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.
This commit is contained in:
yetone committed 2026-10-08 17:35:30 +08:00
1 parent 0b7a740ee6
commit f7cf98ee3a
5 files changed
+200 -9

No files matched your search

+3 -1
View File
@@ -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.");
+4
View File
@@ -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…",
+85
View File
@@ -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();
});
}
}
});
}
+39 -8
View File
@@ -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
+69
View File
@@ -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)
}
}