From f7cf98ee3afa1d15c19b9778ef8f44500c92c276 Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 8 Oct 2026 17:34:11 +0800 Subject: [PATCH] update: a magpie off the Mac that can't write to the folder it runs from (a magpie.exe kept at C:\) says so, naming the folder, beside "a new version is out" and on the Update pill, instead of opening the release page with no reason (#1277, baymax0306) Before, a binary in a folder magpie may not write to, on a system that can't ask for the administrator's password (Windows; Linux without pkexec), was left with no exe to replace and shown only "{v} is out"; Download opened GitHub. The reporter found the cause themselves: the exe was at C:\. updater.placeExe now marks it stuck "not-writable" with the folder, and updateStuck says to move it to a folder the user can write to; zh, zh-TW, ja and de strings added. Verified: - TestUpdateNotWritableSaysWhy (internal/gui) fails without the fix: "a binary in a folder magpie can't write to: {State:available ... Stuck: StuckDir: ...}"; passes with it. - update-stuck.test.cjs, Chromium and WebKit, en/zh/ja/de at 1000px and 440px: 18/18 pass; without the app.js change it fails (the reason text never appears, waitForFunction timeout). - gui-ja, gui-de, gui-zh-tw, i18n-keys, update-*.test.cjs, cli-path: 136/136 pass in both engines. - go vet ./internal/gui, go build, GOOS=linux/windows go vet -tags nogui ./internal/gui, GOOS=windows go build; sandbox HOME go test -tags nogui ./... all ok. - On the Windows box, update.Writable (a windows/amd64 build, over ssh) is false for a folder whose ACL denies the user write. Over ssh the token is High integrity, so C:\ itself reads writable there; a standard user's C:\ and the GUI on Windows weren't tried. --- internal/gui/assets/app.js | 4 +- internal/gui/assets/i18n.js | 4 ++ internal/gui/tests/update-stuck.test.cjs | 85 ++++++++++++++++++++++++ internal/gui/update.go | 47 ++++++++++--- internal/gui/update_stuck_test.go | 69 +++++++++++++++++++ 5 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 internal/gui/tests/update-stuck.test.cjs create mode 100644 internal/gui/update_stuck_test.go diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index d3697920..7820734f 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -3165,8 +3165,10 @@ async function backAsNew(was) { } } -// updateStuck says why this magpie can't replace itself where it is. +// updateStuck says why this magpie can't replace itself where it is: off +// the Mac, a folder it may not write to, such as C:\ (#1277). function updateStuck(u) { + if (u.stuck === "not-writable") return t("magpie can't write to the folder it runs from ({dir}), so it can't update itself; move it to a folder you can write to and open it from there.", { dir: u.stuckDir || "" }); return u.stuck === "translocated" ? t("macOS is running magpie from a temporary copy, so it can't update itself; move magpie to Applications and open it from there.") : t("magpie is running from its disk image, so it can't update itself; drag it to Applications and open it from there."); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 3cc66330..ee310ed7 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -2470,6 +2470,7 @@ const I18N = { "Click to open the download page.": "点击打开下载页面。", "macOS is running magpie from a temporary copy, so it can't update itself; move magpie to Applications and open it from there.": "macOS 正从临时副本运行 magpie,无法自动更新;请将 magpie 移到「应用程序」文件夹后从那里打开。", "magpie is running from its disk image, so it can't update itself; drag it to Applications and open it from there.": "magpie 正从磁盘映像运行,无法自动更新;请将它拖到「应用程序」文件夹后从那里打开。", + "magpie can't write to the folder it runs from ({dir}), so it can't update itself; move it to a folder you can write to and open it from there.": "magpie 无法写入它所在的文件夹({dir}),所以无法自动更新;请把它移到你有写入权限的文件夹,再从那里打开。", "Config folder": "配置目录", "Command line": "命令行", "Checking your shells…": "正在检查你的 shell…", @@ -6211,6 +6212,7 @@ const I18N = { "Click to open the download page.": "點選開啟下載頁面。", "macOS is running magpie from a temporary copy, so it can't update itself; move magpie to Applications and open it from there.": "macOS 正從臨時副本執行 magpie,無法自動更新;請將 magpie 移到「應用程式」資料夾後從那裡開啟。", "magpie is running from its disk image, so it can't update itself; drag it to Applications and open it from there.": "magpie 正從磁碟映像執行,無法自動更新;請將它拖到「應用程式」資料夾後從那裡開啟。", + "magpie can't write to the folder it runs from ({dir}), so it can't update itself; move it to a folder you can write to and open it from there.": "magpie 無法寫入它所在的資料夾({dir}),所以無法自動更新;請把它移到你有寫入權限的資料夾,再從那裡開啟。", "Config folder": "設定目錄", "Command line": "命令列", "Checking your shells…": "正在檢查你的 shell…", @@ -9923,6 +9925,7 @@ const I18N = { "Click to open the download page.": "クリックするとダウンロードページを開きます。", "macOS is running magpie from a temporary copy, so it can't update itself; move magpie to Applications and open it from there.": "macOS が magpie を一時的なコピーから実行しているため、自動アップデートできません。magpie を「アプリケーション」フォルダに移動し、そこから開いてください。", "magpie is running from its disk image, so it can't update itself; drag it to Applications and open it from there.": "magpie がディスクイメージから実行されているため、自動アップデートできません。「アプリケーション」フォルダにドラッグし、そこから開いてください。", + "magpie can't write to the folder it runs from ({dir}), so it can't update itself; move it to a folder you can write to and open it from there.": "magpie は実行中のフォルダ({dir})に書き込めないため、自動アップデートできません。書き込み可能なフォルダに移動し、そこから開いてください。", "Config folder": "設定フォルダ", "Command line": "コマンドライン", "Checking your shells…": "シェルを確認しています…", @@ -13654,6 +13657,7 @@ const I18N = { "Click to open the download page.": "Klicken, um die Download-Seite zu öffnen.", "macOS is running magpie from a temporary copy, so it can't update itself; move magpie to Applications and open it from there.": "macOS führt magpie aus einer temporären Kopie aus, daher kann es sich nicht selbst aktualisieren; verschieben Sie magpie in „Programme“ und öffnen Sie es von dort.", "magpie is running from its disk image, so it can't update itself; drag it to Applications and open it from there.": "magpie läuft aus seinem Disk-Image und kann sich daher nicht selbst aktualisieren; ziehen Sie es in „Programme“ und öffnen Sie es von dort.", + "magpie can't write to the folder it runs from ({dir}), so it can't update itself; move it to a folder you can write to and open it from there.": "magpie kann nicht in den Ordner schreiben, aus dem es läuft ({dir}), und kann sich daher nicht selbst aktualisieren; verschieben Sie es in einen Ordner mit Schreibrechten und öffnen Sie es von dort.", "Config folder": "Konfigurationsordner", "Command line": "Befehlszeile", "Checking your shells…": "Shells werden geprüft…", diff --git a/internal/gui/tests/update-stuck.test.cjs b/internal/gui/tests/update-stuck.test.cjs new file mode 100644 index 00000000..428bf37a --- /dev/null +++ b/internal/gui/tests/update-stuck.test.cjs @@ -0,0 +1,85 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Settings → Version and the Update pill, for a magpie that can't replace +// itself where it runs: a magpie.exe kept at C:\ (#1277) said only that a new +// version was out, and its Download opened the release page with nothing +// saying why. Both now say the folder magpie may not write to, in English, +// Chinese, Japanese and German, at a narrow window too. No backend, the API +// is faked here. +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"); +const DIR = "C:\\"; + +const words = { + en: { version: "Version", why: "magpie can't write to the folder it runs from (C:\\), so it can't update itself; move it to a folder you can write to and open it from there." }, + zh: { version: "版本", why: "magpie 无法写入它所在的文件夹(C:\\),所以无法自动更新;请把它移到你有写入权限的文件夹,再从那里打开。" }, + ja: { why: "magpie は実行中のフォルダ(C:\\)に書き込めないため、自動アップデートできません。書き込み可能なフォルダに移動し、そこから開いてください。" }, + de: { why: "magpie kann nicht in den Ordner schreiben, aus dem es läuft (C:\\), und kann sich daher nicht selbst aktualisieren; verschieben Sie es in einen Ordner mit Schreibrechten und öffnen Sie es von dort." }, +}; + +const update = { state: "available", current: "0.1.400", latest: "0.1.401", url: "https://github.com/yetone/magpie-releases/releases/tag/v0.1.401", stuck: "not-writable", stuckDir: DIR }; + +function server(lang, ctl) { + const settings = { + theme: "light", lang, tray: "panel", quotaLeft: false, currency: "usd", + version: "0.1.400", dir: "~/.config/magpie", gateway: "http://127.0.0.1:3999", + proxyNow: "none", proxySource: "none", login: false, redactWords: [], + visionModels: [], imageGenModels: [], workbuddyCheckins: [], lanURLs: [], + fx: { rate: 7.2, at: new Date().toISOString(), stale: false }, + }; + return async (route) => { + const req = route.request(), url = new URL(req.url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" }, fx: settings.fx }); + if (url.pathname === "/api/settings") return json(settings); + if (url.pathname === "/api/update" || url.pathname === "/api/update/check") return json({ ...update }); + if (url.pathname === "/api/update/install") { ctl.installs++; return json({ ...update }); } + if (url.pathname === "/api/usage/quotas") return json([]); + if (url.pathname === "/api/groups") return json({ groups: [], models: [] }); + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); } + }; +} + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(engine + ": a magpie that can't write to its folder says why it can't update", async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + for (const lang of ["en", "zh", "ja", "de"]) { + for (const width of [1000, 440]) { + await t.test(`${lang} at ${width}px`, async () => { + const ctl = { installs: 0 }; + const errors = []; + const page = await (await browser.newContext({ viewport: { width, height: 700 }, reducedMotion: "reduce" })).newPage(); + page.setDefaultTimeout(5000); + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", server(lang, ctl)); + await page.goto("http://magpie.test/?view=settings&tab=about"); + const want = words[lang].why; + // the Version row: the version out, then why it can't be put in here + // (the row is drawn again as the answer is read, so it is found + // and read in one go) + const sub = await (await page.waitForFunction((w) => [...document.querySelectorAll("#about .row.pref .sub")].find((s) => s.textContent.includes(w))?.textContent, want)).jsonValue(); + assert.ok(sub.includes("0.1.401"), sub); + // the pill's tooltip says it too, before the click opens the page + await page.waitForFunction(() => !document.querySelector("#update")?.hidden); + const title = await page.locator("#update").getAttribute("title"); + assert.ok(title.startsWith(want), title); + // nothing is cut off or pushes the page sideways at a narrow width + const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + assert.ok(over <= 0, `the page scrolls sideways by ${over}px`); + assert.deepEqual(errors, []); + await page.close(); + }); + } + } + }); +} diff --git a/internal/gui/update.go b/internal/gui/update.go index b127e1ab..5acdf4a4 100644 --- a/internal/gui/update.go +++ b/internal/gui/update.go @@ -31,8 +31,9 @@ type updater struct { latest *update.Release err string bundle string // the .app to replace, "" when not in one or stuck - stuck string // why the .app can't be replaced where it is (update.Stuck) + stuck string // why the .app or binary can't be replaced where it is (update.Stuck, exeStuck) exe string // off the Mac: the binary to replace, "" when not writable + exeDir string // off the Mac: the binary's folder, named when it is stuck there self os.FileInfo // exe as this process started from it retry bool // error: the download failed, and may be tried again mirror string // error: the mirror the failed download came through (#893) @@ -71,7 +72,9 @@ type updateJSON struct { Notes string `json:"notes,omitempty"` URL string `json:"url,omitempty"` Stuck string `json:"stuck,omitempty"` // available: why it can't update itself - Retry bool `json:"retry,omitempty"` // error: a click downloads it again + // available, stuck "not-writable": the folder magpie may not write to + StuckDir string `json:"stuckDir,omitempty"` + Retry bool `json:"retry,omitempty"` // error: a click downloads it again // error: the download failed through this mirror (Settings' UpdateMirror), // and GitHub itself may do (#893) Mirror string `json:"mirror,omitempty"` @@ -104,12 +107,8 @@ func (u *updater) start() { u.bundle = b } } else if runtime.GOOS != "darwin" { - if exe, err := update.Executable(); err == nil && (update.Writable(filepath.Dir(exe)) || update.CanElevate()) { - u.exe = exe - u.self, _ = os.Stat(exe) - update.RemoveOld(exe) // what the last updates on Windows moved aside - update.RemoveStaleNew(exe) // what a magpie left running downloaded again - u.blocked = update.ReadBlocked(Version) + if exe, err := update.Executable(); err == nil { + u.placeExe(exe) } } go func() { @@ -126,6 +125,35 @@ func (u *updater) start() { }() } +// placeExe takes exe, off the Mac, as the binary an update replaces, or +// says why it can't be (stuck). +func (u *updater) placeExe(exe string) { + u.exeDir = filepath.Dir(exe) + if u.stuck = exeStuck(u.exeDir); u.stuck != "" { + return + } + u.exe = exe + u.self, _ = os.Stat(exe) + update.RemoveOld(exe) // what the last updates on Windows moved aside + update.RemoveStaleNew(exe) // what a magpie left running downloaded again + u.blocked = update.ReadBlocked(Version) +} + +// exeStuck says why the binary in dir can't be replaced off the Mac, or "" +// when it can: "not-writable" when magpie may not write to dir and this +// system can't ask for the administrator's password. On Windows a magpie.exe +// kept at C:\ updated only by opening the release page, with nothing saying +// why (#1277). +func exeStuck(dir string) string { + if update.Writable(dir) || canElevate() { + return "" + } + return "not-writable" +} + +// canElevate is update.CanElevate; tests stand in for it. +var canElevate = update.CanElevate + // check asks the feed and, when it can, stages the new version. func (u *updater) check() { if u.begin() { @@ -352,6 +380,9 @@ func (u *updater) jsonIn(lang string) updateJSON { } if u.state == "available" { j.Stuck = u.stuck + if u.stuck == "not-writable" { + j.StuckDir = u.exeDir + } } if u.state == "downloading" { j.Done, j.Total = u.done, u.total diff --git a/internal/gui/update_stuck_test.go b/internal/gui/update_stuck_test.go new file mode 100644 index 00000000..afdbbb52 --- /dev/null +++ b/internal/gui/update_stuck_test.go @@ -0,0 +1,69 @@ +package gui + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/yetone/magpie/internal/update" +) + +// #1277: a magpie.exe kept at C:\ may not write there, so it can't replace +// itself. It still says a new version is out, and says why it opens the +// release page instead of updating, naming the folder. +func TestUpdateNotWritableSaysWhy(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("needs a folder this user may not write to") + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(update.Release{Version: "0.1.11", Assets: map[string]update.Asset{ + update.BinaryAsset(): {URL: "http://" + r.Host + "/asset"}, + }}) + })) + defer srv.Close() + t.Setenv("MAGPIE_UPDATE_FEED", srv.URL) + old := Version + Version = "0.1.10" + defer func() { Version = old }() + was := canElevate + defer func() { canElevate = was }() + + dir := t.TempDir() + exe := filepath.Join(dir, "magpie.exe") + if err := os.WriteFile(exe, []byte("magpie"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o555); err != nil { + t.Fatal(err) + } + defer os.Chmod(dir, 0o755) + + canElevate = func() bool { return false } + u := &updater{} + u.placeExe(exe) + u.check() + j := u.json() + if j.State != "available" || j.Stuck != "not-writable" || j.StuckDir != dir { + t.Fatalf("a binary in a folder magpie can't write to: %+v", j) + } + + // where the system can ask for the password, it updates as before + canElevate = func() bool { return true } + u = &updater{} + u.placeExe(exe) + if u.stuck != "" || u.exe != exe { + t.Fatalf("stuck %q, exe %q", u.stuck, u.exe) + } + // and a folder it may write to is never stuck + canElevate = func() bool { return false } + os.Chmod(dir, 0o755) + u = &updater{} + u.placeExe(exe) + if u.stuck != "" || u.exe != exe { + t.Fatalf("a writable folder: stuck %q, exe %q", u.stuck, u.exe) + } +}