From 75cb192647533f8d681f2e959c25f6b3699ae359 Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 24 Sep 2026 03:22:02 +0800 Subject: [PATCH] Desktop app for Windows and Linux The Windows app cross-compiles (WebView2, no cgo) as a GUI program with an icon, version info and a DPI-aware manifest; run from a terminal with a command it borrows the console. The Linux app links GTK 3 and WebKitGTK 4.1. Off the Mac the app updates its own binary from its own build: downloaded in the background, swapped in on restart or quit, and the relaunched process waits for the old one before taking the gateway. The window leaves room for traffic lights only on a Mac. --- .gitignore | 1 + Makefile | 33 ++++++++++-- README.md | 17 +++++-- build/windows/icon-128.png | Bin 0 -> 3252 bytes build/windows/icon-16.png | Bin 0 -> 459 bytes build/windows/icon-256.png | Bin 0 -> 6619 bytes build/windows/icon-32.png | Bin 0 -> 948 bytes build/windows/icon-48.png | Bin 0 -> 1484 bytes build/windows/icon-64.png | Bin 0 -> 1972 bytes build/windows/winres.json | 45 +++++++++++++++++ console_windows.go | 31 ++++++++++++ internal/gui/app.go | 4 ++ internal/gui/assets/app.css | 7 +-- internal/gui/assets/app.js | 2 + internal/gui/update.go | 41 ++++++++++++--- internal/update/proc_unix.go | 23 +++++++++ internal/update/proc_windows.go | 30 +++++++++++ internal/update/update.go | 86 ++++++++++++++++++++++++++------ main.go | 2 + 19 files changed, 286 insertions(+), 36 deletions(-) create mode 100644 build/windows/icon-128.png create mode 100644 build/windows/icon-16.png create mode 100644 build/windows/icon-256.png create mode 100644 build/windows/icon-32.png create mode 100644 build/windows/icon-48.png create mode 100644 build/windows/icon-64.png create mode 100644 build/windows/winres.json create mode 100644 console_windows.go create mode 100644 internal/update/proc_unix.go create mode 100644 internal/update/proc_windows.go diff --git a/.gitignore b/.gitignore index 87ba22cd..ae73d5bc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /magpie.app/ /dist/ /magpie-dev +/rsrc_windows_*.syso diff --git a/Makefile b/Makefile index 4b79a728..8122d2de 100644 --- a/Makefile +++ b/Makefile @@ -9,17 +9,22 @@ ifeq ($(shell uname -s),Darwin) export CGO_CFLAGS = -mmacosx-version-min=11.0 export CGO_LDFLAGS = -mmacosx-version-min=11.0 endif +# On Linux, GTK 3 and WebKitGTK 4.1: older distributions have them, where +# Wails' default GTK 4 and WebKitGTK 6 would leave them out. +ifeq ($(shell uname -s),Linux) + TAGS += gtk3 +endif -.PHONY: build cli install test app icons release release-cli clean dev dev-once +.PHONY: build cli install test app icons release release-cli release-windows release-linux clean dev dev-once build: - go build -tags $(TAGS) -trimpath -ldflags="$(LDFLAGS)" -o magpie . + go build -tags "$(TAGS)" -trimpath -ldflags="$(LDFLAGS)" -o magpie . cli: CGO_ENABLED=0 go build -tags nogui -trimpath -ldflags="$(LDFLAGS)" -o magpie . install: - go install -tags $(TAGS) -trimpath -ldflags="$(LDFLAGS)" . + go install -tags "$(TAGS)" -trimpath -ldflags="$(LDFLAGS)" . test: go vet ./... && go test ./... @@ -56,8 +61,28 @@ release-cli: CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -tags nogui -trimpath -ldflags="$(LDFLAGS)" -o dist/magpie-cli-$$os-$$arch$$ext . ; \ done +# The desktop app for Windows: the system WebView2 needs no cgo, so both +# arches cross-compile. Linked as a GUI program (no console window), with +# the icon, version and a DPI-aware manifest from build/windows. +WINVER = $(shell echo $(VERSION) | sed -E 's/^v//; s/[^0-9.].*//') +release-windows: + @mkdir -p dist + @go run github.com/tc-hib/go-winres@v0.3.3 make --in build/windows/winres.json --arch amd64,arm64 --out rsrc \ + --file-version "$(or $(WINVER),0.0.0)" --product-version "$(or $(WINVER),0.0.0)" + @for arch in amd64 arm64; do \ + echo " windows/$$arch (app)"; \ + CGO_ENABLED=0 GOOS=windows GOARCH=$$arch go build -tags production -trimpath -ldflags="$(LDFLAGS) -H windowsgui" -o dist/magpie-windows-$$arch.exe . || exit 1; \ + done; rm -f rsrc_windows_*.syso + +# The desktop app for Linux links GTK 3 and WebKitGTK 4.1 through cgo, so it +# is built natively, one arch per machine. +release-linux: + @mkdir -p dist + @echo " linux/$(shell go env GOARCH) (app)" + @go build -tags "$(TAGS)" -trimpath -ldflags="$(LDFLAGS)" -o dist/magpie-linux-$(shell go env GOARCH) . + clean: - rm -rf magpie magpie.exe magpie.app dist + rm -rf magpie magpie.exe magpie.app dist rsrc_windows_*.syso # Development: the UI is served from internal/gui/assets and the window # reloads itself when a file there is saved; with fswatch installed, Go diff --git a/README.md b/README.md index fec31dbe..6ac4900f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ and there is a terminal version (`magpie tui`) and a plain CLI. ↑↓ agent · ←→ field · ↵ change · s save profile · p profiles · q quit ``` -- **One small binary.** About 11 MB with the desktop app (it uses the system +- **One small binary.** Under 15 MB with the desktop app (it uses the system webview through [Wails](https://wails.io), nothing bundled), 7 MB for the terminal-only build. macOS, Linux and Windows. - **Edits config files surgically.** Only the one key you change is touched; @@ -179,14 +179,18 @@ the API key goes to `~/.gemini/.env`. Picking a catalog model points ## Install -Download the Mac app from [usemagpie.ai](https://usemagpie.ai), or from a -terminal (macOS installs the app, Linux the command): +Download the app for macOS, Windows or Linux from +[usemagpie.ai](https://usemagpie.ai), or install it from a terminal (on +Linux, the desktop app when WebKitGTK 4.1 is installed, the command +otherwise): ```sh curl -fsSL https://usemagpie.ai/install.sh | sh ``` -Releases are signed and notarised, and keep themselves current: the app +Mac releases are signed and notarised; the Windows and Linux builds are not +signed yet (Windows SmartScreen may ask before the first run). Every build +keeps itself current: the app downloads a new version in the background and installs it when you restart (*Restart to Update* in the menu) or quit; `magpie update` does the same from a terminal. Every release is on @@ -205,9 +209,12 @@ make build # ./magpie with the desktop app (needs cgo + the platform make app # macOS: magpie.app, a menu bar app with no Dock icon make cli # terminal-only build, no cgo, cross-compiles anywhere make release # dist/: native app build + cli builds for every platform +make release-windows # dist/: the Windows app, amd64 and arm64 (cross-compiles) +make release-linux # dist/: the Linux app for this machine's arch ``` -Linux needs `libgtk-3-dev` and `libwebkit2gtk-4.1-dev` for the app build; +Linux needs `libgtk-3-dev` and `libwebkit2gtk-4.1-dev` for the app build +(the Makefile adds the `gtk3` tag; with plain `go build`, pass `-tags gtk3`); Windows uses the WebView2 runtime that ships with the OS. ### Developing diff --git a/build/windows/icon-128.png b/build/windows/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..1988ac6c448a291afc588c1e0fb8b26d1d85b34c GIT binary patch literal 3252 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 document.querySelectorAll(s); const params = new URLSearchParams(location.search); const mode = params.get("mode") || "window"; document.body.classList.add(mode); +// Only the Mac window draws its title bar inside the page (the traffic lights). +if (/^Mac/.test(navigator.platform)) document.body.classList.add("mac"); if (params.get("theme")) document.documentElement.dataset.theme = params.get("theme"); let state = { agents: [], profiles: [], catalog: "", settings: {} }; diff --git a/internal/gui/update.go b/internal/gui/update.go index 7ea0a613..121317db 100644 --- a/internal/gui/update.go +++ b/internal/gui/update.go @@ -4,7 +4,9 @@ import ( "context" "log" "net/http" + "os" "path/filepath" + "runtime" "sync" "time" @@ -12,9 +14,9 @@ import ( ) // updater keeps the app current. It asks the feed at start-up and every six -// hours; when a newer release is out and the app may replace its own -// bundle, the new one is downloaded and unpacked straight away, so all that -// is left is a restart. Quitting installs it too, and the next launch is +// hours; when a newer release is out and the app may replace itself — the +// bundle on a Mac, the binary elsewhere — the new one is downloaded +// straight away, so all that is left is a restart. Quitting installs it too, and the next launch is // the new version. type updater struct { mu sync.Mutex @@ -22,6 +24,7 @@ type updater struct { latest *update.Release err string bundle string // the .app to replace, "" when not in one or not writable + exe string // off the Mac: the binary to replace, "" when not writable staged string onReady func(version string) } @@ -42,6 +45,11 @@ const updateEvery = 6 * time.Hour func (u *updater) start() { if b := update.Bundle(); b != "" && update.Writable(filepath.Dir(b)) { u.bundle = b + } else if runtime.GOOS != "darwin" { + if exe, err := update.Executable(); err == nil && update.Writable(filepath.Dir(exe)) { + u.exe = exe + os.Remove(exe + ".old") // what the last update on Windows moved aside + } } go func() { time.Sleep(5 * time.Second) // let the app settle first @@ -79,13 +87,18 @@ func (u *updater) check() { case !update.Newer(rel.Version, Version): u.state = "latest" return - case u.bundle == "": + case u.bundle == "" && u.exe == "": u.state = "available" // the user fetches it from the release page return } u.state = "downloading" u.mu.Unlock() - staged, err := update.Stage(ctx, rel, u.bundle) + var staged string + if u.bundle != "" { + staged, err = update.Stage(ctx, rel, u.bundle) + } else { + staged, err = update.StageBinary(ctx, rel) + } u.mu.Lock() if err != nil { log.Println("update:", err) @@ -105,7 +118,13 @@ func (u *updater) install() bool { if u.staged == "" { return false } - if err := update.Install(u.staged, u.bundle); err != nil { + var err error + if u.bundle != "" { + err = update.Install(u.staged, u.bundle) + } else { + err = update.InstallBinary(u.staged) + } + if err != nil { log.Println("update:", err) u.state, u.err = "error", err.Error() return false @@ -151,11 +170,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 { - bundle := updates.bundle + bundle, exe := updates.bundle, updates.exe if !updates.install() { return false } - if err := update.Relaunch(bundle); err != nil { + var err error + if bundle != "" { + err = update.Relaunch(bundle) + } else { + err = update.RelaunchBinary(exe) + } + if err != nil { log.Println("update:", err) } return true diff --git a/internal/update/proc_unix.go b/internal/update/proc_unix.go new file mode 100644 index 00000000..f5e5dd7f --- /dev/null +++ b/internal/update/proc_unix.go @@ -0,0 +1,23 @@ +//go:build !windows + +package update + +import ( + "os" + "os/exec" + "syscall" +) + +// alive reports whether pid is still running. A relaunched magpie is that +// process's child, so its exit shows as a new parent even before the +// process table lets go of it. +func alive(pid int) bool { + if os.Getppid() != pid { + return syscall.Kill(pid, 0) == nil + } + return true +} + +// detach keeps a relaunched magpie out of this one's process group, so it +// outlives it. +func detach(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} } diff --git a/internal/update/proc_windows.go b/internal/update/proc_windows.go new file mode 100644 index 00000000..f2277e44 --- /dev/null +++ b/internal/update/proc_windows.go @@ -0,0 +1,30 @@ +package update + +import ( + "os/exec" + "syscall" +) + +const ( + synchronize = 0x00100000 + waitTimeout = 0x00000102 + detachedProc = 0x00000008 + newProcGroup = 0x00000200 +) + +// alive reports whether pid is still running. +func alive(pid int) bool { + h, err := syscall.OpenProcess(synchronize, false, uint32(pid)) + if err != nil { + return false + } + defer syscall.CloseHandle(h) + ev, _ := syscall.WaitForSingleObject(h, 0) + return ev == waitTimeout +} + +// detach starts a relaunched magpie outside this one's console, so it +// outlives it. +func detach(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: detachedProc | newProcGroup} +} diff --git a/internal/update/update.go b/internal/update/update.go index 0ecff4e9..7c84f9d4 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -4,8 +4,9 @@ // // The macOS app replaces its own bundle: the new zip is downloaded, checked // against its hash, unpacked, and accepted only if it is signed by the same -// team as the app already installed. A bare binary (the terminal build) -// replaces itself the same way, minus the signature. +// team as the app already installed. A bare binary (the terminal build, +// and the desktop app on Windows and Linux) replaces itself the same way, +// minus the signature. package update import ( @@ -138,10 +139,18 @@ func parse(v string) *semver { return &s } +// GUI says this binary has the desktop app in it. Off the Mac that app is a +// single binary too, and it updates from its own build, not the terminal +// one. +var GUI bool + // AppAsset and BinaryAsset name the files this machine would install. func AppAsset() string { return "magpie-darwin-" + runtime.GOARCH + ".zip" } func BinaryAsset() string { name := "magpie-cli-" + runtime.GOOS + "-" + runtime.GOARCH + if GUI && runtime.GOOS != "darwin" { + name = "magpie-" + runtime.GOOS + "-" + runtime.GOARCH + } if runtime.GOOS == "windows" { name += ".exe" } @@ -154,7 +163,7 @@ func Bundle() string { if runtime.GOOS != "darwin" { return "" } - exe, err := executable() + exe, err := Executable() if err != nil { return "" } @@ -166,7 +175,8 @@ func Bundle() string { return app } -func executable() (string, error) { +// Executable is the running binary, symlinks resolved. +func Executable() (string, error) { exe, err := os.Executable() if err != nil { return "", err @@ -264,40 +274,84 @@ func Relaunch(bundle string) error { return cmd.Start() } -// ReplaceBinary puts the release's terminal build where the running binary -// is. +// ReplaceBinary puts the release's build for this binary where the +// running one is. func ReplaceBinary(ctx context.Context, rel *Release) error { - a, ok := rel.Assets[BinaryAsset()] - if !ok { - return fmt.Errorf("release %s has no %s", rel.Version, BinaryAsset()) - } - exe, err := executable() + staged, err := StageBinary(ctx, rel) if err != nil { return err } + return InstallBinary(staged) +} + +// StageBinary downloads the release's build for this binary next to it, +// ready for InstallBinary. +func StageBinary(ctx context.Context, rel *Release) (string, error) { + a, ok := rel.Assets[BinaryAsset()] + if !ok { + return "", fmt.Errorf("release %s has no %s", rel.Version, BinaryAsset()) + } + exe, err := Executable() + if err != nil { + return "", err + } tmp := exe + ".new" if err := download(ctx, a, tmp); err != nil { - return err + return "", err } if err := os.Chmod(tmp, 0o755); err != nil { os.Remove(tmp) - return err + return "", err } + return tmp, nil +} + +// InstallBinary swaps a staged binary in for the running one, which keeps +// going until it exits. +func InstallBinary(staged string) error { + exe := strings.TrimSuffix(staged, ".new") if runtime.GOOS == "windows" { // A running .exe cannot be overwritten, but it can be moved aside. os.Remove(exe + ".old") if err := os.Rename(exe, exe+".old"); err != nil { - os.Remove(tmp) + os.Remove(staged) return err } } - if err := os.Rename(tmp, exe); err != nil { - os.Remove(tmp) + if err := os.Rename(staged, exe); err != nil { + os.Remove(staged) return err } return nil } +// 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 := exec.Command(exe, "tray") + cmd.Env = append(os.Environ(), "MAGPIE_REPLACES="+strconv.Itoa(os.Getpid())) + cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil + detach(cmd) + if err := cmd.Start(); err != nil { + return err + } + return cmd.Process.Release() +} + +// AwaitPredecessor blocks, for a while at most, until the magpie that +// relaunched this one has exited. +func AwaitPredecessor() { + pid, err := strconv.Atoi(os.Getenv("MAGPIE_REPLACES")) + os.Unsetenv("MAGPIE_REPLACES") + if err != nil || pid <= 0 { + return + } + for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline) && alive(pid); { + time.Sleep(100 * time.Millisecond) + } +} + // download fetches a to path and checks its hash. func download(ctx context.Context, a Asset, path string) error { if a.SHA256 == "" { diff --git a/main.go b/main.go index d66b6a3d..91d5e950 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "github.com/yetone/magpie/internal/profile" "github.com/yetone/magpie/internal/settings" "github.com/yetone/magpie/internal/tui" + "github.com/yetone/magpie/internal/update" ) var version = "dev" @@ -62,6 +63,7 @@ var ( func main() { gateway.Version = version + update.GUI = hasGUI if err := run(os.Args[1:]); err != nil { fmt.Fprintln(os.Stderr, "magpie:", err) os.Exit(1)