From e40f63a942d454523225f3ed5f6bac81f78331a2 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 17 Jul 2026 19:32:44 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(dock):=20desktop=20launcher=20?= =?UTF-8?q?=E2=80=94=20tray/menu-bar=20app=20that=20runs=20the=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd/dock is a small pure-Go launcher (fyne.io/systray): menu bar item on macOS, notification-area icon on Windows, StatusNotifierItem on Linux. It spawns the sibling chartplotter binary's serve subcommand, adopts an already-running server (via the new "app" field on /api/health), opens the browser when healthy, and stops the child on quit. Headless fallback when no tray host is on the bus (stock GNOME); single-instance via lock file; child output logged to the state dir. Packaging: make xbuild-dock cross-builds dist/dock__ for linux+windows (windows -H=windowsgui) and ships the Linux .desktop + icon; make package-macos assembles an LSUIElement ChartPlotter.app (Info.plist template + AppIcon.icns) natively on a mac. Tray/app icons are generated glyphs (gen_icons.go). --- Makefile | 20 ++- cmd/dock/appicon.png | Bin 0 -> 23655 bytes cmd/dock/gen_icons.go | 97 ++++++++++++ cmd/dock/health.go | 46 ++++++ cmd/dock/icon.go | 41 +++++ cmd/dock/icon.ico | Bin 0 -> 491 bytes cmd/dock/icon.png | Bin 0 -> 469 bytes cmd/dock/icon_error.ico | Bin 0 -> 521 bytes cmd/dock/icon_error.png | Bin 0 -> 499 bytes cmd/dock/lock_unix.go | 27 ++++ cmd/dock/lock_windows.go | 32 ++++ cmd/dock/main.go | 190 +++++++++++++++++++++++ cmd/dock/open.go | 22 +++ cmd/dock/paths.go | 47 ++++++ cmd/dock/process.go | 207 ++++++++++++++++++++++++++ cmd/dock/process_unix.go | 14 ++ cmd/dock/process_windows.go | 22 +++ cmd/dock/sni_linux.go | 20 +++ cmd/dock/sni_other.go | 6 + go.mod | 8 +- go.sum | 6 + internal/engine/server/http.go | 4 +- internal/engine/server/server_test.go | 6 +- packaging/linux/chartplotter.desktop | 8 + packaging/macos/Info.plist | 31 ++++ scripts/macos-app.sh | 46 ++++++ scripts/xbuild-dock.sh | 35 +++++ 27 files changed, 929 insertions(+), 6 deletions(-) create mode 100644 cmd/dock/appicon.png create mode 100644 cmd/dock/gen_icons.go create mode 100644 cmd/dock/health.go create mode 100644 cmd/dock/icon.go create mode 100644 cmd/dock/icon.ico create mode 100644 cmd/dock/icon.png create mode 100644 cmd/dock/icon_error.ico create mode 100644 cmd/dock/icon_error.png create mode 100644 cmd/dock/lock_unix.go create mode 100644 cmd/dock/lock_windows.go create mode 100644 cmd/dock/main.go create mode 100644 cmd/dock/open.go create mode 100644 cmd/dock/paths.go create mode 100644 cmd/dock/process.go create mode 100644 cmd/dock/process_unix.go create mode 100644 cmd/dock/process_windows.go create mode 100644 cmd/dock/sni_linux.go create mode 100644 cmd/dock/sni_other.go create mode 100644 packaging/linux/chartplotter.desktop create mode 100644 packaging/macos/Info.plist create mode 100755 scripts/macos-app.sh create mode 100755 scripts/xbuild-dock.sh diff --git a/Makefile b/Makefile index 9411d08..16828a6 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ CACHE ?= $(if $(XDG_CACHE_HOME),$(XDG_CACHE_HOME),$(HOME)/.cache)/chartplotter S101_PC ?= $(HOME)/Projects/s101-portrayal-catalogue/PortrayalCatalog S101_FC ?= $(HOME)/Projects/s101-feature-catalogue/S-101FC/FeatureCatalogue.xml -.PHONY: build build-tile57 tile57-lib vendor-style-engine xbuild xbuild-tile57 test vet fmt fmt-check tidy clean clear-cache serve docs docs-shots bake-ienc bake-noaa serve-widget demo demo-chart1 serve-demo preslib-chart1 s64-pages +.PHONY: build build-tile57 build-dock tile57-lib vendor-style-engine xbuild xbuild-tile57 xbuild-dock package-macos test vet fmt fmt-check tidy clean clear-cache serve docs docs-shots bake-ienc bake-noaa serve-widget demo demo-chart1 serve-demo preslib-chart1 s64-pages # Prebaked prod test set (US Inland ENC bundle + the NOAA world archive). # NB: keep these as bare values with NO inline `#` comments — Make folds any @@ -122,6 +122,13 @@ build: $(TILE57_LIB) ## Build bin/chartplotter (CGO + native libtile57; fetches # Back-compat alias — libtile57 is now the default engine, so this is just `build`. build-tile57: build ## Alias for `build` (libtile57 is the sole engine now) +# The dock is the desktop launcher (cmd/dock): a tray/menu-bar app that +# spawns `chartplotter serve` as a child. Pure Go on linux/windows (no libtile57, +# no Zig); systray needs cgo/Cocoa on darwin only. +build-dock: ## Build bin/dock (desktop launcher tray app; no libtile57) + CGO_ENABLED=$(if $(filter darwin,$(shell go env GOOS)),1,0) go build -ldflags "-X main.version=$(VERSION)" -o bin/dock ./cmd/dock + @echo "→ bin/dock" + # Quick cross-platform test builds. CGO is off, so this is pure `go build` per # target — fast cold, near-instant on re-runs thanks to the build cache. Stamps # the same version as `build`; strips symbols (-s -w) and paths (-trimpath) like a @@ -136,6 +143,17 @@ build-tile57: build ## Alias for `build` (libtile57 is the sole engine now) # Outputs dist/chartplotter__[.exe]. xbuild xbuild-tile57: $(TILE57)/include/tile57.h ## Cross-compile CGO+libtile57 binaries with zig cc (linux+windows; darwin builds on a Mac runner) VERSION="$(VERSION)" TILE57="$(TILE57)" ENGINE_COMMIT="$(ENGINE_COMMIT)" scripts/xbuild-tile57.sh + VERSION="$(VERSION)" scripts/xbuild-dock.sh + +# Dock-only cross-builds (pure Go, no Zig): dist/dock__[.exe] plus the +# Linux desktop-integration files. darwin: see package-macos. +xbuild-dock: ## Cross-compile the dock launcher for linux+windows into dist/ + VERSION="$(VERSION)" scripts/xbuild-dock.sh + +# Assemble dist/ChartPlotter.app (+ zip) natively on macOS: engine + dock + +# Info.plist (LSUIElement) + AppIcon.icns, ad-hoc signed (IDENTITY= to override). +package-macos: ## Build ChartPlotter.app + zip into dist/ (run on macOS) + VERSION="$(VERSION)" scripts/macos-app.sh serve: build ## Serve the web frontend + provisioning API on :8080 (HOST/PORT/ASSETS overridable) $(BIN) serve --host $(HOST) --port $(PORT) --assets $(ASSETS) diff --git a/cmd/dock/appicon.png b/cmd/dock/appicon.png new file mode 100644 index 0000000000000000000000000000000000000000..afd8c55c9108005243f7bcebdb366b200224c750 GIT binary patch literal 23655 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 Ot#ldh@c#kFXI%&Y literal 0 HcmV?d00001 diff --git a/cmd/dock/gen_icons.go b/cmd/dock/gen_icons.go new file mode 100644 index 0000000..34258ff --- /dev/null +++ b/cmd/dock/gen_icons.go @@ -0,0 +1,97 @@ +//go:build ignore + +// gen_icons.go regenerates the dock's icon assets: a monochrome compass rose +// (placeholder until an OpenBridge asset replaces it) as 32px tray glyphs — PNG +// for macOS/Linux, PNG-in-ICO for Windows, plus an error-badged variant — and a +// 512px appicon.png that packaging turns into the macOS AppIcon.icns and the +// Linux .desktop icon. Run with `go generate ./cmd/dock`. +package main + +import ( + "bytes" + "encoding/binary" + "image" + "image/color" + "image/png" + "math" + "os" +) + +func glyph(size int, badge bool) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, size, size)) + s := float64(size) / 32 // all geometry is designed at 32px and scaled + c := float64(size) / 2 + set := func(x, y int, a float64) { + if a <= 0 { + return + } + if a > 1 { + a = 1 + } + img.SetNRGBA(x, y, color.NRGBA{0, 0, 0, uint8(a * 255)}) + } + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + fx, fy := float64(x)+0.5-c, float64(y)+0.5-c + r := math.Hypot(fx, fy) + // Outer ring at radius 13.5, softened for AA. + a := 1.5 - math.Abs(r-13.5*s)/(1.1*s) + // Four-point star: pinched diamond |x·y| ≤ k, |x|+|y| ≤ L. + if math.Abs(fx*fy) <= 14*s*s && math.Abs(fx)+math.Abs(fy) <= 11*s { + a = 1 + } + set(x, y, a) + } + } + if badge { + // Filled disc, bottom-right, with a punched-out gap so the badge reads. + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + d := math.Hypot(float64(x)-25.5*s, float64(y)-25.5*s) + switch { + case d <= 5.5*s: + img.SetNRGBA(x, y, color.NRGBA{0, 0, 0, 255}) + case d <= 7.5*s: + img.SetNRGBA(x, y, color.NRGBA{}) + } + } + } + } + return img +} + +func encode(size int, badge bool) []byte { + var b bytes.Buffer + if err := png.Encode(&b, glyph(size, badge)); err != nil { + panic(err) + } + return b.Bytes() +} + +// ico wraps one PNG in a single-image ICO container (valid since Vista). +func ico(pngData []byte, size int) []byte { + var b bytes.Buffer + binary.Write(&b, binary.LittleEndian, []uint16{0, 1, 1}) // ICONDIR + b.Write([]byte{byte(size), byte(size), 0, 0}) // w, h, colors, reserved + binary.Write(&b, binary.LittleEndian, []uint16{1, 32}) // planes, bpp + binary.Write(&b, binary.LittleEndian, uint32(len(pngData))) // data size + binary.Write(&b, binary.LittleEndian, uint32(6+16)) // data offset + b.Write(pngData) + return b.Bytes() +} + +func write(name string, data []byte) { + if err := os.WriteFile(name, data, 0o644); err != nil { + panic(err) + } +} + +func main() { + tray := encode(32, false) + trayErr := encode(32, true) + write("icon.png", tray) + write("icon.ico", ico(tray, 32)) + write("icon_error.png", trayErr) + write("icon_error.ico", ico(trayErr, 32)) + write("appicon.png", encode(512, false)) +} diff --git a/cmd/dock/health.go b/cmd/dock/health.go new file mode 100644 index 0000000..684c5c4 --- /dev/null +++ b/cmd/dock/health.go @@ -0,0 +1,46 @@ +package main + +import ( + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "syscall" + "time" +) + +type probeResult int + +const ( + probeFree probeResult = iota // connection refused — nobody listening + probeOurs // chartplotter answered /api/health + probeOther // something answered, but not chartplotter +) + +var probeClient = &http.Client{Timeout: 2 * time.Second} + +// probe classifies port: a 200 /api/health whose body says +// "app":"chartplotter" — or the pre-field shape "ok":true — is ours (adoptable); +// refused means free; anything else means the port belongs to someone else. +func probe(port int) probeResult { + resp, err := probeClient.Get(fmt.Sprintf("http://127.0.0.1:%d/api/health", port)) + if err != nil { + var opErr *net.OpError + if errors.As(err, &opErr) && errors.Is(err, syscall.ECONNREFUSED) { + return probeFree + } + return probeOther + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + if resp.StatusCode != http.StatusOK { + return probeOther + } + s := string(body) + if strings.Contains(s, `"app":"chartplotter"`) || strings.Contains(s, `"ok":true`) { + return probeOurs + } + return probeOther +} diff --git a/cmd/dock/icon.go b/cmd/dock/icon.go new file mode 100644 index 0000000..5d96cd5 --- /dev/null +++ b/cmd/dock/icon.go @@ -0,0 +1,41 @@ +package main + +import ( + _ "embed" + "runtime" + + "fyne.io/systray" +) + +// Placeholder compass glyph generated by gen_icons.go (`go generate ./cmd/dock`); +// swap the PNG/ICO pairs for an OpenBridge asset without touching this file. +// Windows tray icons must be ICO; the other platforms take PNG. On macOS the +// PNGs are template images (monochrome + alpha) so the menu bar recolours them. +// +//go:generate go run gen_icons.go + +//go:embed icon.png +var iconPNG []byte + +//go:embed icon_error.png +var iconErrorPNG []byte + +//go:embed icon.ico +var iconICO []byte + +//go:embed icon_error.ico +var iconErrorICO []byte + +// setTrayIcon applies the normal or error-badged glyph. SetTemplateIcon uses +// the first argument on macOS (template rendering) and the second elsewhere. +func setTrayIcon(errState bool) { + png, ico := iconPNG, iconICO + if errState { + png, ico = iconErrorPNG, iconErrorICO + } + regular := png + if runtime.GOOS == "windows" { + regular = ico + } + systray.SetTemplateIcon(png, regular) +} diff --git a/cmd/dock/icon.ico b/cmd/dock/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..807bb3869932907a8057f004a6f3adbf027255ee GIT binary patch literal 491 zcmVAfBE!Dk*JVzfK-V~x)k&|Mh<~eQ{kSY zaEVk@rf?CZN4?3sCQ#=ASa|HzF0NBGQuD_{&<%CkMN z1)hM%6KiZS1xla>4uM}Sw$qrvgTVVCa0}c37v_1B=?2)z{}C{e$|&A|d*BXuPtWl)Z{qpLV?-3ju3Zt@jC2QUcDQwbI0T>FS~$KqRlr!rjU!kL zu}etTcVjuV8){eE8Rt_%;|&Z)%Xv2EfxV0)8MRBEsuH&Ajx47N*vdHZIHrnmwfaei zvqiRLXpvz7ZZzYSF=83 hxt#tQJ^}y$|Nj+p?F;Nb;YR=f002ovPDHLkV1lQ=)z$z2 literal 0 HcmV?d00001 diff --git a/cmd/dock/icon.png b/cmd/dock/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3828c7b8314d7797e93364a007992eda5a0bb8fe GIT binary patch literal 469 zcmV;`0V@89P)aY%z!Ik3|z{yJ+K9yfX5SSY%v8&pau?sUoE!Nn81U; z`yp@(+yEEmd6Ve|*vbD9FpQY!LH4OtsJky_CWvq1`N(5L z6vnPy5!#G&2Wxh?b$~bopWRwGzBpCDSjLSbSPZdCNY{5`Ikg*VSKArqQ$phn3`fg( zHs^u8j3XJfOP;C{w(E{8rwZ80IPo~9igC62Nr$sVwqf3K4iI^{u(|4009606?5$i>_6d000000 LNkvXXu0mjfHFwm9 literal 0 HcmV?d00001 diff --git a/cmd/dock/icon_error.ico b/cmd/dock/icon_error.ico new file mode 100644 index 0000000000000000000000000000000000000000..ca92210ac10e1c753f0a1ad68420fbd763945edf GIT binary patch literal 521 zcmV+k0`~m?0096203aX$0096X0P_I=02TlM0EtjeM-2)Z3IG5A4M|8uQUCw|AOHXW zAP5Ek0047(dh`GQ0lG;AfBE!Dk*JVzfK-V~s8Z197#sqnrouf( z;S#B+OyMF*m4Y%-3P7R?vYCwrll|)@@T6!JduHC-XRl}XAHR(M2{|+10vH45a%~4} zfG6NFv&I%vpag1Q@A=gdI}Is3@Z28)x4;c>rmik@$ER!W4| ziJ`K+^4Q&d`D`eZh^25?Q7YSAGmjB~)H08wv;{TG=2e1h&pcHCQgbgHN0(@+tdAuR z6;ZidsC&#l)`hxzW2TC@UOX3hh=@YPwJX9jBl87ozHloKu@A1em2ez5jexO?YfG^Z zVw;d|?pkx2ZfLsNRwd_zsW(s@E$5%v5A0+d$!NM{E+=%?9cfM@U?bzikr*q+*6Jr6 z&K9RyhF#7tIK_wP8}v)~AmepPAq%2}=59twk1AI?_K{mfm~4{Xcc>A-rHxdXZIh!jQx_Upm5|A5>u(XrPPYT9C(%j zFH-av49PJpBzG^h=Aq;>Mc&Nh$`R`lfaF$6gx86ovc2-y-F^9ND3pk$a9B|)+g&q{ z5rEV(kE65&HOuBzf^5$`RRB_RFC0ggXsN7^B@Y!*xm>7w%s$qIx_e`$inv}p7kP+? zLdCT!!ZaiE1#7-=D-W>`uDF$O95{`Dv5ad=u@GXLkZ$f;bDC~wy4qGH=Y**@P#i7i zpV<%WWE{z8x@0aVbk`keP9tC=&4yQIJQh0v`9{~UW|Nm%S7{?_gJ@)_r002ovPDHLkV1iJM*n$86 literal 0 HcmV?d00001 diff --git a/cmd/dock/lock_unix.go b/cmd/dock/lock_unix.go new file mode 100644 index 0000000..ac07804 --- /dev/null +++ b/cmd/dock/lock_unix.go @@ -0,0 +1,27 @@ +//go:build !windows + +package main + +import ( + "os" + "path/filepath" + "syscall" +) + +type instanceLock struct{ f *os.File } + +// acquireLock takes an exclusive non-blocking flock on /launcher.lock. +// Failure means another dock is running (the caller opens its URL and exits). +func acquireLock(dir string) (*instanceLock, error) { + f, err := os.OpenFile(filepath.Join(dir, "launcher.lock"), os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + f.Close() + return nil, err + } + return &instanceLock{f}, nil +} + +func (l *instanceLock) release() { l.f.Close() } diff --git a/cmd/dock/lock_windows.go b/cmd/dock/lock_windows.go new file mode 100644 index 0000000..48b7ccd --- /dev/null +++ b/cmd/dock/lock_windows.go @@ -0,0 +1,32 @@ +//go:build windows + +package main + +import ( + "os" + "path/filepath" + + "golang.org/x/sys/windows" +) + +type instanceLock struct{ f *os.File } + +// acquireLock takes an exclusive non-blocking LockFileEx on +// /launcher.lock. Failure means another dock is running (the caller +// opens its URL and exits). The lock releases automatically if we crash. +func acquireLock(dir string) (*instanceLock, error) { + f, err := os.OpenFile(filepath.Join(dir, "launcher.lock"), os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, err + } + ol := new(windows.Overlapped) + err = windows.LockFileEx(windows.Handle(f.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, ol) + if err != nil { + f.Close() + return nil, err + } + return &instanceLock{f}, nil +} + +func (l *instanceLock) release() { l.f.Close() } diff --git a/cmd/dock/main.go b/cmd/dock/main.go new file mode 100644 index 0000000..5f04036 --- /dev/null +++ b/cmd/dock/main.go @@ -0,0 +1,190 @@ +// Command dock is the desktop launcher: a menu bar item (macOS) / +// notification-area icon (Windows) / StatusNotifierItem (Linux) that spawns the +// sibling `chartplotter` binary's `serve` subcommand and manages its lifecycle. +// It links no libtile57 and adds no server logic; the CLI binary stays the +// single engine. +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + + "fyne.io/systray" + "github.com/alecthomas/kong" +) + +// version is overridden at build time via -ldflags "-X main.version=...". +var version = "dev" + +type cli struct { + Engine string `type:"existingfile" help:"Path to the chartplotter binary (default: sibling of this executable)."` + Version bool `help:"Print version and exit."` +} + +func main() { + var c cli + kong.Parse(&c, + kong.Name("dock"), + kong.Description("Desktop launcher for chartplotter (tray/menu-bar app)."), + kong.UsageOnError(), + ) + if c.Version { + fmt.Printf("dock %s\n", version) + return + } + + dir, err := stateDir() + if err != nil { + fatal("state dir: %v", err) + } + logf, err := openLog(dir) + if err != nil { + fatal("open log: %v", err) + } + defer logf.Close() + + m := &manager{log: logf, engine: c.Engine} + + // Single instance: if another launcher holds the lock, just open the browser + // at whatever it's serving and exit — double-clicking again "opens the app". + lock, err := acquireLock(dir) + if err != nil { + if url := findRunning(); url != "" { + openBrowser(url) + } + return + } + defer lock.release() + + if !trayAvailable() { + // No tray host (Linux without a StatusNotifierItem watcher, e.g. stock + // GNOME sans AppIndicator extension): degrade to headless — start the + // server, open the browser, and stay resident so it keeps running. + m.logf("no system tray available (on GNOME, install the AppIndicator extension); running headless") + m.startup() + waitForSignal() + m.stop() + return + } + + // systray.Run must own the main goroutine (Cocoa main thread on macOS). + systray.Run(func() { onReady(m) }, func() { m.stop() }) +} + +// onReady builds the menu and wires the state machine to it. Runs once on the +// systray's ready callback; all launcher logic stays in goroutines. +func onReady(m *manager) { + setTrayIcon(false) + systray.SetTooltip("Chart Plotter") + + open := systray.AddMenuItem("Open Chart Plotter", "Open in the browser") + open.Disable() + status := systray.AddMenuItem("Starting…", "") + status.Disable() + systray.AddSeparator() + startStop := systray.AddMenuItem("Stop", "Stop the chart plotter server") + logs := systray.AddMenuItem("Show Logs", "Open the launcher log") + systray.AddSeparator() + quit := systray.AddMenuItem("Quit", "Stop the server and quit") + + changed := make(chan struct{}, 1) + m.onChange = func() { + select { + case changed <- struct{}{}: + default: + } + } + + render := func() { + st, url := m.state() + switch st { + case stateStarting: + status.SetTitle("Starting…") + open.Disable() + startStop.SetTitle("Stop") + startStop.Show() + setTrayIcon(false) + case stateRunning, stateAdopted: + status.SetTitle("Running on " + hostPort(url)) + open.Enable() + if st == stateAdopted { + // Not our child — Stop/Start would lie. Hide it (spec §Menu). + startStop.Hide() + } else { + startStop.SetTitle("Stop") + startStop.Show() + } + setTrayIcon(false) + case stateStopped: + status.SetTitle("Stopped") + open.Disable() + startStop.SetTitle("Start") + startStop.Show() + setTrayIcon(false) + case stateError: + status.SetTitle("Error — see logs") + open.Disable() + startStop.SetTitle("Start") + startStop.Show() + setTrayIcon(true) + } + } + + go func() { + for { + select { + case <-changed: + render() + case <-open.ClickedCh: + if _, url := m.state(); url != "" { + openBrowser(url) + } + case <-startStop.ClickedCh: + st, _ := m.state() + if st == stateRunning || st == stateStarting { + go m.stop() + } else { + go m.startup() + } + case <-logs.ClickedCh: + openBrowser(m.log.Name()) + case <-quit.ClickedCh: + systray.Quit() + return + } + } + }() + + // Ctrl-C / SIGTERM behaves like Quit so no child is orphaned. + go func() { + waitForSignal() + systray.Quit() + }() + + go m.startup() +} + +func hostPort(url string) string { + const pfx, sfx = "http://", "/" + s := url + if len(s) > len(pfx) && s[:len(pfx)] == pfx { + s = s[len(pfx):] + } + if len(s) > 0 && s[len(s)-1] == '/' { + s = s[:len(s)-1] + } + return s +} + +func waitForSignal() { + ch := make(chan os.Signal, 1) + signal.Notify(ch, os.Interrupt, syscall.SIGTERM) + <-ch +} + +func fatal(format string, args ...any) { + fmt.Fprintf(os.Stderr, "dock: "+format+"\n", args...) + os.Exit(1) +} diff --git a/cmd/dock/open.go b/cmd/dock/open.go new file mode 100644 index 0000000..c227e71 --- /dev/null +++ b/cmd/dock/open.go @@ -0,0 +1,22 @@ +package main + +import ( + "os/exec" + "runtime" +) + +// openBrowser opens a URL (or file path — used for Show Logs) with the +// platform's default opener. Errors are ignored: there is nowhere useful to +// surface them from a tray app, and the menu stays functional. +func openBrowser(target string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", target) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", target) + default: + cmd = exec.Command("xdg-open", target) + } + cmd.Start() +} diff --git a/cmd/dock/paths.go b/cmd/dock/paths.go new file mode 100644 index 0000000..4335339 --- /dev/null +++ b/cmd/dock/paths.go @@ -0,0 +1,47 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" +) + +const maxLogSize = 5 << 20 // truncate launcher.log above this on launch + +// stateDir is where the lock file and launcher.log live: +// ~/Library/Logs/chartplotter on macOS, %LocalAppData%\chartplotter on +// Windows, XDG state (~/.local/state/chartplotter) elsewhere. +func stateDir() (string, error) { + var dir string + switch runtime.GOOS { + case "darwin": + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir = filepath.Join(home, "Library", "Logs", "chartplotter") + case "windows": + dir = filepath.Join(os.Getenv("LocalAppData"), "chartplotter") + default: + if x := os.Getenv("XDG_STATE_HOME"); x != "" { + dir = filepath.Join(x, "chartplotter") + } else { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir = filepath.Join(home, ".local", "state", "chartplotter") + } + } + return dir, os.MkdirAll(dir, 0o755) +} + +// openLog opens (and, when oversized, truncates) launcher.log for appending. +// The child server's stdout/stderr are wired to the same file. +func openLog(dir string) (*os.File, error) { + path := filepath.Join(dir, "launcher.log") + if fi, err := os.Stat(path); err == nil && fi.Size() > maxLogSize { + os.Truncate(path, 0) + } + return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) +} diff --git a/cmd/dock/process.go b/cmd/dock/process.go new file mode 100644 index 0000000..36b063a --- /dev/null +++ b/cmd/dock/process.go @@ -0,0 +1,207 @@ +package main + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + "time" +) + +type state int + +const ( + stateStarting state = iota + stateRunning + stateAdopted // a chartplotter we didn't spawn owns the port; Quit ≠ stop + stateStopped + stateError +) + +// manager owns the launcher state machine: probe 8080, +// adopt an existing chartplotter or spawn `chartplotter serve` on the first +// free port, watch its health, and stop it on quit. +type manager struct { + log *os.File + engine string // --engine override; "" = sibling of this executable + + mu sync.Mutex + st state + url string + child *exec.Cmd + done chan struct{} // closed when the child exits + onChange func() +} + +const ( + basePort = 8080 + maxPort = 8090 + startTimeout = 15 * time.Second + stopGrace = 5 * time.Second +) + +func (m *manager) state() (state, string) { + m.mu.Lock() + defer m.mu.Unlock() + return m.st, m.url +} + +func (m *manager) set(st state, url string) { + m.mu.Lock() + m.st, m.url = st, url + cb := m.onChange + m.mu.Unlock() + if cb != nil { + cb() + } +} + +func (m *manager) logf(format string, args ...any) { + fmt.Fprintf(m.log, time.Now().Format("2006-01-02 15:04:05")+" "+format+"\n", args...) +} + +// startup implements the launch flow: adopt our own server if one is already +// on 8080, otherwise spawn on the first free port in [8080, 8090]. +func (m *manager) startup() { + m.set(stateStarting, "") + for port := basePort; port <= maxPort; port++ { + switch probe(port) { + case probeOurs: + url := fmt.Sprintf("http://127.0.0.1:%d/", port) + m.logf("adopted running chartplotter at %s", url) + m.set(stateAdopted, url) + return + case probeFree: + m.spawn(port) + return + default: // something else owns this port — keep walking + m.logf("port %d taken by another server, trying next", port) + } + } + m.logf("no free port in %d..%d", basePort, maxPort) + m.set(stateError, "") +} + +// spawn starts `chartplotter serve` on port and polls /api/health until it is +// ready (→ RUNNING, open the browser) or it dies / times out (→ ERROR). +func (m *manager) spawn(port int) { + bin, err := m.enginePath() + if err != nil { + m.logf("engine binary: %v", err) + m.set(stateError, "") + return + } + cmd := exec.Command(bin, "serve", "--host", "127.0.0.1", "--port", fmt.Sprint(port)) + cmd.Stdout = m.log + cmd.Stderr = m.log + cmd.SysProcAttr = sysProcAttr() + m.logf("spawning %s serve --port %d", bin, port) + if err := cmd.Start(); err != nil { + m.logf("start: %v", err) + m.set(stateError, "") + return + } + done := make(chan struct{}) + go func() { cmd.Wait(); close(done) }() + + m.mu.Lock() + m.child, m.done = cmd, done + m.mu.Unlock() + + deadline := time.Now().Add(startTimeout) + for time.Now().Before(deadline) { + select { + case <-done: + m.logf("server exited during startup — see log above") + m.set(stateError, "") + return + case <-time.After(250 * time.Millisecond): + } + if probe(port) == probeOurs { + url := fmt.Sprintf("http://127.0.0.1:%d/", port) + m.logf("running at %s", url) + m.set(stateRunning, url) + openBrowser(url) + go m.watch(done) + return + } + } + m.logf("server not healthy after %s, giving up", startTimeout) + m.stop() + m.set(stateError, "") +} + +// watch flips to ERROR if the child dies while we think it's running. +func (m *manager) watch(done chan struct{}) { + <-done + m.mu.Lock() + crashed := m.done == done && m.st == stateRunning + m.mu.Unlock() + if crashed { + m.logf("server exited unexpectedly") + m.set(stateError, "") + } +} + +// stop gracefully terminates the child: SIGTERM (CTRL_BREAK on Windows), a +// grace period, then kill. Adopted servers are never touched. +func (m *manager) stop() { + m.mu.Lock() + cmd, done := m.child, m.done + m.child, m.done = nil, nil + m.mu.Unlock() + if cmd == nil || cmd.Process == nil { + if st, _ := m.state(); st != stateError { + m.set(stateStopped, "") + } + return + } + if err := terminate(cmd); err != nil { + cmd.Process.Kill() + } + select { + case <-done: + case <-time.After(stopGrace): + m.logf("server did not exit within %s, killing", stopGrace) + cmd.Process.Kill() + <-done + } + m.logf("server stopped") + m.set(stateStopped, "") +} + +// enginePath resolves the chartplotter binary: --engine override, else the +// sibling of this executable (inside the macOS bundle both live in +// Contents/MacOS/). +func (m *manager) enginePath() (string, error) { + if m.engine != "" { + return m.engine, nil + } + self, err := os.Executable() + if err != nil { + return "", err + } + name := "chartplotter" + if runtime.GOOS == "windows" { + name += ".exe" + } + bin := filepath.Join(filepath.Dir(self), name) + if _, err := os.Stat(bin); err != nil { + return "", errors.New(bin + " not found beside the launcher (or pass --engine)") + } + return bin, nil +} + +// findRunning scans the probe range for an already-running chartplotter and +// returns its URL, or "". Used by a second launcher instance before exiting. +func findRunning() string { + for port := basePort; port <= maxPort; port++ { + if probe(port) == probeOurs { + return fmt.Sprintf("http://127.0.0.1:%d/", port) + } + } + return "" +} diff --git a/cmd/dock/process_unix.go b/cmd/dock/process_unix.go new file mode 100644 index 0000000..f038969 --- /dev/null +++ b/cmd/dock/process_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package main + +import ( + "os/exec" + "syscall" +) + +func sysProcAttr() *syscall.SysProcAttr { return nil } + +func terminate(cmd *exec.Cmd) error { + return cmd.Process.Signal(syscall.SIGTERM) +} diff --git a/cmd/dock/process_windows.go b/cmd/dock/process_windows.go new file mode 100644 index 0000000..8a55e94 --- /dev/null +++ b/cmd/dock/process_windows.go @@ -0,0 +1,22 @@ +//go:build windows + +package main + +import ( + "os/exec" + "syscall" + + "golang.org/x/sys/windows" +) + +// The child gets its own process group (so CTRL_BREAK reaches only it) and no +// console window — the launcher itself is built -H=windowsgui. +func sysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{ + CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_NO_WINDOW, + } +} + +func terminate(cmd *exec.Cmd) error { + return windows.GenerateConsoleCtrlEvent(windows.CTRL_BREAK_EVENT, uint32(cmd.Process.Pid)) +} diff --git a/cmd/dock/sni_linux.go b/cmd/dock/sni_linux.go new file mode 100644 index 0000000..4e6f221 --- /dev/null +++ b/cmd/dock/sni_linux.go @@ -0,0 +1,20 @@ +//go:build linux + +package main + +import "github.com/godbus/dbus/v5" + +// trayAvailable reports whether a StatusNotifierItem host is on the session +// bus. Stock GNOME has none without the AppIndicator extension; in that case +// the dock degrades to headless instead of letting +// systray fail with no icon anywhere. +func trayAvailable() bool { + conn, err := dbus.SessionBus() + if err != nil { + return false + } + var owner string + err = conn.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, + "org.kde.StatusNotifierWatcher").Store(&owner) + return err == nil && owner != "" +} diff --git a/cmd/dock/sni_other.go b/cmd/dock/sni_other.go new file mode 100644 index 0000000..fb38d3b --- /dev/null +++ b/cmd/dock/sni_other.go @@ -0,0 +1,6 @@ +//go:build !linux + +package main + +// macOS and Windows always have a menu bar / notification area. +func trayAvailable() bool { return true } diff --git a/go.mod b/go.mod index 0756f46..677eefb 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,11 @@ require ( modernc.org/sqlite v1.53.0 ) -require golang.org/x/sync v0.21.0 // indirect +require ( + fyne.io/systray v1.12.2 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + golang.org/x/sync v0.21.0 // indirect +) require ( github.com/adrianmo/go-nmea v1.3.0 // indirect @@ -24,7 +28,7 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.45.0 // indirect + golang.org/x/sys v0.47.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 69d9380..cd37272 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= +fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/BertoldVdb/go-ais v0.4.0 h1:bsORFIzgLW4H/pI9xQ+FMT/e0O0jT+Bhfw5O67IpKTk= github.com/BertoldVdb/go-ais v0.4.0/go.mod h1:V2+fRhMf6AWOIEGEjgGAImHm+D/gCe6iGTUHvDEZf3U= github.com/adrianmo/go-nmea v1.3.0 h1:BFrLRj/oIh+DYujIKpuQievq7X3NDHYq57kNgsfr2GY= @@ -13,6 +15,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -42,6 +46,8 @@ golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/engine/server/http.go b/internal/engine/server/http.go index 013f06f..91470c2 100644 --- a/internal/engine/server/http.go +++ b/internal/engine/server/http.go @@ -297,8 +297,10 @@ func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) { } switch { case r.URL.Path == "/api/health": + // "app" identifies this as chartplotter to the dock launcher, which + // must distinguish an adoptable server from a stranger on the port. w.Header().Set("Content-Type", jsonCT) - fmt.Fprintf(w, `{"ok":true,"version":%q}`, s.Version) + fmt.Fprintf(w, `{"ok":true,"app":"chartplotter","version":%q}`, s.Version) case r.URL.Path == "/api/cells": s.serveCells(w, r) // GET: names of cells currently in the server's ENC_ROOT cache case r.URL.Path == "/api/ienc/catalog": diff --git a/internal/engine/server/server_test.go b/internal/engine/server/server_test.go index ff1d91b..b4b6052 100644 --- a/internal/engine/server/server_test.go +++ b/internal/engine/server/server_test.go @@ -94,8 +94,10 @@ func TestAPIHealthAndHostCheck(t *testing.T) { got, _ := io.ReadAll(resp.Body) resp.Body.Close() // /api/health also advertises the server version, so match the liveness - // marker rather than an exact body. - if !strings.Contains(string(got), `"ok":true`) { + // marker rather than an exact body. The "app" field is the dock launcher's + // adoption check — keep it stable. + if !strings.Contains(string(got), `"ok":true`) || + !strings.Contains(string(got), `"app":"chartplotter"`) { t.Errorf("health: got %q", got) } diff --git a/packaging/linux/chartplotter.desktop b/packaging/linux/chartplotter.desktop new file mode 100644 index 0000000..09b3d99 --- /dev/null +++ b/packaging/linux/chartplotter.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=Chart Plotter +Comment=Marine chart plotter — S-101 portrayed NOAA ENC charts +Exec=dock +Icon=chartplotter +Terminal=false +Categories=Utility;Geography;Maps; diff --git a/packaging/macos/Info.plist b/packaging/macos/Info.plist new file mode 100644 index 0000000..f9dc8ce --- /dev/null +++ b/packaging/macos/Info.plist @@ -0,0 +1,31 @@ + + + + + + CFBundleName + Chart Plotter + CFBundleDisplayName + Chart Plotter + CFBundleIdentifier + org.beetlebug.chartplotter + CFBundleExecutable + dock + CFBundleIconFile + AppIcon + CFBundlePackageType + APPL + CFBundleShortVersionString + @VERSION@ + CFBundleVersion + @VERSION@ + LSUIElement + + LSMinimumSystemVersion + 11.0 + NSHighResolutionCapable + + + diff --git a/scripts/macos-app.sh b/scripts/macos-app.sh new file mode 100755 index 0000000..2bfdab7 --- /dev/null +++ b/scripts/macos-app.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Assemble ChartPlotter.app — the macOS menu-bar launcher bundle: +# Contents/MacOS/dock (the LSUIElement menu-bar app) beside +# Contents/MacOS/chartplotter (the engine it spawns). Runs NATIVELY on macOS +# only — the engine links Apple frameworks and systray needs cgo/Cocoa — making +# this the mac-runner counterpart of scripts/xbuild-tile57.sh + xbuild-dock.sh. +# +# Ad-hoc signs the bundle (codesign --sign -); real identity signing + +# notarisation are a release-pipeline concern layered on top (pass IDENTITY=). +# Outputs dist/ChartPlotter__darwin_.zip. +set -euo pipefail + +[ "$(uname -s)" = Darwin ] || { echo "macos-app.sh must run on macOS (see header)"; exit 1; } + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${OUT:-$REPO/dist}" +VERSION="${VERSION:-$(git -C "$REPO" describe --tags --always --dirty 2>/dev/null || echo dev)}" +IDENTITY="${IDENTITY:--}" # "-" = ad-hoc +case "$(uname -m)" in arm64) ARCH=arm64 ;; *) ARCH=amd64 ;; esac + +make -C "$REPO" build build-dock VERSION="$VERSION" + +APP="$OUT/ChartPlotter.app" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +sed "s/@VERSION@/$VERSION/g" "$REPO/packaging/macos/Info.plist" > "$APP/Contents/Info.plist" +cp "$REPO/bin/dock" "$APP/Contents/MacOS/dock" +cp "$REPO/bin/chartplotter" "$APP/Contents/MacOS/chartplotter" + +# AppIcon.icns from the generated 512px glyph — sips + iconutil ship with macOS. +iconset="$(mktemp -d)/AppIcon.iconset" +mkdir -p "$iconset" +for s in 16 32 64 128 256; do + sips -z "$s" "$s" "$REPO/cmd/dock/appicon.png" --out "$iconset/icon_${s}x${s}.png" >/dev/null +done +cp "$REPO/cmd/dock/appicon.png" "$iconset/icon_512x512.png" +iconutil -c icns "$iconset" -o "$APP/Contents/Resources/AppIcon.icns" + +# Sign inside-out: the nested engine binary first, then the bundle. +codesign --force --sign "$IDENTITY" "$APP/Contents/MacOS/chartplotter" +codesign --force --sign "$IDENTITY" "$APP" + +zip="$OUT/ChartPlotter_${VERSION}_darwin_${ARCH}.zip" +rm -f "$zip" +ditto -c -k --keepParent "$APP" "$zip" +echo "→ $zip" diff --git a/scripts/xbuild-dock.sh b/scripts/xbuild-dock.sh new file mode 100755 index 0000000..4d14075 --- /dev/null +++ b/scripts/xbuild-dock.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Cross-compile the dock desktop launcher (cmd/dock) for linux + windows. +# Pure Go — no libtile57, no Zig: systray is Win32 syscalls on windows and D-Bus +# StatusNotifierItem on linux, so CGO stays off and plain GOOS/GOARCH covers it. +# Windows links with -H=windowsgui so double-clicking dock.exe opens no console +# window. darwin is deliberately EXCLUDED: systray needs cgo/Cocoa there, so the +# macOS runner assembles ChartPlotter.app natively via scripts/macos-app.sh. +# +# Outputs dist/dock__[.exe], plus the Linux desktop-integration files +# (chartplotter.desktop + chartplotter.png icon) release zips ship alongside. +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${OUT:-$REPO/dist}" +VERSION="${VERSION:-dev}" +PLATFORMS="${PLATFORMS:-linux/amd64 linux/arm64 windows/amd64 windows/arm64}" + +mkdir -p "$OUT" +for plat in $PLATFORMS; do + goos="${plat%/*}"; goarch="${plat#*/}"; ext=""; gui="" + case "$goos" in + windows) ext=.exe; gui="-H=windowsgui" ;; + darwin) echo "skip $plat (darwin builds natively via scripts/macos-app.sh)"; continue ;; + esac + echo "→ dock $plat" + CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \ + go build -trimpath -ldflags "-s -w $gui -X main.version=$VERSION" \ + -o "$OUT/dock_${goos}_${goarch}${ext}" ./cmd/dock +done + +cp "$REPO/packaging/linux/chartplotter.desktop" "$OUT/" +cp "$REPO/cmd/dock/appicon.png" "$OUT/chartplotter.png" + +echo "→ $OUT" +ls -1 "$OUT"/dock_* "$OUT"/chartplotter.desktop 2>/dev/null || true From c98ff64b1eb3ed2a67fd5cde8d689e5e3d5569a5 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 19 Jul 2026 04:11:44 +0000 Subject: [PATCH 2/4] build: make the core Make targets run natively on Windows GNU Make on Windows (winget/scoop/choco) usually executes recipes through cmd.exe, so the POSIX-isms in build/build-dock/tile57-lib/clean broke native builds: VAR=x prefixes, rm -f, [ ... ] tests, /dev/null, @#-comment lines. Rework those targets to be recipe-shell-agnostic: - env vars move to make-level 'export' (build: export CGO_ENABLED = 1) - file tests move into make ($(wildcard)/ifeq replaces test -e and [ -f ]) - file ops route through an OS shim block (explicit 'cmd /C ...' on Windows, so they behave the same under cmd.exe and MSYS sh alike) - outputs gain .exe on Windows; the dock also gets -H=windowsgui natively Native Windows builds pin the engine lib and C toolchain to the same -windows-gnu triple xbuild-tile57.sh cross-links with (zig doubles as the C compiler; no MinGW needed) and normalize zig's tile57.lib to the libtile57.a path the Go binding links. The bake/demo/docs/xbuild targets stay POSIX-only by design (WSL/Git Bash); README + CLAUDE.md document the split. --- CLAUDE.md | 7 ++++ Makefile | 113 +++++++++++++++++++++++++++++++++++++++--------------- README.md | 13 +++++++ 3 files changed, 102 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5f888f1..4dd7e4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,13 @@ support is planned. you develop in), overriding BOTH halves — a gitignored `go.work` replacing `github.com/beetlebugorg/tile57/bindings/go => /bindings/go` plus `make TILE57= …`. Never commit `go.work`. +- **Windows-native builds.** The core Make targets (`build`, `build-dock`, + `tile57-lib`, `serve`, `test`, `vet`, `tidy`, `clean`) must run on a Windows + host where make's recipe shell may be cmd.exe: set recipe env vars via + make-level `export` (never `VAR=x cmd` prefixes), do file tests in make + (`$(wildcard)`/`ifeq`), route file ops through the OS shim block at the top of + the Makefile, and keep recipe shell syntax to `cd x && y`. The bake/demo/docs + targets are POSIX-only by design (WSL/Git Bash on Windows). - Use https://www.openbridge.no/ for design and icons. - Match the style of the code around you. - Never run `git add -A` or `git add .`. The repo holds large untracked files diff --git a/Makefile b/Makefile index 16828a6..804ae50 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,44 @@ -VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +# ---- host-OS shim (native Windows builds) ------------------------------------- +# The core dev targets — build, build-dock, tile57-lib, serve, test, vet, tidy, +# clean — also run on a Windows host, where GNU Make (winget/choco/scoop) usually +# executes recipes through cmd.exe, not a POSIX sh. Those targets therefore avoid +# shell-isms: recipe env vars are set via make-level `export` (never `VAR=x cmd` +# prefixes), file tests live in make ($(wildcard)/ifeq, not `[ ... ]`), and the +# only shell syntax in their recipes is `cd x && y`, which cmd and sh parse the +# same. File ops with no syntax common to both route through the shims below — +# an explicit `cmd /C "..."` on Windows, so they behave identically whether +# make's recipe shell is cmd.exe or an MSYS sh (Git Bash). Everything else +# (bake/demo/docs/screenshot targets, xbuild) stays POSIX-only: on Windows run +# those from WSL or Git Bash. +ifeq ($(OS),Windows_NT) +EXE := .exe +DEVNULL := NUL +# Pin the engine lib AND the C toolchain to the -windows-gnu triple that +# scripts/xbuild-tile57.sh already cross-links with, so a native build never +# mixes Zig's detected-native ABI (possibly msvc) with the gnu-ABI link the +# binding's `-lpthread` expects. Zig doubles as the C compiler — it's a hard +# build dep anyway, so no MinGW install is needed (`make build CC=gcc` to +# override). +GOARCH_HOST = $(shell go env GOARCH) +ZIG_HOST_TARGET = $(if $(filter arm64,$(GOARCH_HOST)),aarch64,x86_64)-windows-gnu +ZIG_BUILD_ARGS = -Dtarget=$(ZIG_HOST_TARGET) +# Zig names the Windows static lib tile57.lib, but the Go binding links the +# fixed path zig-out/lib/libtile57.a — normalize after every engine build (the +# same move xbuild-tile57.sh makes). +NORMALIZE_TILE57_LIB = cmd /C "copy /Y $(subst /,\,$(TILE57)/zig-out/lib/tile57.lib) $(subst /,\,$(TILE57_LIB))" +RM_BIN = cmd /C "del /F /Q $(subst /,\,$(BIN)) 2>NUL & exit /B 0" +RM_BUILD_DIRS = cmd /C "if exist bin rmdir /S /Q bin" && cmd /C "if exist dist rmdir /S /Q dist" +else +EXE := +DEVNULL := /dev/null +NORMALIZE_TILE57_LIB = +RM_BIN = rm -f $(BIN) +RM_BUILD_DIRS = rm -rf bin dist +endif + +VERSION ?= $(shell git describe --tags --always --dirty 2>$(DEVNULL) || echo dev) LDFLAGS := -X main.version=$(VERSION) -BIN := bin/chartplotter +BIN := bin/chartplotter$(EXE) # Cross-build matrix for `make xbuild`. Override for a subset, e.g. # `make xbuild PLATFORMS=darwin/arm64` or `PLATFORMS="darwin/arm64 linux/amd64"`. @@ -77,11 +115,15 @@ TILE57_LIB := $(TILE57)/zig-out/lib/libtile57.a # main.version so every bake can record WHICH engine produced its tiles (and the # client can flag a mixed-engine cache). Resolves for the default ./tile57 submodule # AND a TILE57=… override; "unknown" when git can't answer (submodule not yet -# initialized, tarball checkout). The `test -e .git` guard matters: git -C into a -# missing dir would walk up and report THIS repo's HEAD instead of failing (for the -# submodule .git is a gitdir FILE, for a plain clone a directory — test -e matches -# both, so either resolves cleanly). -ENGINE_COMMIT ?= $(shell test -e "$(TILE57)/.git" && git -C "$(TILE57)" rev-parse --short=9 HEAD 2>/dev/null || echo unknown) +# initialized, tarball checkout). The .git guard matters: git -C into a missing +# dir would walk up and report THIS repo's HEAD instead of failing. It's a make +# $(wildcard), not a shell `test -e`, so it needs no POSIX shell — wildcard +# matches the submodule's gitdir FILE and a plain clone's directory alike. +ifneq ($(wildcard $(TILE57)/.git),) +ENGINE_COMMIT ?= $(shell git -C "$(TILE57)" rev-parse --short=9 HEAD 2>$(DEVNULL) || echo unknown) +else +ENGINE_COMMIT ?= unknown +endif LDFLAGS += -X main.engineCommit=$(ENGINE_COMMIT) # Materialize the engine source if it isn't there yet. For the default ./tile57 @@ -89,45 +131,54 @@ LDFLAGS += -X main.engineCommit=$(ENGINE_COMMIT) # update --init --recursive`; for a TILE57= override the checkout must already # exist (we don't guess where an external engine tree should come from). $(TILE57)/include/tile57.h: - @if [ "$(TILE57)" = "tile57" ] && [ -f .gitmodules ]; then \ - echo "fetching the tile57 engine submodule (git submodule update --init --recursive)…"; \ - git submodule update --init --recursive tile57; \ - else \ - echo "missing $(TILE57)/include/tile57.h — TILE57=$(TILE57) is not the default submodule; point it at a github.com/beetlebugorg/tile57 checkout"; \ - exit 1; \ - fi - -# Build the static library on demand (only when absent). Needs Zig 0.16 on PATH. +ifneq ($(and $(filter tile57,$(TILE57)),$(wildcard .gitmodules)),) + @echo fetching the tile57 engine submodule: git submodule update --init --recursive tile57 + git submodule update --init --recursive tile57 +else + $(error missing $(TILE57)/include/tile57.h — TILE57=$(TILE57) is not the default submodule; point it at a github.com/beetlebugorg/tile57 checkout) +endif + +# Build the static library on demand (only when absent). Needs Zig 0.16 on PATH +# (a missing zig surfaces as the shell's own command-not-found error). $(TILE57_LIB): $(TILE57)/include/tile57.h - @command -v zig >/dev/null 2>&1 || { echo "Zig 0.16 not on PATH and $(TILE57_LIB) missing — install Zig or prebuild the lib"; exit 1; } - @echo "building libtile57.a (zig build in $(TILE57))…" - cd "$(TILE57)" && zig build + cd "$(TILE57)" && zig build $(ZIG_BUILD_ARGS) + $(NORMALIZE_TILE57_LIB) tile57-lib: ## Force-rebuild $(TILE57)/zig-out/lib/libtile57.a (the native engine static lib) - @command -v zig >/dev/null 2>&1 || { echo "Zig 0.16 not on PATH"; exit 1; } - cd "$(TILE57)" && zig build + cd "$(TILE57)" && zig build $(ZIG_BUILD_ARGS) + $(NORMALIZE_TILE57_LIB) # Build bin/chartplotter. libtile57 is the sole engine, so this is a CGO build that # statically links the native lib; the S-101 catalogue lives inside libtile57, so # there is no separate sync/embed step (web/ is still embedded). Fetches the ./tile57 # submodule on demand (see the $(TILE57)/include/tile57.h rule) + needs Zig 0.16. +# The pre-build $(RM_BIN) forces the link: go's build-cache action ID does NOT +# hash external static-lib content, so with an existing up-to-date-looking +# $(BIN) `go build` silently skips the relink and a fresh libtile57.a never +# reaches the output. +build: export CGO_ENABLED = 1 +ifeq ($(OS),Windows_NT) +build: export CC = zig cc -target $(ZIG_HOST_TARGET) +build: export CXX = zig c++ -target $(ZIG_HOST_TARGET) +endif build: $(TILE57_LIB) ## Build bin/chartplotter (CGO + native libtile57; fetches the ./tile57 submodule on demand + needs Zig 0.16) - @# Force the link: go's build-cache action ID does NOT hash external static-lib - @# content, so with an existing up-to-date-looking $(BIN) `go build` silently - @# skips the relink and a fresh libtile57.a never reaches the output. - @rm -f $(BIN) - CGO_ENABLED=1 go build -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/chartplotter - @echo "→ $(BIN) (native libtile57 engine)" + @$(RM_BIN) + go build -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/chartplotter + @echo → $(BIN) - native libtile57 engine # Back-compat alias — libtile57 is now the default engine, so this is just `build`. build-tile57: build ## Alias for `build` (libtile57 is the sole engine now) # The dock is the desktop launcher (cmd/dock): a tray/menu-bar app that # spawns `chartplotter serve` as a child. Pure Go on linux/windows (no libtile57, -# no Zig); systray needs cgo/Cocoa on darwin only. +# no Zig); systray needs cgo/Cocoa on darwin only. On windows, -H=windowsgui +# keeps a double-clicked dock.exe from opening a console window (matching +# scripts/xbuild-dock.sh). +GOOS_HOST = $(shell go env GOOS) +build-dock: export CGO_ENABLED = $(if $(filter darwin,$(GOOS_HOST)),1,0) build-dock: ## Build bin/dock (desktop launcher tray app; no libtile57) - CGO_ENABLED=$(if $(filter darwin,$(shell go env GOOS)),1,0) go build -ldflags "-X main.version=$(VERSION)" -o bin/dock ./cmd/dock - @echo "→ bin/dock" + go build -ldflags "$(if $(filter windows,$(GOOS_HOST)),-H=windowsgui )-X main.version=$(VERSION)" -o bin/dock$(EXE) ./cmd/dock + @echo → bin/dock$(EXE) # Quick cross-platform test builds. CGO is off, so this is pure `go build` per # target — fast cold, near-instant on re-runs thanks to the build cache. Stamps @@ -339,7 +390,7 @@ tidy: go mod tidy clean: - rm -rf bin dist + $(RM_BUILD_DIRS) clear-cache: ## Delete the provisioning cache (region zips, baked .pmtiles, charts-user, cell cache) for a clean slate rm -rf "$(CACHE)" diff --git a/README.md b/README.md index 11cd6dc..8fa5fa8 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,19 @@ bin/chartplotter version statically linking libtile57); [CLAUDE.md](CLAUDE.md) and the [Makefile](Makefile) describe the build contract. +### Building on Windows + +The same recipe works natively on Windows — you need **GNU Make** +(`winget install ezwinports.make`, or via scoop/choco) plus the Go/Zig/git +requirements above; no MinGW or MSVC install, Zig doubles as the C compiler. +The core targets (`make build`, `make build-dock`, `make tile57-lib`, +`make serve`, `make test`, `make vet`, `make clean`) run from PowerShell, cmd, +or Git Bash alike — their recipes avoid POSIX-shell syntax, so it doesn't +matter which shell make picks. Outputs gain the `.exe` suffix +(`bin\chartplotter.exe`, `bin\dock.exe`). The remaining targets (baking, +demo/docs bundles, `make xbuild`) drive bash scripts — run those from WSL or +Git Bash. + ## 🚀 Get started The frontend is built into the binary, so one file is all you need. Start the From a3d1c0fa74ca4b5c0327c8591995978b7f230d14 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 19 Jul 2026 04:28:47 +0000 Subject: [PATCH 3/4] build: fail fast when TILE57 is overridden without a go.work With make TILE57=, make builds the C lib from the custom checkout but the Go binding still resolves via go.mod's replace at ./tile57/bindings/go, and go dies with the cryptic 'replacement directory ./tile57/bindings/go does not exist'. Guard build/xbuild with a pure-make parse-time check (no POSIX shell needed, so it fires on Windows too) that explains the missing half: create a gitignored go.work replacing the binding at /bindings/go, per README 'Developing the engine'. --- Makefile | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 804ae50..82813ac 100644 --- a/Makefile +++ b/Makefile @@ -65,7 +65,7 @@ CACHE ?= $(if $(XDG_CACHE_HOME),$(XDG_CACHE_HOME),$(HOME)/.cache)/chartplotter S101_PC ?= $(HOME)/Projects/s101-portrayal-catalogue/PortrayalCatalog S101_FC ?= $(HOME)/Projects/s101-feature-catalogue/S-101FC/FeatureCatalogue.xml -.PHONY: build build-tile57 build-dock tile57-lib vendor-style-engine xbuild xbuild-tile57 xbuild-dock package-macos test vet fmt fmt-check tidy clean clear-cache serve docs docs-shots bake-ienc bake-noaa serve-widget demo demo-chart1 serve-demo preslib-chart1 s64-pages +.PHONY: build build-tile57 build-dock tile57-lib check-engine-override vendor-style-engine xbuild xbuild-tile57 xbuild-dock package-macos test vet fmt fmt-check tidy clean clear-cache serve docs docs-shots bake-ienc bake-noaa serve-widget demo demo-chart1 serve-demo preslib-chart1 s64-pages # Prebaked prod test set (US Inland ENC bundle + the NOAA world archive). # NB: keep these as bare values with NO inline `#` comments — Make folds any @@ -111,6 +111,21 @@ NOAA_ARCHIVES := $(foreach d,$(DISTRICTS),noaa-d$(d).pmtiles) TILE57 ?= tile57 TILE57_LIB := $(TILE57)/zig-out/lib/libtile57.a +# A TILE57 override changes only the C lib make builds — the Go binding still +# resolves via go.mod's replace at ./tile57/bindings/go. Building against +# another engine checkout therefore needs a gitignored go.work overriding the +# binding too (README.md "Developing the engine"). Catch the mismatch here, +# up front, instead of letting go print the cryptic "replacement directory +# ./tile57/bindings/go does not exist". Pure-make check (parse-time wildcard + +# $(error)) so it works without a POSIX shell; GOWORK=off counts as absent, +# GOWORK= as present. +check-engine-override: +ifneq ($(TILE57),tile57) +ifeq ($(filter-out off,$(GOWORK))$(wildcard go.work),) + $(error TILE57=$(TILE57) overrides the engine lib, but the Go binding still points at ./tile57/bindings/go — create a gitignored go.work: `go work init .` then `go work edit -replace github.com/beetlebugorg/tile57/bindings/go=$(TILE57)/bindings/go`; see README.md "Developing the engine") +endif +endif + # Engine-commit stamp: the tile57 checkout's HEAD, linked into the binary beside # main.version so every bake can record WHICH engine produced its tiles (and the # client can flag a mixed-engine cache). Resolves for the default ./tile57 submodule @@ -161,7 +176,7 @@ ifeq ($(OS),Windows_NT) build: export CC = zig cc -target $(ZIG_HOST_TARGET) build: export CXX = zig c++ -target $(ZIG_HOST_TARGET) endif -build: $(TILE57_LIB) ## Build bin/chartplotter (CGO + native libtile57; fetches the ./tile57 submodule on demand + needs Zig 0.16) +build: check-engine-override $(TILE57_LIB) ## Build bin/chartplotter (CGO + native libtile57; fetches the ./tile57 submodule on demand + needs Zig 0.16) @$(RM_BIN) go build -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/chartplotter @echo → $(BIN) - native libtile57 engine @@ -192,7 +207,7 @@ build-dock: ## Build bin/dock (desktop launcher tray app; no libtile57) # (Security/CoreFoundation) that Zig doesn't bundle. The S-101 catalogue lives in # libtile57, so there's no embed step. Fetches the ./tile57 submodule on demand + Zig 0.16. # Outputs dist/chartplotter__[.exe]. -xbuild xbuild-tile57: $(TILE57)/include/tile57.h ## Cross-compile CGO+libtile57 binaries with zig cc (linux+windows; darwin builds on a Mac runner) +xbuild xbuild-tile57: check-engine-override $(TILE57)/include/tile57.h ## Cross-compile CGO+libtile57 binaries with zig cc (linux+windows; darwin builds on a Mac runner) VERSION="$(VERSION)" TILE57="$(TILE57)" ENGINE_COMMIT="$(ENGINE_COMMIT)" scripts/xbuild-tile57.sh VERSION="$(VERSION)" scripts/xbuild-dock.sh From 5175f71b7259db4c6371b02a90ac57e067402c0b Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 19 Jul 2026 05:13:31 +0000 Subject: [PATCH 4/4] fix(dock): detect refused ports on Windows so the launcher can start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The port probe classified a connection-refused dial as 'free' only when errors.Is(err, syscall.ECONNREFUSED) matched. On Windows that constant is a synthetic APPLICATION_ERROR value that Winsock never returns — refused dials fail with WSAECONNREFUSED (10061, absent from stdlib syscall) — so every idle port in 8080..8090 read as 'taken by another server' and the dock gave up without ever spawning chartplotter. Split the sentinel into per-OS errno_{unix,windows}.go (x/sys/windows supplies WSAECONNREFUSED; already in the module graph) and add probe unit tests covering free/ours/other classification. --- cmd/dock/errno_unix.go | 8 +++++++ cmd/dock/errno_windows.go | 14 +++++++++++ cmd/dock/health.go | 3 +-- cmd/dock/health_test.go | 49 +++++++++++++++++++++++++++++++++++++++ go.mod | 10 ++++---- go.sum | 2 -- 6 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 cmd/dock/errno_unix.go create mode 100644 cmd/dock/errno_windows.go create mode 100644 cmd/dock/health_test.go diff --git a/cmd/dock/errno_unix.go b/cmd/dock/errno_unix.go new file mode 100644 index 0000000..62c1f24 --- /dev/null +++ b/cmd/dock/errno_unix.go @@ -0,0 +1,8 @@ +//go:build !windows + +package main + +import "syscall" + +// errConnRefused is the errno a dial to a closed local port unwraps to. +var errConnRefused error = syscall.ECONNREFUSED diff --git a/cmd/dock/errno_windows.go b/cmd/dock/errno_windows.go new file mode 100644 index 0000000..b7ad500 --- /dev/null +++ b/cmd/dock/errno_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package main + +import "golang.org/x/sys/windows" + +// errConnRefused is the errno a dial to a closed local port unwraps to. +// errors.Is(err, syscall.ECONNREFUSED) never matches on Windows — Go defines +// that constant as a synthetic APPLICATION_ERROR value, while Winsock dials +// actually fail with WSAECONNREFUSED (10061, absent from stdlib syscall) — so +// match the real thing. Without this the probe classified every refused port +// as "taken by another server" and the dock exhausted 8080..8090 without ever +// spawning the server. +var errConnRefused error = windows.WSAECONNREFUSED diff --git a/cmd/dock/health.go b/cmd/dock/health.go index 684c5c4..3336153 100644 --- a/cmd/dock/health.go +++ b/cmd/dock/health.go @@ -7,7 +7,6 @@ import ( "net" "net/http" "strings" - "syscall" "time" ) @@ -28,7 +27,7 @@ func probe(port int) probeResult { resp, err := probeClient.Get(fmt.Sprintf("http://127.0.0.1:%d/api/health", port)) if err != nil { var opErr *net.OpError - if errors.As(err, &opErr) && errors.Is(err, syscall.ECONNREFUSED) { + if errors.As(err, &opErr) && errors.Is(err, errConnRefused) { return probeFree } return probeOther diff --git a/cmd/dock/health_test.go b/cmd/dock/health_test.go new file mode 100644 index 0000000..cf70f48 --- /dev/null +++ b/cmd/dock/health_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "fmt" + "net" + "net/http" + "net/http/httptest" + "testing" +) + +// probe must classify a closed port as free — the refused-connection detection +// is per-OS (ECONNREFUSED vs WSAECONNREFUSED; see errno_windows.go), and +// misclassifying refusal as probeOther makes the dock reject every port in +// 8080..8090 and never spawn the server. +func TestProbeClosedPortIsFree(t *testing.T) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := l.Addr().(*net.TCPAddr).Port + l.Close() // now nothing listens there + + if got := probe(port); got != probeFree { + t.Fatalf("probe(closed port %d) = %v, want probeFree", port, got) + } +} + +func TestProbeClassifiesListeners(t *testing.T) { + cases := []struct { + name string + body string + want probeResult + }{ + {"ours", `{"app":"chartplotter","ok":true}`, probeOurs}, + {"other", `{"hello":"world"}`, probeOther}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, tc.body) + })) + defer srv.Close() + port := srv.Listener.Addr().(*net.TCPAddr).Port + if got := probe(port); got != tc.want { + t.Fatalf("probe(%s listener) = %v, want %v", tc.name, got, tc.want) + } + }) + } +} diff --git a/go.mod b/go.mod index 677eefb..f06c52e 100644 --- a/go.mod +++ b/go.mod @@ -5,18 +5,16 @@ go 1.26.0 toolchain go1.26.5 require ( + fyne.io/systray v1.12.2 github.com/BertoldVdb/go-ais v0.4.0 github.com/alecthomas/kong v1.15.0 + github.com/godbus/dbus/v5 v5.1.0 github.com/stretchr/testify v1.11.1 golang.org/x/image v0.44.0 modernc.org/sqlite v1.53.0 ) -require ( - fyne.io/systray v1.12.2 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect - golang.org/x/sync v0.21.0 // indirect -) +require golang.org/x/sync v0.21.0 // indirect require ( github.com/adrianmo/go-nmea v1.3.0 // indirect @@ -28,7 +26,7 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.47.0 // indirect + golang.org/x/sys v0.47.0 gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index cd37272..7ded8f0 100644 --- a/go.sum +++ b/go.sum @@ -44,8 +44,6 @@ golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=