package main import ( "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "time" ) const sessionName = "datastorage-dropbox" 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"` SessionName string `json:"session_name"` } func defaultConfig() Config { return Config{ Enabled: false, IntervalMinutes: 15, Rclone: "rclone", CheckFilename: "RCLONE_TEST", SessionName: sessionName, } } 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" } if cfg.SessionName == "" { cfg.SessionName = sessionName } if cfg.CheckFilename == "" { cfg.CheckFilename = "RCLONE_TEST" } 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(8000,'%s','%s',[System.Windows.Forms.ToolTipIcon]::Info); Start-Sleep -Seconds 8; $n.Dispose()`, escapePS(title), escapePS(body)) _ = exec.Command("powershell", "-NoProfile", "-Command", ps).Run() } } func escapePS(s string) string { return strings.ReplaceAll(s, "'", "''") } func listingFiles(cfg Config) (path1, path2 string) { base := filepath.Join(cfg.Workdir, cfg.SessionName) return base + ".path1.lst", base + ".path2.lst" } func listingsExist(cfg Config) bool { p1, p2 := listingFiles(cfg) _, e1 := os.Stat(p1) _, e2 := os.Stat(p2) return e1 == nil && e2 == nil } func listingsPartial(cfg Config) bool { p1, p2 := listingFiles(cfg) _, e1 := os.Stat(p1) _, e2 := os.Stat(p2) return (e1 == nil) != (e2 == nil) } func setEnabled(v bool) { p := configPath() b, err := os.ReadFile(p) if err != nil { return } var m map[string]any if json.Unmarshal(b, &m) != nil { return } m["enabled"] = v out, err := json.MarshalIndent(m, "", " ") if err != nil { return } _ = os.WriteFile(p, append(out, '\n'), 0600) } func remoteHasCheckFile(cfg Config) bool { cmd := exec.Command(cfg.Rclone, "lsf", cfg.Path2, "--include", cfg.CheckFilename) out, err := cmd.CombinedOutput() if err != nil { return false } return strings.Contains(string(out), cfg.CheckFilename) } func localHasCheckFile(cfg Config) bool { _, err := os.Stat(filepath.Join(cfg.Path1, cfg.CheckFilename)) return err == nil } func acceptPath(cfg Config) string { return filepath.Join(cfg.Workdir, "resync-accepted") } func acquireLock(workdir string) (func(), error) { if err := os.MkdirAll(workdir, 0700); err != nil { return nil, err } lockdir := filepath.Join(workdir, ".bisyncd.lock") if err := os.Mkdir(lockdir, 0700); err != nil { info, statErr := os.Stat(lockdir) if statErr == nil && time.Since(info.ModTime()) > 3*time.Hour { _ = os.RemoveAll(lockdir) if err2 := os.Mkdir(lockdir, 0700); err2 == nil { return func() { _ = os.RemoveAll(lockdir) }, nil } } return nil, errors.New("another rclone-bisyncd holds " + lockdir) } _ = os.WriteFile(filepath.Join(lockdir, "pid"), []byte(fmt.Sprintf("%d", os.Getpid())), 0600) return func() { _ = os.RemoveAll(lockdir) }, nil } func bisyncArgs(cfg Config, extra ...string) []string { args := []string{ "bisync", cfg.Path1, cfg.Path2, "--workdir", cfg.Workdir, "--session-name", cfg.SessionName, "--delta-list", "--check-access", "--check-filename", cfg.CheckFilename, "--compare", "size,modtime,checksum", "--slow-hash-sync-only", "--max-delete", "1", "--max-delete-files", "100", "--max-delete-size", "1G", "--max-lock", "2h", "--recover", "--conflict-resolve", "none", "--resync-mode", "newer", } return append(args, extra...) } func runRclone(cfg Config, extra ...string) error { cmd := exec.Command(cfg.Rclone, bisyncArgs(cfg, extra...)...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } func runOnce(cfg Config, acceptResync bool) error { if cfg.Path1 == "" || cfg.Path2 == "" || cfg.Workdir == "" { return fmt.Errorf("path1, path2, and workdir must be set in %s", configPath()) } unlock, err := acquireLock(cfg.Workdir) if err != nil { fmt.Println(err) return nil } defer unlock() if !localHasCheckFile(cfg) { return fmt.Errorf("missing local %s under %s", cfg.CheckFilename, cfg.Path1) } if !remoteHasCheckFile(cfg) { notify("rclone-bisync blocked", "No "+cfg.CheckFilename+" on "+cfg.Path2+" — copy it, then set enabled=true") return fmt.Errorf("missing %s on %s", cfg.CheckFilename, cfg.Path2) } if listingsPartial(cfg) { return fmt.Errorf("incomplete listings in %s (one side missing) — not auto --resync; fix or delete both .lst files", cfg.Workdir) } if !listingsExist(cfg) { // First use only: never auto --resync after a failed normal run. if !acceptResync { notify("rclone-bisync", "No listings — dry-run --resync --resync-mode newer") if err := runRclone(cfg, "--dry-run", "--resync"); err != nil { notify("rclone-bisync dry-run failed", err.Error()) return err } } notify("rclone-bisync", "Live --resync --resync-mode newer (first listings)") if err := runRclone(cfg, "--resync"); err != nil { notify("rclone-bisync resync failed", err.Error()) return err } _ = os.WriteFile(acceptPath(cfg), []byte("ok\n"), 0600) setEnabled(true) notify("rclone-bisync", "Resync finished; enabled=true") return nil } if !cfg.Enabled { return fmt.Errorf("disabled in %s (listings exist; set enabled=true)", configPath()) } if err := runRclone(cfg); err != nil { notify("rclone-bisync failed", err.Error()) return err } notify("rclone-bisync", "Run finished") return nil } func main() { once := false accept := false for _, a := range os.Args[1:] { switch a { case "--once": once = true case "--accept-resync": accept = true once = true case "--help", "-h": fmt.Fprintf(os.Stderr, "rclone-bisyncd [--once] [--accept-resync]\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 err := runOnce(cfg, accept); err != nil { fmt.Fprintf(os.Stderr, "bisync: %v\n", err) if once { os.Exit(1) } } else if once { return } if once { return } mins := 15 if cfg, err := loadConfig(); err == nil && cfg.IntervalMinutes > 0 { mins = cfg.IntervalMinutes } time.Sleep(time.Duration(mins) * time.Minute) } }