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 00000000..1988ac6c Binary files /dev/null and b/build/windows/icon-128.png differ diff --git a/build/windows/icon-16.png b/build/windows/icon-16.png new file mode 100644 index 00000000..5eef8ad9 Binary files /dev/null and b/build/windows/icon-16.png differ diff --git a/build/windows/icon-256.png b/build/windows/icon-256.png new file mode 100644 index 00000000..6531d29b Binary files /dev/null and b/build/windows/icon-256.png differ diff --git a/build/windows/icon-32.png b/build/windows/icon-32.png new file mode 100644 index 00000000..9349bde4 Binary files /dev/null and b/build/windows/icon-32.png differ diff --git a/build/windows/icon-48.png b/build/windows/icon-48.png new file mode 100644 index 00000000..7c12f50d Binary files /dev/null and b/build/windows/icon-48.png differ diff --git a/build/windows/icon-64.png b/build/windows/icon-64.png new file mode 100644 index 00000000..a38774f1 Binary files /dev/null and b/build/windows/icon-64.png differ diff --git a/build/windows/winres.json b/build/windows/winres.json new file mode 100644 index 00000000..e6d7b3df --- /dev/null +++ b/build/windows/winres.json @@ -0,0 +1,45 @@ +{ + "RT_GROUP_ICON": { + "APP": { + "0000": ["icon-256.png", "icon-128.png", "icon-64.png", "icon-48.png", "icon-32.png", "icon-16.png"] + } + }, + "RT_MANIFEST": { + "#1": { + "0409": { + "identity": { "name": "ai.usemagpie.magpie", "version": "" }, + "description": "magpie", + "minimum-os": "win10", + "execution-level": "as invoker", + "ui-access": false, + "auto-elevate": false, + "dpi-awareness": "per monitor v2", + "disable-theming": false, + "disable-window-filtering": false, + "high-resolution-scrolling-aware": false, + "ultra-high-resolution-scrolling-aware": false, + "long-path-aware": true, + "printer-driver-isolation": false, + "gdi-scaling": false, + "segment-heap": false, + "use-common-controls-v6": true + } + } + }, + "RT_VERSION": { + "#1": { + "0000": { + "fixed": { "file_version": "0.0.0.0", "product_version": "0.0.0.0" }, + "info": { + "0409": { + "CompanyName": "magpie", + "FileDescription": "magpie", + "LegalCopyright": "© 2026 yetone · MIT", + "OriginalFilename": "magpie.exe", + "ProductName": "magpie" + } + } + } + } + } +} diff --git a/console_windows.go b/console_windows.go new file mode 100644 index 00000000..643aa29e --- /dev/null +++ b/console_windows.go @@ -0,0 +1,31 @@ +package main + +import ( + "os" + "syscall" +) + +// The desktop build is linked as a GUI program so double-clicking it opens +// no console window; such a program starts with no standard streams. When it +// is run from a terminal with a command, borrow that terminal's console so +// `magpie ls` and friends still print. Output that is redirected already has +// somewhere to go and is left alone. +func init() { + if len(os.Args) < 2 { + return + } + if t, err := syscall.GetFileType(syscall.Stdout); err == nil && t != 0 { + return // FILE_TYPE_UNKNOWN is 0: nothing attached + } + const attachParentProcess = ^uintptr(0) // (DWORD)-1 + r, _, _ := syscall.NewLazyDLL("kernel32.dll").NewProc("AttachConsole").Call(attachParentProcess) + if r == 0 { + return + } + if out, err := os.OpenFile("CONOUT$", os.O_RDWR, 0); err == nil { + os.Stdout, os.Stderr = out, out + } + if in, err := os.OpenFile("CONIN$", os.O_RDWR, 0); err == nil { + os.Stdin = in + } +} diff --git a/internal/gui/app.go b/internal/gui/app.go index a62acfc8..30674f6b 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -14,6 +14,7 @@ import ( "github.com/yetone/magpie/internal/catalog" "github.com/yetone/magpie/internal/gateway" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/update" ) //go:embed tray.png @@ -64,6 +65,9 @@ func (h *host) FitPanel(height int) { // panel, plus a regular window for when you want it to stay around. // showMain opens the window immediately; otherwise only the tray icon appears. func Run(version string, showMain bool) error { + // After an update off the Mac, the old process starts this one and then + // quits; let it go before looking for the gateway. + update.AwaitPredecessor() // The gateway runs inside the app. If another magpie already has the // port, that one serves and this one only shows its status. var gw *gateway.Server diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index a34040ca..503b8db1 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -109,8 +109,8 @@ code { font: 11.5px var(--code); } /* ---- header --------------------------------------------------------------- */ .top { display: flex; align-items: center; gap: 8px; padding: 0 10px 0 14px; height: 46px; flex: none; position: relative; } -body.window .top { padding-left: 88px; height: 50px; padding-top: 2px; } /* the traffic lights centre 25.75pt from the top, not 25 */ -body.window .seg { top: calc(50% + 1px); } /* 50% centres in the padding box; the 2px top padding puts the flex row a pixel lower */ +body.window.mac .top { padding-left: 88px; height: 50px; padding-top: 2px; } /* the traffic lights centre 25.75pt from the top, not 25 */ +body.window.mac .seg { top: calc(50% + 1px); } /* 50% centres in the padding box; the 2px top padding puts the flex row a pixel lower */ .brand { display: flex; align-items: center; gap: 6px; font-weight: 600; letter-spacing: .01em; } .logo { color: var(--fg); display: inline-flex; } /* ink, like the wordmark; the accent blue fought the text in light mode */ .seg { @@ -688,7 +688,8 @@ html.theming, html.theming *, html.theming *::before, html.theming *::after { .modal .dialog::-webkit-scrollbar { width: 0; } /* Keep native window chrome outside the scrim and dialog. In particular, the macOS traffic lights must never sit on top of modal content. */ -body.window .modal { top: 50px; padding: 16px 24px 24px; } +body.window.mac .modal { top: 50px; padding: 16px 24px 24px; } +body.window .modal { padding: 16px 24px 24px; } body.panel .modal { padding: 16px 12px; } @keyframes fade-in { from { opacity: 0; } } @keyframes fade-out { to { opacity: 0; } } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 9cf01371..afd98549 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -4,6 +4,8 @@ const $$ = (s) => 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)