Both RCLONE_TEST required. Never after a failed normal run. Partial listings abort. Enables config after live resync.
288 lines
7.3 KiB
Go
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)
|
|
}
|
|
}
|