diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index eb2e6eeb..8470a109 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -71,16 +71,29 @@ func URL() string { return "http://" + a } +// Window says this process shows the routing of the gateway it serves: +// the app's window (a tray's too, when opened) or magpie web's page, and +// not magpie serve, which has none. Set by the gui. +var Window bool + // Running reports whether a gateway answers at the address. func Running() bool { + running, _ := Serving() + return running +} + +// Serving asks the address what answers there: whether a magpie gateway +// does, and whether the magpie serving it has a window its routing can be +// watched in (#110). +func Serving() (running, window bool) { c := &http.Client{Timeout: 700 * time.Millisecond} res, err := c.Get(URL() + "/") if err != nil { - return false + return false, false } defer res.Body.Close() b, _ := io.ReadAll(io.LimitReader(res.Body, 4096)) - return bytes.Contains(b, []byte(`"magpie"`)) + return bytes.Contains(b, []byte(`"magpie"`)), bytes.Contains(b, []byte(`"window":true`)) } // Call is one request the gateway handled, for the status views. @@ -235,7 +248,7 @@ func (s *Server) Handler() http.Handler { } func (s *Server) info(w http.ResponseWriter, r *http.Request) { - writeJSON(w, 200, map[string]any{"name": "magpie", "version": Version, "models": len(provider.Catalog()), + writeJSON(w, 200, map[string]any{"name": "magpie", "version": Version, "models": len(provider.Catalog()), "window": Window, "apis": []string{"/v1/chat/completions", "/v1/responses", "/v1/messages", "/v1beta/models/{model}:generateContent", "/v1/magpie/quotas"}}) } diff --git a/internal/gui/app.go b/internal/gui/app.go index adef2094..8aa22ead 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -245,7 +245,7 @@ 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() { + if restartToUpdate(false) { h.app.Quit() } }) diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 3d091759..babb37a4 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -741,6 +741,7 @@ async function renderUpdateBadge() { // 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(() => ({})); + if (!a) return backAsNew(u.current); if (a) { if (["checking", "downloading"].includes(a.state)) b.dataset.pulling = "1"; else b.classList.remove("busy"); @@ -772,10 +773,22 @@ async function renderUpdateBadge() { label.textContent = t("Downloading…"); return api("update/install", {}).then(renderUpdateBadge, renderUpdateBadge); } + if (web && u.url) return window.open(u.url, "_blank", "noopener"); api("update/install", {}).catch(() => {}); }; } +// backAsNew waits, in magpie web, for the version the page restarted into +// to answer in its place, and reloads the page from it (#111). +async function backAsNew(was) { + if (!web) return; + for (let i = 0; i < 180; i++) { + await new Promise((r) => setTimeout(r, 1000)); + const u = await fetch("/api/update").then((r) => (r.ok ? r.json() : null)).catch(() => null); + if (u && u.current !== was) return location.reload(); + } +} + // updateStuck says why this magpie can't replace itself where it is. function updateStuck(u) { return u.stuck === "translocated" @@ -4668,12 +4681,17 @@ async function renderUpdate(r, u) { case "ready": 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 () => renderUpdate(r, await api("update/install", {}).catch(() => null) || undefined)); + btn(t("Restart to update"), async () => { + const a = await api("update/install", {}).catch(() => ({ state: "error" })); + if (a) return renderUpdate(r, a.current ? a : undefined); + sub.textContent = t("Restarting…"); + backAsNew(u.current); + }); break; case "available": sub.textContent = t("{v} is out", { v: u.latest }); if (u.stuck) sub.textContent += " · " + updateStuck(u); - btn(t("Download"), () => api("update/install", {})); + btn(t("Download"), () => (web && u.url ? window.open(u.url, "_blank", "noopener") : api("update/install", {}))); break; case "downloading": sub.textContent = t("Downloading {v}…", { v: u.latest }); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index f88abcfd..5c96af62 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -801,6 +801,7 @@ const I18N = { "Every request an agent sends to magpie shows up here, routed for real.": "Agent 发给 magpie 的每个请求都会出现在这里——真实的路由。", "Another magpie serves the gateway; its routing plays live in that magpie's window.": "网关由另一个 magpie 提供,它的路由在那个 magpie 的窗口里实时播放。", "The gateway isn't running, so nothing is routed.": "网关没有运行,因此没有任何路由。", + "The gateway is served by a magpie without a window (magpie serve), so its routing can't be watched. Stop it and let this magpie serve the gateway to see routing live.": "网关由一个没有窗口的 magpie(magpie serve)提供,它的路由没法在这里看到。停掉它、让这个 magpie 来提供网关,就能实时看到路由。", "Smart: of the accounts with quota to spare, the one whose allowance renews soonest goes first — what it has left would be lost at the reset. One at 90% or more waits until the others can't answer; one resting after a failure goes last.": "智能:在还有余量的账号里,额度最先重置的排第一——它剩下的额度到重置时就作废了。用到 90% 以上的账号等其他账号都答不了时才用;失败后正在休息的排最后。", "Smart: keys that suit the request go first — one made for the model's own API — then in their order. One resting after a failure goes last.": "智能:适合这个请求的 Key 排前面——专为该模型原生 API 的 Key 优先——其余按顺序。失败后正在休息的排最后。", "In order: the first answers everything until it can't; then the next.": "按顺序:第一个处理所有请求,直到它不行了,再换下一个。", diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 7151f401..c6c44e38 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -1200,7 +1200,11 @@ statB[1].textContent = d.totals.rerouted; statB[2].textContent = d.totals.errors; if (!mine) { - offline(t(providers?.gateway?.running ? "Another magpie serves the gateway; its routing plays live in that magpie's window." : "The gateway isn't running, so nothing is routed.")); + const gw = providers?.gateway; + offline(t(!gw?.running ? "The gateway isn't running, so nothing is routed." + : gw.window ? "Another magpie serves the gateway; its routing plays live in that magpie's window." + // magpie serve: its routing isn't shown anywhere + : "The gateway is served by a magpie without a window (magpie serve), so its routing can't be watched. Stop it and let this magpie serve the gateway to see routing live.")); loaded = false; await new Promise((r) => setTimeout(r, 5000)); continue; diff --git a/internal/gui/backend.go b/internal/gui/backend.go index 54f1b42f..719af67b 100644 --- a/internal/gui/backend.go +++ b/internal/gui/backend.go @@ -22,6 +22,7 @@ var served atomic.Pointer[gateway.Server] // running from before an update, a magpie serve in a terminal), and the // model lists kept warm. func startBackend() (gw *gateway.Server) { + gateway.Window = true // the routing this process serves is shown on its page gw = serveGateway() go watchGateway() // Model lists are fetched, never compiled in: whatever the agents can see diff --git a/internal/gui/providers.go b/internal/gui/providers.go index d52de5fa..aa9f78df 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -102,7 +102,8 @@ type presetJSON struct { type gatewayJSON struct { URL string `json:"url"` Running bool `json:"running"` - Mine bool `json:"mine"` // this process serves it + Mine bool `json:"mine"` // this process serves it + Window bool `json:"window"` // the magpie serving it shows its routing Models int `json:"models"` Calls []gateway.Call `json:"calls"` Groups []gwGroupJSON `json:"groups"` // the catalog's routing groups, listed before the models @@ -295,10 +296,10 @@ func providersState() providersJSON { s.Gateway.Groups = append(s.Gateway.Groups, g) } if gw := served.Load(); gw != nil { - s.Gateway.Running, s.Gateway.Mine = true, true + s.Gateway.Running, s.Gateway.Mine, s.Gateway.Window = true, true, true s.Gateway.Calls = gw.Recent() } else { - s.Gateway.Running = gateway.Running() + s.Gateway.Running, s.Gateway.Window = gateway.Serving() } return s } diff --git a/internal/gui/update.go b/internal/gui/update.go index aec6b563..1995c61c 100644 --- a/internal/gui/update.go +++ b/internal/gui/update.go @@ -257,7 +257,7 @@ func updateRoutes(mux *http.ServeMux, w Windows) { } switch j := updates.json(); { case j.State == "ready": - if restartToUpdate() { + if restartToUpdate(isWeb(w)) { rw.WriteHeader(http.StatusNoContent) go w.Quit() return @@ -274,12 +274,17 @@ 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. -func restartToUpdate() bool { +// 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 { bundle, exe := updates.bundle, updates.exe if !updates.install(true) { return false } + if web { + webReexec.Store(true) + return true + } var err error if bundle != "" { err = update.Relaunch(bundle) diff --git a/internal/gui/web.go b/internal/gui/web.go index d1b33901..a35ba983 100644 --- a/internal/gui/web.go +++ b/internal/gui/web.go @@ -12,7 +12,10 @@ import ( "os" "strings" "sync" + "sync/atomic" "time" + + "github.com/yetone/magpie/internal/update" ) // `magpie web` serves the window's page to a browser, for a computer that @@ -38,15 +41,26 @@ func (webHost) OpenFolder(path string) error { // browser in. type Web struct { Addr, Link string + key string srv *http.Server quit chan struct{} } -// StartWeb serves the page on addr (host:port). Every request needs the +// webReexec is set when the page's restart to update has put the new +// version in: Wait then runs it in this one's place. +var webReexec atomic.Bool + +// webRunKey hands a run's own key to the version it restarts into, so the +// browser's cookie still opens the page (#111). +const webRunKey = "MAGPIE_WEB_RUNKEY" + +// StartWeb serves the page on addr (host:port), as the given version of +// magpie, which keeps itself up to date as the app does. Every request needs the // key the link carries, taken once into a cookie other sites' pages can't // send with a POST (every change is one), so neither a page elsewhere nor // anyone else on the network reaches the settings and keys behind it. -func StartWeb(addr string) (*Web, error) { +func StartWeb(addr, version string) (*Web, error) { + Version = version key, fixed, err := webKey() if err != nil { return nil, err @@ -59,7 +73,7 @@ func StartWeb(addr string) (*Web, error) { if err != nil { return nil, err } - w := &Web{Addr: ln.Addr().String(), quit: make(chan struct{})} + w := &Web{Addr: ln.Addr().String(), key: key, quit: make(chan struct{})} host, port, _ := net.SplitHostPort(w.Addr) if ip := net.ParseIP(host); ip == nil || ip.IsUnspecified() { host = "127.0.0.1" @@ -72,14 +86,25 @@ func StartWeb(addr string) (*Web, error) { ReadHeaderTimeout: 30 * time.Second, } go w.srv.Serve(ln) + updates.start() return w, nil } -// Wait returns once the page's Quit is used. -func (w *Web) Wait() { +// Wait returns once the page's Quit is used. After a restart to update it +// runs the new version in this one's place, with the same arguments and +// key, so the page open in the browser finds it where it was. +func (w *Web) Wait() error { <-w.quit time.Sleep(200 * time.Millisecond) // the answer to the Quit gets out w.srv.Close() + if !webReexec.Load() { + return nil + } + exe, err := update.Executable() + if err != nil { + return err + } + return update.Reexec(exe, os.Args[1:], append(os.Environ(), webRunKey+"="+w.key)) } // webKeyMin is the shortest MAGPIE_WEB_KEY taken: it is all that stands @@ -95,6 +120,12 @@ const webCookieAge = 400 * 24 * time.Hour // MAGPIE_WEB_KEY names one — for a page kept running as a service, which // browsers then stay signed in to across restarts. fixed says which. func webKey() (key string, fixed bool, err error) { + if k := os.Getenv(webRunKey); k != "" { + os.Unsetenv(webRunKey) // the run's alone, not its children's + if len(k) == 32 && strings.Trim(k, "0123456789abcdef") == "" && os.Getenv("MAGPIE_WEB_KEY") == "" { + return k, false, nil + } + } if k := os.Getenv("MAGPIE_WEB_KEY"); k != "" { if len(k) < webKeyMin { return "", false, fmt.Errorf("MAGPIE_WEB_KEY is %d characters; it needs at least %d", len(k), webKeyMin) diff --git a/internal/update/proc_unix.go b/internal/update/proc_unix.go index cd9700f5..0068bdfb 100644 --- a/internal/update/proc_unix.go +++ b/internal/update/proc_unix.go @@ -26,3 +26,10 @@ func detach(cmd *exec.Cmd) { } cmd.SysProcAttr.Setsid = true } + +// Reexec runs exe in this process's place with args and env: the same +// process, in the same terminal or under the same service manager, now +// running the new version (magpie web's restart to update). +func Reexec(exe string, args, env []string) error { + return syscall.Exec(exe, append([]string{exe}, args...), env) +} diff --git a/internal/update/proc_windows.go b/internal/update/proc_windows.go index dbc01124..5b73ebc6 100644 --- a/internal/update/proc_windows.go +++ b/internal/update/proc_windows.go @@ -1,8 +1,11 @@ package update import ( + "os" "os/exec" "syscall" + + "github.com/yetone/magpie/internal/proc" ) const ( @@ -32,3 +35,16 @@ func detach(cmd *exec.Cmd) { } cmd.SysProcAttr.CreationFlags |= detachedProc | newProcGroup } + +// Reexec starts exe with args and env in this console and returns, for +// the caller to exit: Windows can't run another program in a process's +// place, so the new version goes on printing where this one did. +func Reexec(exe string, args, env []string) error { + cmd := proc.Command(exe, args...) + cmd.Env = env + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Start(); err != nil { + return err + } + return cmd.Process.Release() +} diff --git a/web_cli.go b/web_cli.go index cb7da71c..53a2e2d6 100644 --- a/web_cli.go +++ b/web_cli.go @@ -36,7 +36,7 @@ func webCmd(args []string) error { _, port, _ := net.SplitHostPort(webAddr) addr = "0.0.0.0:" + port } - w, err := gui.StartWeb(addr) + w, err := gui.StartWeb(addr, version) if err != nil { return err } @@ -58,6 +58,5 @@ func webCmd(args []string) error { if open { openInBrowser(w.Link) } - w.Wait() - return nil + return w.Wait() }