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 9411d08..82813ac 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"`. @@ -27,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 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 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 @@ -73,15 +111,34 @@ 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 # 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,39 +146,55 @@ 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. -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)" +# 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: 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 # 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. 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) + 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 # the same version as `build`; strips symbols (-s -w) and paths (-trimpath) like a @@ -134,8 +207,19 @@ build-tile57: build ## Alias for `build` (libtile57 is the sole engine now) # (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 + +# 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) @@ -321,7 +405,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 diff --git a/cmd/dock/appicon.png b/cmd/dock/appicon.png new file mode 100644 index 0000000..afd8c55 Binary files /dev/null and b/cmd/dock/appicon.png differ 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/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..3336153 --- /dev/null +++ b/cmd/dock/health.go @@ -0,0 +1,45 @@ +package main + +import ( + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "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, errConnRefused) { + 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/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/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 0000000..807bb38 Binary files /dev/null and b/cmd/dock/icon.ico differ diff --git a/cmd/dock/icon.png b/cmd/dock/icon.png new file mode 100644 index 0000000..3828c7b Binary files /dev/null and b/cmd/dock/icon.png differ diff --git a/cmd/dock/icon_error.ico b/cmd/dock/icon_error.ico new file mode 100644 index 0000000..ca92210 Binary files /dev/null and b/cmd/dock/icon_error.ico differ diff --git a/cmd/dock/icon_error.png b/cmd/dock/icon_error.png new file mode 100644 index 0000000..398d681 Binary files /dev/null and b/cmd/dock/icon_error.png differ 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..f06c52e 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,10 @@ 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 @@ -24,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.45.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 69d9380..7ded8f0 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= @@ -40,8 +44,8 @@ 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= 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