diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 6e1c70eb..302ca5d0 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -16326,6 +16326,7 @@ function setSetTab(tab, remember) { // own button, with the file it names. Asked once a load and again after // half a minute: each shell is run to read its PATH. let cliView = null, cliAt = 0, cliAsk = null; +const exeName = (p) => String(p || "").split(/[\\/]/).pop(); function renderCLI(r) { r.classList.add("cli-row"); const sub = r.querySelector(".sub"); @@ -16341,18 +16342,22 @@ function renderCLI(r) { cliView = await api("cli", body); cliAt = Date.now(); paint(cliView); - status(cliView.windows ? t("Added: open a new PowerShell or Command Prompt window to run magpie") : t("Added: open a new terminal window to run magpie"), "ok"); + status(cliView.windows && cliView.shim ? t("Added: magpie.cmd beside {name} runs it as magpie; open a new PowerShell or Command Prompt window to run magpie", { name: exeName(cliView.exe) }) + : cliView.windows ? t("Added: open a new PowerShell or Command Prompt window to run magpie") : t("Added: open a new terminal window to run magpie"), "ok"); } catch (e) { status(t(e.message), "err"); } finally { btn.disabled = false; } }; const paint = (v) => { sub.textContent = v.stuck === "translocated" ? t("macOS runs magpie from a temporary copy until it is moved to Applications: move it there first") : v.stuck === "read-only" ? t("magpie runs from its disk image: copy it to Applications first") - : v.ours ? t("In a new terminal, magpie runs this app: {path}", { path: v.command }) + : v.ours ? t("In a new terminal, magpie runs this app: {path}", { path: v.command }) + (v.shim && v.command === v.shim ? " (" + t("which runs {name}", { name: exeName(v.exe) }) + ")" : "") : v.command ? t("In a new terminal, magpie runs another copy: {path}", { path: v.command }) + (v.version ? " (" + v.version + ")" : "") : t("A new terminal can't find the magpie command"); add.hidden = v.ours || !v.dir || !!v.stuck; add.textContent = v.command ? t("Use this app") : t("Add to PATH"); - add.title = v.windows ? t("Puts {dir} first in your user PATH", { dir: v.dir || "" }) : t("Links magpie in {dir}", { dir: v.dir || "" }); + // an app not named magpie.exe (the site's magpie-windows-amd64.exe, + // #942) is given a magpie.cmd beside it that runs it + add.title = v.windows && v.shim ? t("Puts {dir} first in your user PATH and writes magpie.cmd there, which runs {name}", { dir: v.dir || "", name: exeName(v.exe) }) + : v.windows ? t("Puts {dir} first in your user PATH", { dir: v.dir || "" }) : t("Links magpie in {dir}", { dir: v.dir || "" }); shells.replaceChildren(...(v.shells || []).map((s) => { const line = el("div", "cli-shell" + (s.ours ? " on" : "")); line.dataset.shell = s.name; diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 6ad8f3f9..704ea9c9 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -2222,6 +2222,9 @@ const I18N = { "Couldn't download {v} through the mirror {mirror}; it may be down or sent a file that doesn't match the release, which isn't installed": "经镜像 {mirror} 下载 {v} 失败:镜像可能不可用,或发来的文件与发布版本不符(不会安装)", "Download from GitHub": "改用官方下载", "Use this app": "改用这个 app", + "Puts {dir} first in your user PATH and writes magpie.cmd there, which runs {name}": "把 {dir} 放到你用户 PATH 的最前面,并在那里写一个 magpie.cmd 来运行 {name}", + "Added: magpie.cmd beside {name} runs it as magpie; open a new PowerShell or Command Prompt window to run magpie": "已添加:{name} 旁边的 magpie.cmd 会以 magpie 这个命令运行它;新开一个 PowerShell 或命令提示符窗口即可运行 magpie", + "which runs {name}": "它运行 {name}", "Puts {dir} first in your user PATH": "把 {dir} 放到你用户 PATH 的最前面", "Links magpie in {dir}": "在 {dir} 里链接 magpie", "Added: open a new PowerShell or Command Prompt window to run magpie": "已添加:新开一个 PowerShell 或命令提示符窗口即可运行 magpie", @@ -5616,6 +5619,9 @@ const I18N = { "Couldn't download {v} through the mirror {mirror}; it may be down or sent a file that doesn't match the release, which isn't installed": "ミラー {mirror} 経由で {v} をダウンロードできませんでした。ミラーが停止しているか、リリースと一致しないファイルが送られました(インストールされません)", "Download from GitHub": "公式からダウンロード", "Use this app": "このアプリを使う", + "Puts {dir} first in your user PATH and writes magpie.cmd there, which runs {name}": "{dir} をユーザーの PATH の先頭に置き、{name} を実行する magpie.cmd をそこに書きます", + "Added: magpie.cmd beside {name} runs it as magpie; open a new PowerShell or Command Prompt window to run magpie": "追加しました:{name} の隣の magpie.cmd が magpie コマンドとしてそれを実行します。新しい PowerShell かコマンドプロンプトのウィンドウで magpie を実行できます", + "which runs {name}": "{name} を実行します", "Puts {dir} first in your user PATH": "{dir} をユーザーの PATH の先頭に置きます", "Links magpie in {dir}": "{dir} に magpie をリンクします", "Added: open a new PowerShell or Command Prompt window to run magpie": "追加しました:新しい PowerShell かコマンドプロンプトのウィンドウで magpie を実行できます", @@ -9000,6 +9006,9 @@ const I18N = { "Couldn't download {v} through the mirror {mirror}; it may be down or sent a file that doesn't match the release, which isn't installed": "{v} konnte über den Spiegel {mirror} nicht geladen werden; er ist evtl. nicht erreichbar oder lieferte eine Datei, die nicht zum Release passt (wird nicht installiert)", "Download from GitHub": "Von GitHub laden", "Use this app": "Diese App verwenden", + "Puts {dir} first in your user PATH and writes magpie.cmd there, which runs {name}": "Setzt {dir} an den Anfang Ihres Benutzer-PATH und legt dort eine magpie.cmd ab, die {name} startet", + "Added: magpie.cmd beside {name} runs it as magpie; open a new PowerShell or Command Prompt window to run magpie": "Hinzugefügt: Die magpie.cmd neben {name} startet es als magpie. Öffnen Sie ein neues PowerShell- oder Eingabeaufforderungsfenster, um magpie auszuführen", + "which runs {name}": "startet {name}", "Puts {dir} first in your user PATH": "Setzt {dir} an den Anfang Ihres Benutzer-PATH", "Links magpie in {dir}": "Verlinkt magpie in {dir}", "Added: open a new PowerShell or Command Prompt window to run magpie": "Hinzugefügt: Öffnen Sie ein neues PowerShell- oder Eingabeaufforderungsfenster, um magpie auszuführen", diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 5a30a57e..c1de098a 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -1849,7 +1849,9 @@ it says what `magpie` in a new terminal runs, with a dot for each shell (no left border, no native select); Add to PATH posts `/api/cli` with no shell and the row turns green, a shell whose PATH lacks the folder has a button naming its profile that posts that shell alone, and no click scrolls. On Windows it -lists PowerShell and cmd with no profile buttons; a translocated app gets no +lists PowerShell and cmd with no profile buttons, and a program not named +magpie.exe (the site's portable download, #942) is said to get a magpie.cmd +that runs it, in Add's tooltip, the row and the status; a translocated app gets no button. Every string has zh, ja and de. English and Chinese, Chromium and WebKit; the API is faked, no shell profile is read or written. diff --git a/internal/gui/tests/cli-path.test.cjs b/internal/gui/tests/cli-path.test.cjs index bfcfe217..f0a97c59 100644 --- a/internal/gui/tests/cli-path.test.cjs +++ b/internal/gui/tests/cli-path.test.cjs @@ -1,7 +1,9 @@ // Run with Node's test runner and Playwright on the module path; see README.md. // Settings › About › Command line (PAMI on Discord): whether `magpie` in a // terminal opened now runs this app, said for each shell (a dot, no left -// border), and Add to PATH, which posts /api/cli and shows the answer. A +// border), and Add to PATH, which posts /api/cli and shows the answer. On +// Windows a program not named magpie.exe (the site's portable download, +// #942) is said to get a magpie.cmd that runs it. A // shell whose PATH lacks the folder has its own button naming its profile, // and only that button posts that shell. On Windows PowerShell and cmd are // listed, with no profile to write; a translocated app gets no button. No @@ -17,18 +19,24 @@ const assets = path.resolve(__dirname, "../assets"); const words = { en: { row: "Command line", add: "Add to PATH", none: "can't find the magpie command", ours: "runs this app", fish: "Add to ~/.config/fish/config.fish", - added: "open a new terminal window", ps: "PowerShell and cmd take up a change in new windows", moved: "moved to Applications" }, + added: "open a new terminal window", ps: "PowerShell and cmd take up a change in new windows", moved: "moved to Applications", + shimTitle: "writes magpie.cmd there, which runs magpie-windows-amd64.exe", shimAdded: "magpie.cmd beside magpie-windows-amd64.exe runs it as magpie", shimRuns: "which runs magpie-windows-amd64.exe" }, zh: { row: "命令行", add: "添加到 PATH", none: "新终端里找不到 magpie 命令", ours: "运行这个 app", fish: "添加到 ~/.config/fish/config.fish", - added: "新开一个终端窗口", ps: "PowerShell 和 cmd 在新窗口里生效", moved: "移到「应用程序」" }, + added: "新开一个终端窗口", ps: "PowerShell 和 cmd 在新窗口里生效", moved: "移到「应用程序」", + shimTitle: "写一个 magpie.cmd 来运行 magpie-windows-amd64.exe", shimAdded: "magpie-windows-amd64.exe 旁边的 magpie.cmd", shimRuns: "它运行 magpie-windows-amd64.exe" }, }; const EXE = "/Applications/magpie.app/Contents/MacOS/magpie"; function view(ctl) { if (ctl.windows) { - const exe = "C:\\Users\\u\\AppData\\Local\\magpie\\magpie.exe"; - return { exe, ours: ctl.added, command: ctl.added ? exe : "", dir: "C:\\Users\\u\\AppData\\Local\\magpie", windows: true, - shells: ["PowerShell", "cmd"].map((name, i) => ({ name, default: i === 0, known: true, ours: ctl.added, command: ctl.added ? exe : "" })) }; + // the site's portable download (#942) runs as magpie through the + // magpie.cmd Add writes beside it + const dir = ctl.portable ? "D:\\portable_app" : "C:\\Users\\u\\AppData\\Local\\magpie"; + const exe = dir + (ctl.portable ? "\\magpie-windows-amd64.exe" : "\\magpie.exe"), shim = ctl.portable ? dir + "\\magpie.cmd" : ""; + const cmd = ctl.added ? shim || exe : ""; + return { exe, ours: ctl.added, command: cmd, dir, windows: true, shim, + shells: ["PowerShell", "cmd"].map((name, i) => ({ name, default: i === 0, known: true, ours: ctl.added, command: cmd })) }; } const link = "/Users/u/.local/bin/magpie"; return { @@ -154,6 +162,21 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.context().close(); }); + await t.test(lang + ": Windows' portable download gets a magpie.cmd, and says so (#942)", async () => { + const ctl = { posts: [], gets: 0, windows: true, portable: true }; + const errors = []; + const { page, row } = await open(browser, lang, ctl, errors); + const add = row.locator("button.cli-add"); + assert.match(await add.getAttribute("title"), new RegExp(w.shimTitle)); + await add.click(); + await row.locator(".sub", { hasText: "D:\\portable_app\\magpie.cmd" }).waitFor(); + assert.match(await row.locator(".sub").innerText(), new RegExp(w.shimRuns)); + assert.match(await page.locator("#status").innerText(), new RegExp(w.shimAdded)); + assert.deepEqual(ctl.posts, [{}]); + assert.deepEqual(errors, []); + await page.context().close(); + }); + await t.test(lang + ": a translocated app gets no button", async () => { const ctl = { posts: [], gets: 0, stuck: "translocated" }; const errors = []; diff --git a/internal/update/cli_path.go b/internal/update/cli_path.go index 6ab09806..98355679 100644 --- a/internal/update/cli_path.go +++ b/internal/update/cli_path.go @@ -50,6 +50,7 @@ type CLI struct { Stuck string `json:"stuck,omitempty"` // translocated, read-only: why it can't be linked from where it is Shells []CLIShell `json:"shells"` Windows bool `json:"windows,omitempty"` + Shim string `json:"shim,omitempty"` // Windows: the magpie.cmd Add writes beside an app not named magpie.exe, which runs it } // cliShell is a shell installed here. @@ -76,6 +77,9 @@ func ReadCLI() *CLI { return v } v.Exe, v.Stuck = exe, cliStuck() + if v.Windows && needsShim(exe) { + v.Shim = filepath.Join(filepath.Dir(exe), shimName) + } shells := cliShells() paths := make([][]string, len(shells)) var wg sync.WaitGroup @@ -90,7 +94,7 @@ func ReadCLI() *CLI { var def []string for i, sh := range shells { s := CLIShell{Name: sh.Name, Default: sh.Default, Known: paths[i] != nil, Command: findCLI(paths[i])} - s.Ours = s.Command != "" && sameFile(s.Command, exe) + s.Ours = s.Command != "" && (sameFile(s.Command, exe) || shimRuns(s.Command, exe)) if p := cliProfile(sh.Name); p != "" { s.Profile = tildeHome(p) } @@ -335,6 +339,52 @@ func tildeHome(p string) string { return p } +// A Windows app not named magpie.exe — the site's download is +// magpie-windows-amd64.exe (#942) — is given a magpie.cmd beside it that +// runs it, since a terminal looks for `magpie` by that name only. It names +// the program by its folder (%~dp0) and file name, which an update keeps: +// it replaces the program where it is, under its name. + +// shimName is the file a shim is written to. +const shimName = "magpie.cmd" + +// shimText is the magpie.cmd that runs the program exe in its folder. +func shimText(exe string) string { + name := filepath.Base(strings.ReplaceAll(exe, `\`, "/")) + return "@echo off\r\nrem added by magpie (Settings > Command line): the magpie command runs " + name + " in this folder\r\n\"%~dp0" + name + "\" %*\r\n" +} + +// needsShim says whether the program exe can't be run as `magpie` by its +// own name. +func needsShim(exe string) bool { + return !strings.EqualFold(filepath.Base(strings.ReplaceAll(exe, `\`, "/")), "magpie.exe") +} + +// writeShim writes the magpie.cmd for exe beside it, when its name needs +// one; it is the file's path, "" when none was needed. +func writeShim(exe string) (string, error) { + if !needsShim(exe) { + return "", nil + } + p := filepath.Join(filepath.Dir(exe), shimName) + if b, err := os.ReadFile(p); err == nil && string(b) == shimText(exe) { + return p, nil + } + if err := os.WriteFile(p, []byte(shimText(exe)), 0o755); err != nil { + return "", fmt.Errorf("writing %s, which runs %s as magpie: %w", p, filepath.Base(exe), err) + } + return p, nil +} + +// shimRuns says whether cmd is the magpie.cmd magpie wrote for exe. +func shimRuns(cmd, exe string) bool { + if !strings.EqualFold(filepath.Base(cmd), shimName) || !strings.EqualFold(filepath.Dir(cmd), filepath.Dir(exe)) { + return false + } + b, err := os.ReadFile(cmd) + return err == nil && string(b) == shimText(exe) +} + // pathFirst is the PATH value v (Windows', ;-separated) with dir first and // nowhere else. func pathFirst(v, dir string) string { diff --git a/internal/update/cli_path_windows.go b/internal/update/cli_path_windows.go index ccd35290..c07db915 100644 --- a/internal/update/cli_path_windows.go +++ b/internal/update/cli_path_windows.go @@ -22,8 +22,12 @@ func installedShells() []cliShell { func cliDir(exe string, _ []string, _ string) string { return filepath.Dir(exe) } // cliPlace puts dir first in the user's PATH in the registry, where a -// terminal opened from now on takes it up, and tells Explorer. -func cliPlace(_, dir string) error { +// terminal opened from now on takes it up, and tells Explorer; an app not +// named magpie.exe gets its magpie.cmd there first (writeShim). +func cliPlace(exe, dir string) error { + if _, err := writeShim(exe); err != nil { + return err + } k, err := registry.OpenKey(registry.CURRENT_USER, `Environment`, registry.QUERY_VALUE|registry.SET_VALUE) if err != nil { return err diff --git a/internal/update/cli_shim_test.go b/internal/update/cli_shim_test.go new file mode 100644 index 00000000..4e7dcab2 --- /dev/null +++ b/internal/update/cli_shim_test.go @@ -0,0 +1,58 @@ +package update + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// #942: the site's Windows download is magpie-windows-amd64.exe, and Add +// to PATH put its folder on PATH, where a terminal finds no `magpie`. Add +// writes a magpie.cmd beside a program not named magpie.exe that runs it by +// its folder and name, which an update keeps, and the Command line reads +// that magpie.cmd as this app; one written for another program isn't. +func TestCLIShim(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "magpie-windows-amd64.exe") + if err := os.WriteFile(exe, []byte("MZ"), 0o755); err != nil { + t.Fatal(err) + } + p, err := writeShim(exe) + if err != nil { + t.Fatal(err) + } + if p != filepath.Join(dir, "magpie.cmd") { + t.Fatalf("shim at %q", p) + } + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), "\"%~dp0magpie-windows-amd64.exe\" %*\r\n") || !strings.HasPrefix(string(b), "@echo off\r\n") { + t.Fatalf("magpie.cmd:\n%q", b) + } + if !shimRuns(p, exe) { + t.Fatal("the magpie.cmd written isn't read as running this app") + } + if shimRuns(p, filepath.Join(dir, "magpie-old.exe")) { + t.Fatal("magpie.cmd read as running another program") + } + // renamed, as a second download is: written again for the new name + exe2 := filepath.Join(dir, "magpie-windows-amd64 (1).exe") + if _, err := writeShim(exe2); err != nil { + t.Fatal(err) + } + if !shimRuns(p, exe2) || shimRuns(p, exe) { + t.Fatal("magpie.cmd not written again for the renamed program") + } + + // magpie.exe is found by its own name: nothing written + dir2 := t.TempDir() + if p, err := writeShim(filepath.Join(dir2, "Magpie.exe")); err != nil || p != "" { + t.Fatalf("magpie.exe given a shim: %q, %v", p, err) + } + if _, err := os.Stat(filepath.Join(dir2, "magpie.cmd")); !os.IsNotExist(err) { + t.Fatal("magpie.cmd written beside magpie.exe") + } +}