update: a restart to update from the open window (Windows, Linux) opens the window again, on the tab it was asked from, instead of the tray icon alone (#275 Aimer779: 设置页更新重启后主窗口消失,只剩托盘) — RelaunchBinary always started magpie tray, autostart's argument, so the new version came up as a tray icon however the update was asked for; the Mac reopens its .app and was fine. The window's Update pill and Settings' "Restart to update" send the tab they're on, the new process starts as magpie gui <tab>; the tray menu's Restart to Update brings the window back if it was open and keeps to the tray if not; autostart stays tray. magpie gui <tab> / magpie app <tab> open the window on that tab, and hand it to a magpie already running. The MAGPIE_REPLACES handover is unchanged: the new process waits for the old one before its single-instance lock. Tested with TestRelaunchArgs and TestRelaunchBinaryArgs (a script in magpie's place is started with gui settings <pid> / tray <pid>); go vet, go build (GUI and -tags nogui, GOOS=linux, GOOS=windows), go test -tags nogui ./... pass. Not tried: a real restart to update on Windows or Linux.

This commit is contained in:
yetone committed 2026-09-30 12:33:36 +08:00
1 parent 60e4591315
commit 831df8579e
8 files changed
+144 -13

No files matched your search

+2
View File
@@ -9,3 +9,5 @@ const hasGUI = false
func runGUI(bool, string) error { return errors.New("this build has no GUI; run `magpie tui`") }
func runPanel() error { return runGUI(false, "") }
func runWindow(string) error { return runGUI(true, "") }
+7
View File
@@ -16,6 +16,13 @@ func runGUI(showMain bool, link string) error {
return gui.Run(version, showMain, link)
}
// runWindow starts the app with its window open on view ("" for the first
// tab).
func runWindow(view string) error {
gui.OpenView = view
return runGUI(true, "")
}
// runPanel starts the app with its quick panel open, or toggles the panel
// of the one already running.
func runPanel() error {
+14 -3
View File
@@ -10,6 +10,7 @@ import (
"io/fs"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
@@ -95,13 +96,16 @@ func (h *host) HidePanel() { h.panel.Hide() }
func (h *host) ShowMain(view string) {
h.panel.Hide()
if view != "" {
h.main.SetURL("/?view=" + view + h.query)
h.main.SetURL("/?view=" + url.QueryEscape(view) + h.query)
}
h.dock(settings.Load(), true)
h.main.Show()
h.main.Focus()
}
// MainShown says whether the window is up.
func (h *host) MainShown() bool { return h.main != nil && h.main.IsVisible() }
// Import opens the window on an import link, for the user to confirm.
func (h *host) Import(link string) {
id := stash(link)
@@ -349,7 +353,8 @@ func Run(version string, showMain bool, link string) error {
menu.Add("Version " + version).SetEnabled(false)
restart := menu.Add("Restart to Update").SetHidden(true)
restart.OnClick(func(*application.Context) {
if restartToUpdate(false) {
// the window comes back if it was open; the tray alone if not
if restartToUpdate(false, h.MainShown(), "") {
h.app.Quit()
}
})
@@ -421,7 +426,7 @@ func Run(version string, showMain bool, link string) error {
h.whenReady(h.applyZoom)
}
if showMain {
h.whenReady(func() { h.ShowMain("") })
h.whenReady(func() { h.ShowMain(OpenView) })
}
if OpenPanel {
h.whenReady(func() { application.InvokeAsync(h.togglePanel) })
@@ -467,6 +472,8 @@ func singleInstance(h *host) *application.SingleInstanceOptions {
case len(args) == 1 && args[0] == "tray":
case len(args) == 1 && args[0] == "panel":
h.whenReady(func() { application.InvokeAsync(h.togglePanel) })
case len(args) == 2 && (args[0] == "gui" || args[0] == "app"):
h.whenReady(func() { h.ShowMain(args[1]) })
default:
h.whenReady(func() { h.ShowMain("") })
}
@@ -542,6 +549,10 @@ func panelOptions(goos, theme string) application.WebviewWindowOptions {
// icon runs it (see omarchy.AddWidget).
var OpenPanel bool
// OpenView is the tab the window opens on, as `magpie gui settings` asks:
// a restart to update comes back where it was asked for.
var OpenView string
// togglePanel opens the quick panel by the tray icon, or closes it.
func (h *host) togglePanel() {
if omarchy.Hyprland() {
+8 -2
View File
@@ -1044,6 +1044,12 @@ async function load() {
renderUpdateBadge();
}
// installFrom is what a restart to update tells the app: the window's tab,
// for the new version to open its window there (Windows, Linux).
function installFrom() {
return mode === "window" ? { view } : {};
}
// renderUpdateBadge shows the header's Update pill once a newer magpie is
// downloaded (a click restarts into it) or, where magpie can't replace
// itself, out (a click opens the release page).
@@ -1064,7 +1070,7 @@ async function renderUpdateBadge() {
label.textContent = t("Restarting…");
// an answer means it didn't: the password prompt dismissed, the swap
// failed, or a newer version is out and downloading first
const a = await api("update/install", {}).catch(() => ({}));
const a = await api("update/install", installFrom()).catch(() => ({}));
if (!a) return backAsNew(u.current);
if (a) {
if (["checking", "downloading"].includes(a.state)) b.dataset.pulling = "1";
@@ -8440,7 +8446,7 @@ async function renderUpdate(r, u) {
sub.textContent = t("{v} is downloaded", { v: u.latest }) + (u.error ? " · " + u.error : "");
// back with an answer only when it didn't restart
btn(t("Restart to update"), async () => {
const a = await api("update/install", {}).catch(() => ({ state: "error" }));
const a = await api("update/install", installFrom()).catch(() => ({ state: "error" }));
if (a) return renderUpdate(r, a.current ? a : undefined);
sub.textContent = t("Restarting…");
backAsNew(u.current);
+18 -3
View File
@@ -2,6 +2,7 @@ package gui
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
@@ -251,13 +252,18 @@ func updateRoutes(mux *http.ServeMux, w Windows) {
// install restarts into the staged version; after a failed download it
// downloads it again, and the page restarts once it's in. Only an app
// that can't replace itself is sent to the release page.
// The window's page names the tab it is on, to come back to it.
mux.HandleFunc("POST /api/update/install", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
View string `json:"view"`
}
_ = json.NewDecoder(r.Body).Decode(&in)
if updates.json().State == "ready" {
updates.recheck()
}
switch j := updates.json(); {
case j.State == "ready":
if restartToUpdate(isWeb(w)) {
if restartToUpdate(isWeb(w), in.View != "" || mainShown(w), in.View) {
rw.WriteHeader(http.StatusNoContent)
go w.Quit()
return
@@ -276,7 +282,9 @@ func updateRoutes(mux *http.ServeMux, w Windows) {
// restartToUpdate installs the staged version and arranges for it to open
// once this process is gone; the caller then quits. magpie web runs on as
// the new version in its own place (Web.Wait) instead of opening the app.
func restartToUpdate(web bool) bool {
// Off the Mac, the new one opens its window on view when window is set, and
// only the tray icon when not; the Mac's always opens its window.
func restartToUpdate(web, window bool, view string) bool {
bundle, exe := updates.bundle, updates.exe
if !updates.install(true) {
return false
@@ -289,10 +297,17 @@ func restartToUpdate(web bool) bool {
if bundle != "" {
err = update.Relaunch(bundle)
} else {
err = update.RelaunchBinary(exe)
err = update.RelaunchBinary(exe, window, view)
}
if err != nil {
log.Println("update:", err)
}
return true
}
// mainShown says whether the app's window is up, for a restart asked for
// from the panel to bring it back.
func mainShown(w Windows) bool {
s, ok := w.(interface{ MainShown() bool })
return ok && s.MainShown()
}
+72
View File
@@ -0,0 +1,72 @@
package update
import (
"os"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"testing"
"time"
)
// #275: a restart to update from the open window (Windows, Linux) came
// back as the tray icon alone; it opens the window again, on its tab, and
// only a restart with the window closed stays in the tray, as autostart.
func TestRelaunchArgs(t *testing.T) {
for _, c := range []struct {
window bool
view string
want []string
}{
{true, "settings", []string{"gui", "settings"}},
{true, "", []string{"gui"}},
{false, "", []string{"tray"}},
{false, "settings", []string{"tray"}},
} {
if got := RelaunchArgs(c.window, c.view); !slices.Equal(got, c.want) {
t.Errorf("RelaunchArgs(%v, %q) = %q, want %q", c.window, c.view, got, c.want)
}
}
}
// RelaunchBinary starts exe with those arguments, and the handover to this
// process as before.
func TestRelaunchBinaryArgs(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("a shell script stands in for magpie")
}
for _, c := range []struct {
window bool
view string
want string
}{
{true, "settings", "gui settings"},
{false, "", "tray"},
} {
// a script and a file of its own each, so a slow start can't be
// read as the next one's
dir := t.TempDir()
out := filepath.Join(dir, "out")
exe := filepath.Join(dir, "magpie")
script := "#!/bin/sh\necho \"$* $MAGPIE_REPLACES\" > " + out + ".tmp && mv " + out + ".tmp " + out + "\n"
if err := os.WriteFile(exe, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
if err := RelaunchBinary(exe, c.window, c.view); err != nil {
t.Fatal(err)
}
want := c.want + " " + strconv.Itoa(os.Getpid())
var got string
for deadline := time.Now().Add(30 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
if b, err := os.ReadFile(out); err == nil {
got = strings.TrimSpace(string(b))
break
}
}
if got != want {
t.Errorf("relaunched with %q, want %q", got, want)
}
}
}
+18 -5
View File
@@ -442,11 +442,12 @@ func InstallBinaryAsAdmin(staged, exe string) error {
return err
}
// RelaunchBinary starts exe again as the tray app. The new process waits
// for this one to exit before it takes the gateway's port; see
// AwaitPredecessor.
func RelaunchBinary(exe string) error {
cmd := proc.Command(exe, "tray")
// RelaunchBinary starts exe again: with its window on view when window is
// set (the window was open), else as the tray app alone, as autostart
// starts it. The new process waits for this one to exit before it takes
// the gateway's port; see AwaitPredecessor.
func RelaunchBinary(exe string, window bool, view string) error {
cmd := proc.Command(exe, RelaunchArgs(window, view)...)
cmd.Env = append(os.Environ(), "MAGPIE_REPLACES="+strconv.Itoa(os.Getpid()))
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
detach(cmd)
@@ -456,6 +457,18 @@ func RelaunchBinary(exe string) error {
return cmd.Process.Release()
}
// RelaunchArgs is what RelaunchBinary starts magpie with: `gui [view]` for
// the window, `tray` for the tray icon alone.
func RelaunchArgs(window bool, view string) []string {
switch {
case !window:
return []string{"tray"}
case view != "":
return []string{"gui", view}
}
return []string{"gui"}
}
// AwaitPredecessor blocks, for a while at most, until the magpie that
// relaunched this one has exited.
func AwaitPredecessor() {
+5
View File
@@ -143,6 +143,11 @@ func run(args []string) error {
case "web":
return webCmd(args[1:])
case "app", "gui":
// `magpie gui settings`: the window on that tab, as a restart to
// update from it comes back (update.RelaunchArgs)
if len(args) > 1 {
return runWindow(args[1])
}
return runGUI(true, "")
case "tray":
return runGUI(false, "")