From 76f3b97c097436ee6c8d39440385ade81e064609 Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 5 Oct 2026 10:34:46 +0800 Subject: [PATCH] =?UTF-8?q?sync:=20it=20can=20sync=20by=20itself=20less=20?= =?UTF-8?q?often=20or=20only=20when=20asked,=20and=20the=20server's=20setu?= =?UTF-8?q?p=20can=20be=20restored=20over=20this=20computer's,=20undoably?= =?UTF-8?q?=20(Sun1090,=20#847:=20=E5=90=8C=E6=AD=A5=E5=A4=AA=E5=BF=AB?= =?UTF-8?q?=E4=BA=86=EF=BC=8C=E9=9C=80=E8=A6=81=E8=AE=BE=E7=BD=AE=E4=B8=AA?= =?UTF-8?q?=E5=BC=80=E5=85=B3=E6=8E=A7=E5=88=B6=E8=87=AA=E5=8A=A8=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=EF=BC=8C=E8=BF=98=E6=9C=89=E6=81=A2=E5=A4=8D=E6=8C=89?= =?UTF-8?q?=E9=92=AE)=20=E2=80=94=20the=20gateway's=20magpie=20synced=20ev?= =?UTF-8?q?ery=203=20minutes=20with=20no=20way=20to=20change=20it,=20and?= =?UTF-8?q?=20a=20free=20=E5=9D=9A=E6=9E=9C=E4=BA=91=20account=20is=20allo?= =?UTF-8?q?wed=20only=20so=20many=20WebDAV=20requests=20(600=20per=2030=20?= =?UTF-8?q?minutes,=201=20GB=20up=20and=203=20GB=20down=20a=20month);=20an?= =?UTF-8?q?d=20there=20was=20no=20way=20to=20take=20the=20server's=20setup?= =?UTF-8?q?=20back=20over=20a=20computer's=20own,=20which=20the=20next=20s?= =?UTF-8?q?ync=20would=20otherwise=20push=20up.=20Settings=20=E2=80=BA=20S?= =?UTF-8?q?ync=20now=20has=20Sync=20by=20itself,=20the=20app's=20segmented?= =?UTF-8?q?=20control=20(no=20) shows every +// 3 minutes; picking Off posts minutes -1, the row then says only Sync now +// syncs and the sync row says only when asked. Restore from the server asks +// first in the app's own dialog, naming what is replaced and that it is kept +// to undo; Cancel posts nothing; Restore posts it, and the notice it leaves +// has Undo, which posts undo. No click moves the page. In English and +// Chinese, Chromium and WebKit. The API is faked; nothing is synced. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); + +function serve(lang, posts) { + const settings = { + theme: "light", lang, tray: "panel", version: "test", dir: "/tmp/magpie", + gateway: "http://127.0.0.1:3425", visionModels: [], imageGenModels: [], + fx: { rate: 7.2, stale: false }, + }; + let sync = { on: true, kind: "webdav", url: "https://dav.jianguoyun.com/dav/", user: "me", passwordSet: true, passphraseSet: true, + keys: true, agents: true, library: true, auto: 3, last: new Date().toISOString() }; + return async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + const json = (data) => route.fulfill({ json: data }); + if (pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` }); + if (pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (pathname === "/api/state") return json({ agents: [], profiles: [], settings }); + if (pathname === "/api/settings") return json(settings); + if (pathname === "/api/usage/quotas") return json([]); + if (pathname === "/api/groups") return json({ groups: [], models: [] }); + if (pathname === "/api/plugins") return json({ plugins: [] }); + if (pathname === "/api/davsync") return json(sync); + if (pathname.startsWith("/api/davsync/")) { + const action = pathname.slice("/api/davsync/".length); + posts.push({ action, body: request.postDataJSON() }); + if (action === "auto") sync = { ...sync, auto: Math.max(0, request.postDataJSON().minutes) }; + if (action === "restore") { + sync = { ...sync, undo: true, notice: { at: new Date().toISOString(), here: ["providers", "settings"], saved: "/tmp/magpie/sync", restored: true } }; + return json({ ...sync, brought: ["providers", "settings"] }); + } + if (action === "undo") sync = { ...sync, undo: false, notice: undefined }; + return json(sync); + } + if (pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, pathname === "/" ? "index.html" : pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + return route.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +const words = { + en: { + auto: "Sync by itself", every3: "Every 3 minutes while magpie runs", off: "Off", offSub: "Off: only Sync now syncs", + asked: " · only when asked", restoreRow: "Restore from the server", restore: "Restore…", ask: "Restore from the server?", + kept: "What is here now is sealed into the sync folder first, and Undo puts it back.", go: "Restore setup", cancel: "Cancel", + restored: "Restored from the server: providers, settings", undo: "Undo", + }, + zh: { + auto: "自动同步", every3: "magpie 运行时每 3 分钟同步一次", off: "关闭", offSub: "已关闭,仅在点「立即同步」时同步", + asked: " · 仅手动", restoreRow: "从服务器恢复", restore: "恢复…", ask: "从服务器恢复?", + kept: "当前配置会先加密备份到 sync 文件夹,可点「撤销」换回。", go: "恢复配置", cancel: "取消", + restored: "已从服务器恢复:", undo: "撤销", + }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + const w = words[lang]; + test(`${engine} ${lang}: sync by itself can be turned off, and the server's setup restored and undone`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const page = await (await browser.newContext({ viewport: { width: 900, height: 700 }, reducedMotion: "reduce" })).newPage(); + const errors = [], posts = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, posts)); + t.after(async () => { + if (process.env.ARTIFACT_DIR) { + await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true }); + await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-sync-auto-restore.png`), fullPage: true }); + } + await browser.close(); + }); + page.setDefaultTimeout(5000); + + await page.goto("http://magpie.test/"); + await page.locator("#prefs").click(); + await page.locator("#setTab-sync").click(); + const list = page.locator("#syncList"); + const rowNamed = (name) => list.locator(".row.pref").filter({ has: page.locator(".name", { hasText: name }) }); + const auto = rowNamed(w.auto); + await auto.waitFor({ state: "visible" }); + const toEnd = async () => { + await page.mouse.move(450, 300); + const left = () => page.locator("#view-settings").evaluate((v) => v.scrollHeight - v.clientHeight - v.scrollTop); + for (let i = 0; i < 120 && (await left()) > 0.5; i++) { await page.mouse.wheel(0, 80); await page.waitForTimeout(15); } + await page.waitForTimeout(300); + }; + await toEnd(); + const top = () => page.evaluate(() => document.querySelector("#view-settings").scrollTop); + assert.equal(await page.locator("#syncList select").count(), 0, "no native select"); + assert.equal(await auto.locator(".sub").textContent(), w.every3); + assert.equal(await auto.locator(".segs .opt.on").textContent(), lang === "en" ? "3 min" : "3 分钟"); + + // off: only when asked + let at = await top(); + await auto.locator(".segs .opt", { hasText: w.off }).click(); + await page.waitForFunction((s) => [...document.querySelectorAll("#syncList .row.pref .sub")].some((x) => x.textContent === s), w.offSub); + assert.deepEqual(posts.at(-1), { action: "auto", body: { minutes: -1 } }); + const first = list.locator(".row.pref").first(); + assert.ok((await first.locator(".sub").textContent()).endsWith(w.asked), "the sync row says it syncs only when asked"); + assert.equal(await top(), at, "picking Off didn't move the page"); + + // restore asks first; cancel posts nothing + const restoreRow = rowNamed(w.restoreRow); + await restoreRow.locator("button", { hasText: w.restore }).click(); + const dialog = page.locator(".restore-ask"); + await dialog.waitFor({ state: "visible" }); + assert.equal(await dialog.locator(".ehead b").textContent(), w.ask); + assert.ok((await dialog.textContent()).includes(w.kept), "the dialog says what is here is kept to undo"); + const n = posts.length; + await dialog.locator("button", { hasText: w.cancel }).click(); + await dialog.waitFor({ state: "detached" }); + assert.equal(posts.length, n, "Cancel restored nothing"); + + // restore: posted, and the notice it leaves undoes it + at = await top(); + await restoreRow.locator("button", { hasText: w.restore }).click(); + await page.locator(".restore-ask button", { hasText: w.go }).click(); + const note = list.locator(".sync-note"); + await note.waitFor({ state: "visible" }); + assert.equal(posts.at(-1).action, "restore"); + assert.ok((await note.textContent()).includes(w.restored), "the notice says what was restored"); + await note.locator("button", { hasText: w.undo }).click(); + await note.waitFor({ state: "detached" }); + assert.equal(posts.at(-1).action, "undo"); + assert.equal(await top(), at, "restoring and undoing didn't move the page"); + assert.deepEqual(errors, []); + }); + } +} diff --git a/webdav_cli.go b/webdav_cli.go index 9737d849..55bf20e5 100644 --- a/webdav_cli.go +++ b/webdav_cli.go @@ -28,7 +28,10 @@ const webdavUsage = `usage: (with a user) and the passphrase magpie webdav set k=v… change it: address, user, keys, agents, library, usage (yes|no); password= and passphrase= ask for a new one - magpie webdav now sync now (the gateway does every 3 minutes, while it runs) + magpie webdav now sync now (the gateway also syncs by itself while it runs: see auto) + magpie webdav auto off|3|15|30|60 how often the gateway syncs by itself, in minutes; off: only when asked + magpie webdav restore make this computer's setup the server's; what is here is kept first + magpie webdav undo put back what the last restore replaced magpie webdav dismiss clear what the last sync said it replaced magpie webdav off turn it off; the file on the server stays @@ -65,7 +68,10 @@ const s3Usage = `usage: magpie s3 set k=v… change it: address, bucket, prefix, endpoint, region, access-key-id, path-style, keys, agents, library, usage (yes|no); secret= and passphrase= ask for a new one - magpie s3 now sync now (the gateway does every 3 minutes, while it runs) + magpie s3 now sync now (the gateway also syncs by itself while it runs: see auto) + magpie s3 auto off|3|15|30|60 how often the gateway syncs by itself, in minutes; off: only when asked + magpie s3 restore make this computer's setup the server's; what is here is kept first + magpie s3 undo put back what the last restore replaced magpie s3 dismiss clear what the last sync said it replaced magpie s3 off turn it off; the file in the bucket stays @@ -126,6 +132,42 @@ func syncCmd(k syncKind, args []string) error { return syncSet(k, args[1:], false) case "now": return syncNow(k) + case "auto": + if len(args) != 2 { + return fmt.Errorf("magpie %s auto off|3|15|30|60", k.cmd) + } + m := davsync.Manual + if args[1] != "off" { + if _, err := fmt.Sscan(args[1], &m); err != nil { + return fmt.Errorf("magpie %s auto off|3|15|30|60, not %q", k.cmd, args[1]) + } + } + if err := davsync.SetAuto(m); err != nil { + return err + } + return syncShow(k) + case "restore", "undo": + if _, ok := davsync.Load(); !ok { + return fmt.Errorf("%s sync is off: %s", k.name, k.turnOn()) + } + var parts []string + var err error + if args[0] == "restore" { + parts, err = davsync.Restore(context.Background()) + } else { + parts, err = davsync.Undo() + } + if err != nil { + return err + } + if len(parts) == 0 { + fmt.Println("nothing differed: nothing was replaced") + } else if args[0] == "restore" { + fmt.Printf("restored from the server: %s; what was here is kept, and magpie %s undo puts it back\n", strings.Join(parts, ", "), k.cmd) + } else { + fmt.Printf("put back: %s\n", strings.Join(parts, ", ")) + } + return nil case "dismiss": if err := davsync.Dismiss(); err != nil { return err @@ -410,7 +452,10 @@ func syncShow(k syncKind) error { if v.UsageError != "" { fmt.Println(" couldn't share usage:", v.UsageError) } - if n := v.Notice; n != nil { + if n := v.Notice; n != nil && n.Restored { + fmt.Println(" Restored from the server:", partNames(n.Here)) + fmt.Println(muted.Render(" This computer's setup from before is kept in " + tilde(n.Saved) + " (magpie " + cmd + " undo puts it back; magpie " + cmd + " dismiss clears this)")) + } else if n != nil { if len(n.Here) > 0 { fmt.Println(" Replaced here by newer ones from another computer:", partNames(n.Here)) } @@ -423,8 +468,13 @@ func syncShow(k syncKind) error { } fmt.Println(muted.Render(" The copies replaced are kept in " + tilde(dir) + " (magpie restore opens one; magpie " + cmd + " dismiss clears this)")) } - if !gateway.Running() { - fmt.Println(muted.Render(" No gateway runs here: nothing syncs by itself until one does (the app, magpie web or magpie serve), every 3 minutes; magpie " + cmd + " now syncs once")) + switch { + case v.Auto == 0: + fmt.Println(muted.Render(" Syncs only when asked: magpie " + cmd + " now syncs once (magpie " + cmd + " auto 3 syncs by itself again)")) + case !gateway.Running(): + fmt.Println(muted.Render(fmt.Sprintf(" No gateway runs here: nothing syncs by itself until one does (the app, magpie web or magpie serve), every %d minutes; magpie %s now syncs once", v.Auto, cmd))) + default: + fmt.Println(muted.Render(fmt.Sprintf(" Syncs by itself every %d minutes (magpie %s auto changes it)", v.Auto, cmd))) } return nil }