Files
rclone-dropbox-bisync/cmd/rclone-bisyncd/main.go
T
f-denkena e94fca5ae1 daemon: auto --resync on first use after successful dry-run
Both RCLONE_TEST required. Never after a failed normal run. Partial
listings abort. Enables config after live resync.
2026-09-10 01:14:53 +02:00

288 lines
7.3 KiB
Go

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", "10",
"--max-delete-size", "1G",
"--max-lock", "2h",
"--recover",
"--conflict-resolve", "none",
}
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")
if err := runRclone(cfg, "--dry-run", "--resync"); err != nil {
notify("rclone-bisync dry-run failed", err.Error())
return err
}
}
notify("rclone-bisync", "Live --resync (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)
}
}