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.
This commit is contained in:
yetone committed 2026-09-24 03:22:02 +08:00
1 parent b98af9acc6
commit 75cb192647
19 files changed
+286 -36

No files matched your search

+1
View File
@@ -3,3 +3,4 @@
/magpie.app/
/dist/
/magpie-dev
/rsrc_windows_*.syso
+29 -4
View File
@@ -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
+12 -5
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+45
View File
@@ -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"
}
}
}
}
}
}
+31
View File
@@ -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
}
}
+4
View File
@@ -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
+4 -3
View File
@@ -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; } }
+2
View File
@@ -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: {} };
+33 -8
View File
@@ -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
+23
View File
@@ -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} }
+30
View File
@@ -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}
}
+70 -16
View File
@@ -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 == "" {
+2
View File
@@ -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)