From 9b5356d0ab76dcd327915b9781c6ee2f09a90dd3 Mon Sep 17 00:00:00 2001 From: Federico Justus Denkena Date: Thu, 10 Sep 2026 00:24:07 +0200 Subject: [PATCH] Add Windows one-click zip and Debian timer/tray packaging Bundles forked rclone + rclone-bisyncd. Does not vendor dist binaries. --- .gitignore | 4 + README.md | 22 +++ build.sh | 74 ++++++++++ cmd/rclone-bisyncd/go.mod | 3 + cmd/rclone-bisyncd/main.go | 159 +++++++++++++++++++++ config.example.json | 9 ++ linux/autostart/rclone-bisync-tray.desktop | 10 ++ linux/control | 11 ++ linux/session-start.sh | 7 + linux/systemd/rclone-bisync.service | 12 ++ linux/systemd/rclone-bisync.timer | 11 ++ windows/Install.cmd | 2 + windows/Install.ps1 | 35 +++++ windows/config.example.json | 9 ++ windows/tray.ps1 | 24 ++++ 15 files changed, 392 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 build.sh create mode 100644 cmd/rclone-bisyncd/go.mod create mode 100644 cmd/rclone-bisyncd/main.go create mode 100644 config.example.json create mode 100644 linux/autostart/rclone-bisync-tray.desktop create mode 100644 linux/control create mode 100755 linux/session-start.sh create mode 100644 linux/systemd/rclone-bisync.service create mode 100644 linux/systemd/rclone-bisync.timer create mode 100644 windows/Install.cmd create mode 100644 windows/Install.ps1 create mode 100644 windows/config.example.json create mode 100644 windows/tray.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35c66dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +dist/ +*.exe +rclone-linux-amd64 +rclone-bisyncd-linux-amd64 diff --git a/README.md b/README.md new file mode 100644 index 0000000..af8f943 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +rclone-dropbox-bisync 1.0.0 +=========================== + +Forked rclone (ListR + --delta-list, fail-closed) plus a tray/timer. +rclone-ui (Tauri GUI) is NOT inside this zip: this host cannot cross-build +NSIS. Point rclone-ui at the bundled rclone.exe after install. + +Windows (one-click) + 1. Unzip. Double-click Install.cmd + 2. Edit %LOCALAPPDATA%\rclone-dropbox-bisync\config.json + set enabled=true, path1, path2, workdir (NTFS share) + 3. Put RCLONE_TEST on both sides + 4. Tray icon + HKCU Run + schtasks every 15 min + Flags baked in: --delta-list --check-access --max-delete 10 + --compare size,modtime,checksum --slow-hash-sync-only + --resilient --recover --conflict-resolve none + +Linux (.deb) + sudo apt install ./rclone-dropbox-bisync_1.0.0_amd64.deb + (or ar x / dpkg -i) + Log in: autostart enables systemd --user rclone-bisync.timer + Edit ~/.config/rclone-dropbox-bisync/config.json (enabled=true) diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..9c25d47 --- /dev/null +++ b/build.sh @@ -0,0 +1,74 @@ +#!/bin/sh +set -eu +ROOT="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" +DIST="$ROOT/dist" +SRC_RCLONE="${RCLONE_SRC:-$ROOT/../rclone}" +export CGO_ENABLED=0 + +rm -rf "$DIST" +mkdir -p "$DIST/win" "$DIST/deb-root" + +echo "==> rclone linux amd64" +( cd "$SRC_RCLONE" && go build -trimpath -o "$DIST/rclone-linux-amd64" ) +echo "==> rclone windows amd64" +( cd "$SRC_RCLONE" && GOOS=windows GOARCH=amd64 go build -trimpath -o "$DIST/rclone.exe" ) +echo "==> rclone-bisyncd" +( cd "$ROOT/cmd/rclone-bisyncd" && go build -trimpath -o "$DIST/rclone-bisyncd-linux-amd64" ) +( cd "$ROOT/cmd/rclone-bisyncd" && GOOS=windows GOARCH=amd64 go build -trimpath -o "$DIST/rclone-bisyncd.exe" ) + +echo "==> windows zip" +WIN="$DIST/rclone-dropbox-bisync-windows-amd64" +rm -rf "$WIN" +mkdir -p "$WIN" +cp "$DIST/rclone.exe" "$DIST/rclone-bisyncd.exe" \ + "$ROOT/windows/Install.ps1" "$ROOT/windows/Install.cmd" "$ROOT/windows/tray.ps1" \ + "$ROOT/windows/config.example.json" "$WIN/" +( cd "$DIST" && zip -r -q rclone-dropbox-bisync-windows-amd64.zip rclone-dropbox-bisync-windows-amd64 ) + +echo "==> deb" +DEB="$DIST/deb-root" +mkdir -p \ + "$DEB/opt/rclone-dropbox-bisync/bin" \ + "$DEB/usr/lib/systemd/user" \ + "$DEB/etc/xdg/autostart" \ + "$DEB/usr/share/doc/rclone-dropbox-bisync" \ + "$DEB/DEBIAN" +cp "$DIST/rclone-linux-amd64" "$DEB/opt/rclone-dropbox-bisync/bin/rclone" +cp "$DIST/rclone-bisyncd-linux-amd64" "$DEB/opt/rclone-dropbox-bisync/bin/rclone-bisyncd" +cp "$ROOT/linux/session-start.sh" "$DEB/opt/rclone-dropbox-bisync/bin/session-start.sh" +chmod 755 "$DEB/opt/rclone-dropbox-bisync/bin/"* +cp "$ROOT/linux/systemd/rclone-bisync.service" "$ROOT/linux/systemd/rclone-bisync.timer" \ + "$DEB/usr/lib/systemd/user/" +cp "$ROOT/linux/autostart/rclone-bisync-tray.desktop" "$DEB/etc/xdg/autostart/" +cp "$ROOT/config.example.json" "$DEB/usr/share/doc/rclone-dropbox-bisync/" +cat > "$DEB/DEBIAN/postinst" <<'EOF' +#!/bin/sh +set -e +if [ -n "${SUDO_USER:-}" ]; then + uhome=$(getent passwd "$SUDO_USER" | cut -d: -f6) + mkdir -p "$uhome/.config/rclone-dropbox-bisync" + if [ ! -f "$uhome/.config/rclone-dropbox-bisync/config.json" ]; then + cp /usr/share/doc/rclone-dropbox-bisync/config.example.json \ + "$uhome/.config/rclone-dropbox-bisync/config.json" + chown -R "$SUDO_USER:" "$uhome/.config/rclone-dropbox-bisync" + fi +fi +exit 0 +EOF +chmod 755 "$DEB/DEBIAN/postinst" +cp "$ROOT/linux/control" "$DEB/DEBIAN/control" +# Installed-Size in KiB +size=$(du -sk "$DEB" | awk '{print $1}') +echo "Installed-Size: $size" >> "$DEB/DEBIAN/control" + +# ar-based .deb (no dpkg-deb on this host) +pkg="$DIST/deb-pkg" +rm -rf "$pkg" +mkdir -p "$pkg/data" "$pkg" +( cd "$DEB" && tar --owner=0 --group=0 -cJf "$pkg/data.tar.xz" \ + opt usr etc ) +( cd "$DEB/DEBIAN" && tar --owner=0 --group=0 -czf "$pkg/control.tar.gz" . ) +echo 2.0 > "$pkg/debian-binary" +( cd "$pkg" && ar r "$DIST/rclone-dropbox-bisync_1.0.0_amd64.deb" \ + debian-binary control.tar.gz data.tar.xz ) +ls -lh "$DIST"/*.deb "$DIST"/*.zip "$DIST"/rclone* diff --git a/cmd/rclone-bisyncd/go.mod b/cmd/rclone-bisyncd/go.mod new file mode 100644 index 0000000..6d37ac9 --- /dev/null +++ b/cmd/rclone-bisyncd/go.mod @@ -0,0 +1,3 @@ +module rclone-bisyncd + +go 1.22 diff --git a/cmd/rclone-bisyncd/main.go b/cmd/rclone-bisyncd/main.go new file mode 100644 index 0000000..493a961 --- /dev/null +++ b/cmd/rclone-bisyncd/main.go @@ -0,0 +1,159 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +type Config struct { + Enabled bool `json:"enabled"` + Path1 string `json:"path1"` + Path2 string `json:"path2"` + Workdir string `json:"workdir"` + IntervalMinutes int `json:"interval_minutes"` + Rclone string `json:"rclone"` + CheckFilename string `json:"check_filename"` +} + +func defaultConfig() Config { + return Config{ + Enabled: false, + IntervalMinutes: 15, + Rclone: "rclone", + CheckFilename: "RCLONE_TEST", + } +} + +func configPath() string { + if p := os.Getenv("RCLONE_BISYNC_CONFIG"); p != "" { + return p + } + if runtime.GOOS == "windows" { + base := os.Getenv("LOCALAPPDATA") + if base == "" { + base = os.Getenv("USERPROFILE") + } + return filepath.Join(base, "rclone-dropbox-bisync", "config.json") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "rclone-dropbox-bisync", "config.json") +} + +func loadConfig() (Config, error) { + cfg := defaultConfig() + b, err := os.ReadFile(configPath()) + if err != nil { + return cfg, err + } + if err := json.Unmarshal(b, &cfg); err != nil { + return cfg, err + } + if cfg.IntervalMinutes < 5 { + cfg.IntervalMinutes = 5 + } + if cfg.Rclone == "" { + cfg.Rclone = "rclone" + } + return cfg, nil +} + +func notify(title, body string) { + switch runtime.GOOS { + case "linux": + _ = exec.Command("notify-send", "-a", "rclone-bisync", title, body).Run() + case "windows": + ps := fmt.Sprintf(`Add-Type -AssemblyName System.Windows.Forms; $n=New-Object System.Windows.Forms.NotifyIcon; $n.Icon=[System.Drawing.SystemIcons]::Information; $n.Visible=$true; $n.ShowBalloonTip(4000,'%s','%s',[System.Windows.Forms.ToolTipIcon]::Info); Start-Sleep -Seconds 5; $n.Dispose()`, + escapePS(title), escapePS(body)) + _ = exec.Command("powershell", "-NoProfile", "-Command", ps).Run() + } +} + +func escapePS(s string) string { + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + if s[i] == '\'' { + out = append(out, '\'', '\'') + } else { + out = append(out, s[i]) + } + } + return string(out) +} + +func runOnce(cfg Config) error { + if !cfg.Enabled { + return fmt.Errorf("disabled in %s", configPath()) + } + if cfg.Path1 == "" || cfg.Path2 == "" || cfg.Workdir == "" { + return fmt.Errorf("path1, path2, and workdir must be set in %s", configPath()) + } + args := []string{ + "bisync", cfg.Path1, cfg.Path2, + "--workdir", cfg.Workdir, + "--delta-list", + "--check-access", + "--check-filename", cfg.CheckFilename, + "--compare", "size,modtime,checksum", + "--slow-hash-sync-only", + "--max-delete", "10", + "--resilient", + "--recover", + "--conflict-resolve", "none", + } + cmd := exec.Command(cfg.Rclone, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func main() { + once := false + loop := true + for _, a := range os.Args[1:] { + switch a { + case "--once": + once = true + loop = false + case "--help", "-h": + fmt.Fprintf(os.Stderr, "rclone-bisyncd [--once]\nconfig: %s\n", configPath()) + os.Exit(0) + } + } + + for { + cfg, err := loadConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "config: %v\n", err) + notify("rclone-bisync", "No config yet — edit "+configPath()) + if once { + os.Exit(1) + } + } else if !cfg.Enabled { + fmt.Println("disabled; sleeping") + } else if err := runOnce(cfg); err != nil { + fmt.Fprintf(os.Stderr, "bisync: %v\n", err) + notify("rclone-bisync failed", err.Error()) + if once { + os.Exit(1) + } + } else { + notify("rclone-bisync", "Run finished") + if once { + return + } + } + if !loop { + return + } + mins := 15 + if cfg, err := loadConfig(); err == nil && cfg.IntervalMinutes > 0 { + mins = cfg.IntervalMinutes + } + time.Sleep(time.Duration(mins) * time.Minute) + } +} diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..12dd1d8 --- /dev/null +++ b/config.example.json @@ -0,0 +1,9 @@ +{ + "enabled": false, + "path1": "/mnt/ntfs/Dropbox", + "path2": "dropbox:", + "workdir": "/mnt/ntfs/rclone-bisync", + "interval_minutes": 15, + "rclone": "/opt/rclone-dropbox-bisync/bin/rclone", + "check_filename": "RCLONE_TEST" +} diff --git a/linux/autostart/rclone-bisync-tray.desktop b/linux/autostart/rclone-bisync-tray.desktop new file mode 100644 index 0000000..61df297 --- /dev/null +++ b/linux/autostart/rclone-bisync-tray.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=rclone Dropbox bisync +Comment=Enable user timer and show notifications for Dropbox bisync +Exec=/opt/rclone-dropbox-bisync/bin/session-start.sh +Icon=folder-remote +Terminal=false +Categories=Network;FileTransfer; +X-GNOME-Autostart-enabled=true +StartupNotify=false diff --git a/linux/control b/linux/control new file mode 100644 index 0000000..a17ec6c --- /dev/null +++ b/linux/control @@ -0,0 +1,11 @@ +Package: rclone-dropbox-bisync +Version: 1.0.0 +Section: net +Priority: optional +Architecture: amd64 +Maintainer: local +Depends: libnotify-bin +Description: Forked rclone Dropbox bisync with fail-closed defaults + Bundles a patched rclone (ListR + --delta-list) and a user timer/tray + helper. Does not run until ~/.config/rclone-dropbox-bisync/config.json + has enabled=true and valid paths. diff --git a/linux/session-start.sh b/linux/session-start.sh new file mode 100755 index 0000000..248457e --- /dev/null +++ b/linux/session-start.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +systemctl --user daemon-reload 2>/dev/null || true +systemctl --user enable --now rclone-bisync.timer 2>/dev/null || true +if command -v notify-send >/dev/null 2>&1; then + notify-send -a rclone-bisync "rclone-bisync" "Background timer enabled. Edit ~/.config/rclone-dropbox-bisync/config.json (set enabled=true)." +fi diff --git a/linux/systemd/rclone-bisync.service b/linux/systemd/rclone-bisync.service new file mode 100644 index 0000000..758d0db --- /dev/null +++ b/linux/systemd/rclone-bisync.service @@ -0,0 +1,12 @@ +[Unit] +Description=rclone Dropbox bisync (fail-closed) +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/opt/rclone-dropbox-bisync/bin/rclone-bisyncd --once +Nice=10 + +[Install] +WantedBy=default.target diff --git a/linux/systemd/rclone-bisync.timer b/linux/systemd/rclone-bisync.timer new file mode 100644 index 0000000..dd4340a --- /dev/null +++ b/linux/systemd/rclone-bisync.timer @@ -0,0 +1,11 @@ +[Unit] +Description=rclone Dropbox bisync timer + +[Timer] +OnBootSec=3min +OnUnitActiveSec=15min +Persistent=true +Unit=rclone-bisync.service + +[Install] +WantedBy=timers.target diff --git a/windows/Install.cmd b/windows/Install.cmd new file mode 100644 index 0000000..eb42a05 --- /dev/null +++ b/windows/Install.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Install.ps1" diff --git a/windows/Install.ps1 b/windows/Install.ps1 new file mode 100644 index 0000000..5448175 --- /dev/null +++ b/windows/Install.ps1 @@ -0,0 +1,35 @@ +# One-click user install (no admin required). +$ErrorActionPreference = 'Stop' +$Root = Split-Path -Parent $MyInvocation.MyCommand.Path +$Dest = Join-Path $env:LOCALAPPDATA 'rclone-dropbox-bisync' +New-Item -ItemType Directory -Force -Path (Join-Path $Dest 'bin') | Out-Null +Copy-Item -Force (Join-Path $Root 'rclone.exe') (Join-Path $Dest 'bin\rclone.exe') +Copy-Item -Force (Join-Path $Root 'rclone-bisyncd.exe') (Join-Path $Dest 'bin\rclone-bisyncd.exe') +Copy-Item -Force (Join-Path $Root 'tray.ps1') (Join-Path $Dest 'tray.ps1') +$cfg = Join-Path $Dest 'config.json' +if (-not (Test-Path $cfg)) { + Copy-Item (Join-Path $Root 'config.example.json') $cfg + $j = Get-Content $cfg -Raw | ConvertFrom-Json + $j.rclone = Join-Path $Dest 'bin\rclone.exe' + $j | ConvertTo-Json | Set-Content -Encoding UTF8 $cfg +} + +$bin = Join-Path $Dest 'bin' +$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') +if ($userPath -notlike "*$bin*") { + [Environment]::SetEnvironmentVariable('Path', "$bin;$userPath", 'User') +} + +$run = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' +New-ItemProperty -Path $run -Name 'rclone-dropbox-bisync' -PropertyType String -Force ` + -Value "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$Dest\tray.ps1`"" | Out-Null + +schtasks /Create /F /TN "rclone-dropbox-bisync" /SC MINUTE /MO 15 /TR ` + "`"$Dest\bin\rclone-bisyncd.exe`" --once" /ST 00:00 | Out-Null + +Start-Process powershell.exe -WindowStyle Hidden -ArgumentList @( + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $Dest 'tray.ps1') +) + +Add-Type -AssemblyName PresentationFramework +[System.Windows.MessageBox]::Show("Installed to $Dest`r`nEdit config.json (enabled, path1, path2, workdir).`r`nPut RCLONE_TEST on both sides. Tray + 15-min task are on.", 'rclone-dropbox-bisync') diff --git a/windows/config.example.json b/windows/config.example.json new file mode 100644 index 0000000..7f20883 --- /dev/null +++ b/windows/config.example.json @@ -0,0 +1,9 @@ +{ + "enabled": false, + "path1": "D:\\Dropbox", + "path2": "dropbox:", + "workdir": "D:\\rclone-bisync", + "interval_minutes": 15, + "rclone": "rclone.exe", + "check_filename": "RCLONE_TEST" +} diff --git a/windows/tray.ps1 b/windows/tray.ps1 new file mode 100644 index 0000000..6063e57 --- /dev/null +++ b/windows/tray.ps1 @@ -0,0 +1,24 @@ +$ErrorActionPreference = 'SilentlyContinue' +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$Dest = Split-Path -Parent $MyInvocation.MyCommand.Path +$Daemon = Join-Path $Dest 'bin\rclone-bisyncd.exe' +$Cfg = Join-Path $Dest 'config.json' + +$icon = New-Object System.Windows.Forms.NotifyIcon +$icon.Icon = [System.Drawing.SystemIcons]::Application +$icon.Text = 'rclone Dropbox bisync' +$icon.Visible = $true + +$menu = New-Object System.Windows.Forms.ContextMenuStrip +$run = $menu.Items.Add('Run bisync now') +$run.add_Click({ Start-Process $Daemon -ArgumentList '--once' -WindowStyle Hidden }) +$edit = $menu.Items.Add('Edit config.json') +$edit.add_Click({ Start-Process notepad.exe $Cfg }) +$quit = $menu.Items.Add('Quit tray') +$quit.add_Click({ $icon.Visible = $false; [System.Windows.Forms.Application]::Exit() }) +$icon.ContextMenuStrip = $menu +$icon.ShowBalloonTip(4000, 'rclone-bisync', 'Tray running. 15-min task is registered.', [System.Windows.Forms.ToolTipIcon]::Info) + +[System.Windows.Forms.Application]::Run()