Created
February 11, 2026 10:40
-
-
Save morvy/02df87d8de6ffeff3c5adebb29c790d0 to your computer and use it in GitHub Desktop.
WinSCP Compare Files — Wine/Linux Meld alternative to WinMerge
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # | |
| # WinSCP Compare Files — Wine/Linux Fix | |
| # ====================================== | |
| # | |
| # Fix for WinSCP's broken "Compare Files" feature when running under Wine on Linux. | |
| # | |
| # PROBLEM: | |
| # WinSCP's built-in Compare Files extension is PowerShell-based. On native | |
| # Windows this works perfectly — WinMerge opens with the temp file vs local | |
| # file, edits are detected and uploaded. Under Wine, it fails because | |
| # PowerShell is not available. The extension either does nothing or falls | |
| # back to prompting for a new session. | |
| # | |
| # WHY IS THIS SO COMPLICATED: | |
| # Simply replacing PowerShell with a batch script isn't enough. Wine's | |
| # virtual filesystem creates a subtle problem: when WinSCP downloads | |
| # a remote file to a temp location, the file exists inside Wine's VFS | |
| # but is NOT visible to native Linux processes. A native diff tool | |
| # launched via Wine's `start /unix` simply cannot see the temp file. | |
| # The workaround: a batch script running inside Wine (where the file IS | |
| # visible) copies it to /tmp on the real Linux filesystem, then launches | |
| # the native diff tool. When the tool closes, modifications are copied | |
| # back to Wine's temp location so WinSCP detects the change and uploads. | |
| # | |
| # WHAT THIS INSTALLER DOES: | |
| # - Detects WinSCP installation path and Wine prefix automatically | |
| # - Checks for Meld, offers to install it (pacman/apt/dnf/zypper/flatpak) | |
| # - Creates a .WinSCPextension.bat that replaces the broken built-in | |
| # - Creates a Wine batch wrapper that bridges Wine VFS → Linux filesystem | |
| # - Creates a native Linux helper script for launching Meld | |
| # - Updates WinSCP.ini (disables broken extension, adds Meld custom command) | |
| # - Backs up WinSCP.ini before any changes | |
| # | |
| # AFTER INSTALLATION: | |
| # - Compare Files: Commands menu > Compare Files (Shift+Ctrl+Alt+C) | |
| # - Meld command: Commands menu > Custom Commands > Meld | |
| # - WinSCP blocks while Meld is open (same as native Windows behavior) | |
| # - Edits to the remote file in Meld are detected and uploaded by WinSCP | |
| # | |
| # REQUIREMENTS: | |
| # - WinSCP running under Wine (portable mode with WinSCP.ini) | |
| # - Meld (installed automatically if missing) | |
| # - Bash 4+ (for associative arrays) | |
| # | |
| # TESTED WITH: | |
| # - WinSCP 6.x, Wine 9.x/10.x | |
| # - Arch Linux, Ubuntu, Fedora | |
| # | |
| # KEYWORDS (for searchability): | |
| # WinSCP Wine Linux, WinSCP compare files not working Wine, | |
| # WinSCP extension Wine fix, WinSCP PowerShell Wine replacement, | |
| # WinSCP Meld Linux, WinSCP diff tool Wine, WinSCP compare Wine broken, | |
| # WinSCP new session error compare, WinSCP WinSCPnet.dll Wine | |
| # | |
| # Usage: ./winscp-meld-compare.sh [--winscp-path /path/to/WinSCP] | |
| # [--wine-prefix ~/.wine] | |
| # [--uninstall] | |
| # | |
| # License: MIT | |
| # | |
| set -euo pipefail | |
| # --- Colors --- | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| BLUE='\033[0;34m' | |
| BOLD='\033[1m' | |
| NC='\033[0m' | |
| info() { echo -e "${BLUE}::${NC} $*"; } | |
| ok() { echo -e "${GREEN}::${NC} $*"; } | |
| warn() { echo -e "${YELLOW}:: WARNING:${NC} $*"; } | |
| err() { echo -e "${RED}:: ERROR:${NC} $*" >&2; } | |
| die() { err "$*"; exit 1; } | |
| # --- Defaults --- | |
| WINSCP_PATH="" | |
| WINE_PREFIX="${WINEPREFIX:-$HOME/.wine}" | |
| UNINSTALL=0 | |
| # --- Parse args --- | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --winscp-path) WINSCP_PATH="$2"; shift 2 ;; | |
| --wine-prefix) WINE_PREFIX="$2"; shift 2 ;; | |
| --uninstall) UNINSTALL=1; shift ;; | |
| --help|-h) | |
| echo "Usage: $0 [OPTIONS]" | |
| echo "" | |
| echo "Options:" | |
| echo " --winscp-path PATH Path to WinSCP installation directory" | |
| echo " --wine-prefix PATH Wine prefix (default: \$WINEPREFIX or ~/.wine)" | |
| echo " --uninstall Remove the Compare Files extension" | |
| echo " --help Show this help" | |
| exit 0 | |
| ;; | |
| *) die "Unknown option: $1" ;; | |
| esac | |
| done | |
| # --- Detect WinSCP --- | |
| find_winscp() { | |
| local candidates=( | |
| "/opt/WinSCP" | |
| "$HOME/WinSCP" | |
| "$WINE_PREFIX/drive_c/Program Files/WinSCP" | |
| "$WINE_PREFIX/drive_c/Program Files (x86)/WinSCP" | |
| ) | |
| for dir in "${candidates[@]}"; do | |
| if [[ -f "$dir/WinSCP.exe" ]]; then | |
| echo "$dir" | |
| return 0 | |
| fi | |
| done | |
| # Search Wine prefix | |
| local found | |
| found=$(find "$WINE_PREFIX/drive_c" -maxdepth 3 -name "WinSCP.exe" -type f 2>/dev/null | head -1) | |
| if [[ -n "$found" ]]; then | |
| dirname "$found" | |
| return 0 | |
| fi | |
| # Search common system paths | |
| found=$(find /opt /usr/local -maxdepth 2 -name "WinSCP.exe" -type f 2>/dev/null | head -1) | |
| if [[ -n "$found" ]]; then | |
| dirname "$found" | |
| return 0 | |
| fi | |
| return 1 | |
| } | |
| if [[ -z "$WINSCP_PATH" ]]; then | |
| info "Searching for WinSCP installation..." | |
| WINSCP_PATH=$(find_winscp) || die "WinSCP not found. Use --winscp-path to specify the location." | |
| fi | |
| [[ -f "$WINSCP_PATH/WinSCP.exe" ]] || die "WinSCP.exe not found in $WINSCP_PATH" | |
| [[ -f "$WINSCP_PATH/WinSCP.ini" ]] || die "WinSCP.ini not found in $WINSCP_PATH (portable mode required)" | |
| [[ -d "$WINE_PREFIX/drive_c" ]] || die "Wine prefix not found at $WINE_PREFIX" | |
| ok "WinSCP found at: ${BOLD}$WINSCP_PATH${NC}" | |
| ok "Wine prefix: ${BOLD}$WINE_PREFIX${NC}" | |
| INI="$WINSCP_PATH/WinSCP.ini" | |
| TOOLS_DIR="$WINE_PREFIX/drive_c/winscp-tools" | |
| BIN_DIR="$HOME/.local/bin" | |
| EXTENSION="$WINSCP_PATH/CompareFiles.WinSCPextension.bat" | |
| WAIT_SCRIPT="$BIN_DIR/winscp-meld-wait" | |
| BAT_FILE="$TOOLS_DIR/meld-ext.bat" | |
| # --- Uninstall --- | |
| if [[ "$UNINSTALL" -eq 1 ]]; then | |
| info "Uninstalling Compare Files extension..." | |
| rm -f "$EXTENSION" && ok "Removed extension file" || true | |
| rm -f "$WAIT_SCRIPT" && ok "Removed wait script" || true | |
| rm -f "$BAT_FILE" && ok "Removed bat wrapper" || true | |
| if grep -q 'commonext\\CompareFiles' "$INI" 2>/dev/null; then | |
| sed -i 's/ExtensionsDeleted=commonext\\CompareFiles/ExtensionsDeleted=/' "$INI" | |
| ok "Re-enabled built-in CompareFiles extension in WinSCP.ini" | |
| fi | |
| ok "Uninstall complete. Restart WinSCP to apply changes." | |
| exit 0 | |
| fi | |
| # --- Check/Install Meld --- | |
| install_meld() { | |
| local pm="" | |
| if command -v pacman &>/dev/null; then | |
| pm="pacman" | |
| elif command -v apt &>/dev/null; then | |
| pm="apt" | |
| elif command -v dnf &>/dev/null; then | |
| pm="dnf" | |
| elif command -v zypper &>/dev/null; then | |
| pm="zypper" | |
| fi | |
| if [[ -n "$pm" ]]; then | |
| echo "" | |
| read -rp "$(echo -e "${YELLOW}Install Meld using $pm? [Y/n]${NC} ")" answer | |
| answer="${answer:-y}" | |
| if [[ "$answer" =~ ^[Yy] ]]; then | |
| case "$pm" in | |
| pacman) sudo pacman -S --needed meld ;; | |
| apt) sudo apt install -y meld ;; | |
| dnf) sudo dnf install -y meld ;; | |
| zypper) sudo zypper install -y meld ;; | |
| esac | |
| else | |
| die "Meld is required. Install it manually and re-run this script." | |
| fi | |
| elif command -v flatpak &>/dev/null; then | |
| echo "" | |
| read -rp "$(echo -e "${YELLOW}Install Meld via Flatpak? [Y/n]${NC} ")" answer | |
| answer="${answer:-y}" | |
| if [[ "$answer" =~ ^[Yy] ]]; then | |
| flatpak install -y flathub org.gnome.meld | |
| else | |
| die "Meld is required. Install it manually and re-run this script." | |
| fi | |
| else | |
| die "No supported package manager found. Install Meld manually and re-run this script." | |
| fi | |
| } | |
| MELD_BIN="" | |
| if command -v meld &>/dev/null; then | |
| MELD_BIN=$(command -v meld) | |
| ok "Meld found: ${BOLD}$MELD_BIN${NC}" | |
| elif flatpak list 2>/dev/null | grep -q org.gnome.meld; then | |
| MELD_BIN="flatpak run org.gnome.meld" | |
| ok "Meld found (Flatpak)" | |
| else | |
| warn "Meld is not installed." | |
| install_meld | |
| if command -v meld &>/dev/null; then | |
| MELD_BIN=$(command -v meld) | |
| elif flatpak list 2>/dev/null | grep -q org.gnome.meld; then | |
| MELD_BIN="flatpak run org.gnome.meld" | |
| else | |
| die "Meld installation failed." | |
| fi | |
| ok "Meld installed: ${BOLD}$MELD_BIN${NC}" | |
| fi | |
| # --- Backup WinSCP.ini --- | |
| BACKUP="$INI.bak.$(date +%Y%m%d%H%M%S)" | |
| cp "$INI" "$BACKUP" | |
| ok "Backed up WinSCP.ini to ${BOLD}$(basename "$BACKUP")${NC}" | |
| # --- Create directories --- | |
| mkdir -p "$TOOLS_DIR" "$BIN_DIR" | |
| # --- Create bat wrapper --- | |
| # This runs inside Wine: copies the Wine-only temp file to /tmp, | |
| # launches Meld, waits for it to close, copies changes back. | |
| info "Creating bat wrapper..." | |
| cat > "$BAT_FILE" << 'BATEOF' | |
| @echo off | |
| setlocal | |
| set "REMOTE=%~1" | |
| set "LOCAL=%~2" | |
| rem Create temp dir on the Linux filesystem via Z: drive | |
| set "TMPDIR=Z:\tmp\winscp-compare-%RANDOM%" | |
| mkdir "%TMPDIR%" | |
| rem Copy the remote temp file (only visible in Wine) to Linux filesystem | |
| set "REMOTENAME=%~nx1" | |
| copy "%REMOTE%" "%TMPDIR%\%REMOTENAME%" >nul | |
| rem Launch meld (non-blocking) - wrapper creates .done marker when it closes | |
| start "" /unix @WAIT_SCRIPT@ "%TMPDIR%" "%TMPDIR%\%REMOTENAME%" "%LOCAL%" | |
| rem Wait for meld to close by polling for the .done marker | |
| :waitloop | |
| if exist "%TMPDIR%\.done" goto cleanup | |
| ping -n 2 127.0.0.1 >nul | |
| goto waitloop | |
| :cleanup | |
| rem Copy potentially modified file back to WinSCP temp so it detects the change and uploads | |
| copy /y "%TMPDIR%\%REMOTENAME%" "%REMOTE%" >nul 2>&1 | |
| rmdir /s /q "%TMPDIR%" >nul 2>&1 | |
| endlocal | |
| BATEOF | |
| # Replace placeholder with actual path | |
| sed -i "s|@WAIT_SCRIPT@|$WAIT_SCRIPT|g" "$BAT_FILE" | |
| ok "Created: ${BOLD}$BAT_FILE${NC}" | |
| # --- Create wait script --- | |
| # Native Linux script that runs Meld and signals completion. | |
| info "Creating wait script..." | |
| cat > "$WAIT_SCRIPT" << WAITEOF | |
| #!/bin/bash | |
| # Wrapper: runs meld, creates .done marker when it exits | |
| MARKER_DIR="\$1"; shift | |
| convert_zpath() { | |
| local p="\$1" | |
| [[ "\$p" == [Zz]:* ]] && p="\${p:2}" | |
| echo "\${p//\\\\//}" | |
| } | |
| FILE1=\$(convert_zpath "\$1") | |
| FILE2=\$(convert_zpath "\$2") | |
| $MELD_BIN "\$FILE2" "\$FILE1" || true | |
| MARKER_DIR=\$(convert_zpath "\$MARKER_DIR") | |
| touch "\$MARKER_DIR/.done" | |
| WAITEOF | |
| chmod +x "$WAIT_SCRIPT" | |
| ok "Created: ${BOLD}$WAIT_SCRIPT${NC}" | |
| # --- Create extension file --- | |
| # Convert WinSCP path to Wine path for the @command | |
| # If WinSCP is under Wine's C: drive, use C: path | |
| # If it's on the Linux filesystem, use Z: path | |
| info "Creating extension file..." | |
| TOOLS_WINPATH="C:\\\\winscp-tools" | |
| cat > "$EXTENSION" << EXTEOF | |
| rem @name &Compare Files... | |
| rem @command ${TOOLS_WINPATH}\\meld-ext.bat ! !^! | |
| rem @side Local | |
| rem @flag ApplyToDirectories | |
| rem @description Compare local and remote file using Meld (Wine-compatible) | |
| rem @version 3 | |
| rem @author WinSCP Compare Installer | |
| rem @require WinSCP 5.13.4 | |
| rem @shortcut Shift+Ctrl+Alt+C | |
| EXTEOF | |
| ok "Created: ${BOLD}$EXTENSION${NC}" | |
| # --- Update WinSCP.ini --- | |
| info "Updating WinSCP.ini..." | |
| # Disable built-in CompareFiles extension | |
| if grep -q '^ExtensionsDeleted=$' "$INI"; then | |
| sed -i '0,/^ExtensionsDeleted=$/s/^ExtensionsDeleted=$/ExtensionsDeleted=commonext\\CompareFiles/' "$INI" | |
| ok "Disabled built-in CompareFiles extension" | |
| elif grep -q 'ExtensionsDeleted=commonext\\CompareFiles' "$INI"; then | |
| ok "Built-in CompareFiles already disabled" | |
| else | |
| # ExtensionsDeleted has other values, append | |
| current=$(grep '^ExtensionsDeleted=' "$INI" | head -1 | cut -d= -f2-) | |
| if [[ -n "$current" && "$current" != "0" ]]; then | |
| sed -i "0,/^ExtensionsDeleted=/s/^ExtensionsDeleted=.*/ExtensionsDeleted=${current};commonext\\\\CompareFiles/" "$INI" | |
| else | |
| sed -i '0,/^ExtensionsDeleted=/s/^ExtensionsDeleted=.*/ExtensionsDeleted=commonext\\CompareFiles/' "$INI" | |
| fi | |
| ok "Disabled built-in CompareFiles extension" | |
| fi | |
| # Update ExtensionsPortableCount | |
| if grep -q 'ExtensionsPortableCount=0' "$INI"; then | |
| sed -i 's/ExtensionsPortableCount=0/ExtensionsPortableCount=1/' "$INI" | |
| ok "Set ExtensionsPortableCount=1" | |
| fi | |
| # Add Meld custom command if not present | |
| MELD_CMD="C:%5Cwinscp-tools%5Cmeld-ext.bat%20!%20!^!" | |
| if ! grep -q '^\[Configuration\\CustomCommands\]' "$INI"; then | |
| warn "CustomCommands section not found in WinSCP.ini — skipping custom command setup" | |
| elif ! grep -q "^Meld=" "$INI"; then | |
| sed -i "/^\[Configuration\\\\CustomCommands\]/a Meld=$MELD_CMD" "$INI" | |
| # Add params if section exists | |
| if grep -q '^\[Configuration\\CustomCommandsParams\]' "$INI"; then | |
| sed -i '/^\[Configuration\\CustomCommandsParams\]/a Meld=257' "$INI" | |
| fi | |
| ok "Added Meld custom command" | |
| else | |
| ok "Meld custom command already present" | |
| fi | |
| # --- Summary --- | |
| echo "" | |
| echo -e "${GREEN}${BOLD}Installation complete!${NC}" | |
| echo "" | |
| echo " Extension: $EXTENSION" | |
| echo " Bat wrapper: $BAT_FILE" | |
| echo " Wait script: $WAIT_SCRIPT" | |
| echo " INI backup: $BACKUP" | |
| echo "" | |
| echo -e " ${BOLD}Restart WinSCP to activate.${NC}" | |
| echo "" | |
| echo " Compare Files: Commands > Compare Files (Shift+Ctrl+Alt+C)" | |
| echo " Meld command: Commands > Custom Commands > Meld" | |
| echo "" | |
| echo " To uninstall: $0 --uninstall" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment