Files
magpie/internal/davsync/restore.go
T
yetone 76f3b97c09 sync: it can sync by itself less often or only when asked, and the server's setup can be restored over this computer's, undoably (Sun1090, #847: 同步太快了,需要设置个开关控制自动同步,还有恢复按钮) — the gateway's magpie synced every 3 minutes with no way to change it, and a free 坚果云 account is allowed only so many WebDAV requests (600 per 30 minutes, 1 GB up and 3 GB down a month); and there was no way to take the server's setup back over a computer's own, which the next sync would otherwise push up. Settings › Sync now has Sync by itself, the app's segmented control (no <select>): Off, 3 min (the default, as before), 15 min, 30 min, 1 hour; Off syncs only on Sync now, and the sync row says only when asked. The loop reads the setting each minute (no request), so a change takes effect without a restart, and a server limiting requests is still waited for as backoff says. Restore from the server asks first in the app's own dialog, naming the parts replaced; it reads the server's file as it is now, seals this computer's setup (keys and all) into the sync folder as <time>-before-restore (newest 5 kept), brings in each part that differs as a sync brings one changed only there, and leaves the state as such a sync would, so the next sync pushes nothing back. Its notice says what was restored, with Undo, which brings the kept setup back (the next sync then takes it to the others); OK, or a later sync's own notice, drops the undo. The form's Save keeps the setting (SetAuto alone sets it). CLI: magpie webdav|s3 auto off|3|15|30|60, restore, undo; the status says how often it syncs by itself. zh, ja, de strings. TestAutoSync (off: no request by itself; back to 15 min: one sync, then none; Save keeps it; 7 refused) and TestRestore (b's own changes replaced by the server's with no push, the kept copy holds b's provider and key, Undo puts it back and a gets it, nothing differing restores nothing) pass -race; sync-auto-restore.test.cjs (WebKit, en and zh: Off posts -1 and the rows say so, Cancel posts nothing, Restore and Undo post, no click moves the page) fails on the old code. sync-other-kind, sync-spin, s3-sync, webdav-settings-refresh, usage-computers, click-scroll, gui-ja, gui-de and settings-sections pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
2026-10-05 10:36:46 +08:00

199 lines
5.2 KiB
Go

package davsync
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"time"
"github.com/yetone/magpie/internal/backup"
"github.com/yetone/magpie/internal/edit"
)
// ErrOff is a Restore, Undo or SetAuto asked for with sync off.
var ErrOff = errors.New("sync is off")
// SetAuto sets how many minutes apart the gateway's magpie syncs by
// itself, one of AutoChoices; Manual: never, only when asked (#847).
func SetAuto(minutes int) error {
if !slices.Contains(AutoChoices, minutes) {
return fmt.Errorf("sync by itself every 3, 15, 30 or 60 minutes, or never, not %d", minutes)
}
return locked(func() error {
c, ok := Load()
if !ok {
return ErrOff
}
c.AutoEvery = minutes
if minutes == int(Every/time.Minute) {
c.AutoEvery = 0
}
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
if err := edit.WriteAtomic(path("sync.json"), b); err != nil {
return err
}
return os.Chmod(path("sync.json"), 0o600)
})
}
// Restore makes this computer's setup the server's, whatever changed here:
// each part synced that differs is brought in, as a sync brings in a part
// changed only there. What was here first is sealed into the sync folder,
// with the API keys, and Undo brings it back. It is the parts brought in.
func Restore(ctx context.Context) ([]string, error) {
var brought []string
err := locked(func() error {
c, ok := Load()
if !ok {
return ErrOff
}
d, err := newRemote(c)
if err != nil {
return err
}
data, ver, err := d.get(ctx, version{}) // the file as it is now, never the cached copy
if err != nil {
return err
}
if data == nil {
return errors.New("there is no magpie setup on the server yet: nothing to restore")
}
remote, err := backup.Open(data, c.Passphrase)
if errors.Is(err, backup.ErrPassphrase) {
return errors.New("the passphrase doesn't open the file on the server: it was sealed with another one; use the passphrase set on your other computers")
}
if err != nil {
return err
}
local, err := collect(c)
if err != nil {
return err
}
L, R := hashes(local), hashes(remote)
for _, p := range Parts {
if L[p] == R[p] || !c.syncs(p) || (p == "library" && remote.Library == nil) {
continue
}
brought = append(brought, p)
}
st := loadState()
if st.Key != stateKey(c) {
st = state{Key: stateKey(c)}
}
if len(brought) > 0 {
kept, err := keepHere(c)
if err != nil {
return fmt.Errorf("keeping this computer's setup before restoring: %w", err)
}
for _, p := range brought {
if err := bring(remote, p); err != nil {
return fmt.Errorf("bringing in the %s: %w", p, err)
}
}
if local, err = collect(c); err != nil {
return err
}
st.Undo = kept
st.Notice = &Notice{At: time.Now(), Here: brought, Saved: filepath.Dir(kept), Restored: true}
}
// as a sync that brought them in leaves it: the next one has
// nothing to push back
st.Local, st.Remote, st.Sum, st.Server = hashes(local), R, sum(data), ver
remember(data)
st.Last, st.Error = time.Now(), ""
saveState(st)
return nil
})
return brought, err
}
// Undo puts back the setup the last Restore replaced. The next sync takes
// it to the server, as any change made here.
func Undo() ([]string, error) {
var back []string
err := locked(func() error {
c, ok := Load()
if !ok {
return ErrOff
}
st := loadState()
if st.Key != stateKey(c) || st.Undo == "" {
return errors.New("there is no restore to undo")
}
data, err := os.ReadFile(st.Undo)
if err != nil {
return fmt.Errorf("the copy kept before the restore is gone: %w", err)
}
kept, err := backup.Open(data, c.Passphrase)
if err != nil {
return err
}
local, err := collect(withKeys(c))
if err != nil {
return err
}
L, K := hashes(local), hashes(kept)
for _, p := range Parts {
if L[p] == K[p] || !c.syncs(p) || (p == "library" && kept.Library == nil) {
continue
}
if err := bring(kept, p); err != nil {
return fmt.Errorf("bringing back the %s: %w", p, err)
}
back = append(back, p)
}
st.Undo, st.Notice = "", nil
saveState(st)
return nil
})
return back, err
}
// syncs is whether part goes to the server with c.
func (c Config) syncs(part string) bool {
return (part != "agents" || c.Agents) && (part != "library" || c.library())
}
func withKeys(c Config) Config { c.Keys = true; return c }
// keepHere seals this computer's setup, keys and all, into the sync
// folder before a restore replaces it; the path. The newest few are kept.
func keepHere(c Config) (string, error) {
b, err := collect(withKeys(c))
if err != nil {
return "", err
}
sealed, err := backup.Seal(b, c.Passphrase)
if err != nil {
return "", err
}
dir := path("sync")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
name := filepath.Join(dir, time.Now().Format("2006-01-02-150405")+"-before-restore"+backup.Ext)
if err := edit.WriteAtomic(name, sealed); err != nil {
return "", err
}
os.Chmod(name, 0o600)
old, _ := filepath.Glob(filepath.Join(dir, "*-before-restore"+backup.Ext))
slices.Sort(old)
for len(old) > 5 {
os.Remove(old[0])
old = old[1:]
}
return name, nil
}
func isFile(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.Mode().IsRegular()
}