diff --git a/.gitignore b/.gitignore index 5481b22..ec1a953 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,6 @@ coverage.* node_modules/ dist/ build/ +cmd/sith-desktop/frontend/wailsjs/ .next/ coverage/ diff --git a/Makefile b/Makefile index a1494af..b00ab5f 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,11 @@ GOVULNCHECK ?= govulncheck KIND ?= kind HELM ?= helm GORELEASER ?= goreleaser +WAILS ?= wails +WAILS_VERSION ?= v2.12.0 +CODESIGN ?= codesign +PLISTBUDDY ?= /usr/libexec/PlistBuddy +LIPO ?= lipo DOCKER ?= docker KUBECTL ?= kubectl OCM_SCRATCH_ROOT ?= $(shell python3 -c 'import os; print(os.path.join(os.path.realpath(os.environ.get("TMPDIR", "/tmp")), "sith-m0-{}".format(os.getuid()), "lab"))') @@ -29,7 +34,7 @@ LDFLAGS := -s -w \ -X $(PKG)/internal/buildinfo.Commit=$(COMMIT) \ -X $(PKG)/internal/buildinfo.Date=$(DATE) -.PHONY: all build test test-scripts perf e2e e2e-helm e2e-oci e2e-kind e2e-ocm e2e-postgres e2e-isolation lint vuln fmt fmt-check vet tidy clean run ci release-check help +.PHONY: all build desktop-build test test-scripts perf e2e e2e-helm e2e-oci e2e-kind e2e-ocm e2e-postgres e2e-isolation lint vuln fmt fmt-check vet tidy clean run ci release-check help all: build @@ -37,6 +42,19 @@ build: ## Build the sith binary into bin/ @mkdir -p $(BIN_DIR) go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN_DIR)/$(BINARY) $(CMD) +desktop-build: ## Build the ad-hoc-signed macOS arm64 Sith.app development bundle + @command -v "$(WAILS)" >/dev/null || { echo "Wails $(WAILS_VERSION) is required" >&2; exit 1; } + @"$(WAILS)" version | grep -q '$(WAILS_VERSION)' || { echo "Wails $(WAILS_VERSION) is required" >&2; exit 1; } + cd cmd/sith-desktop && "$(WAILS)" build -clean -m -nosyncgomod -s -trimpath -platform darwin/arm64 + @set -euo pipefail; \ + app='cmd/sith-desktop/build/bin/Sith.app'; \ + test -d "$$app"; \ + "$(LIPO)" -archs "$$app/Contents/MacOS/Sith" | grep -qx 'arm64'; \ + "$(PLISTBUDDY)" -c 'Set :CFBundleIdentifier com.ardurai.sith' "$$app/Contents/Info.plist"; \ + "$(CODESIGN)" --force --sign - "$$app"; \ + "$(CODESIGN)" --verify --strict "$$app"; \ + plutil -extract CFBundleIdentifier raw -o - "$$app/Contents/Info.plist" | grep -qx 'com.ardurai.sith' + test: ## Run unit tests with the race detector and report coverage go test -race -count=1 -coverprofile=coverage.out ./... diff --git a/README.md b/README.md index 2439edc..84bbdbd 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ make build ./bin/sith edit configmap/api-settings --context kind-dev -n apps ./bin/sith ui # loopback-only embedded fleet IDE ./bin/sith ui --kubeconfig-dir "$HOME/kubeconfigs" # import a folder of kubeconfig files for this UI session +./bin/sith desktop # native macOS window for the same local fleet IDE ./bin/sith serve --mcp # loopback-only MCP read server ./bin/sith serve --mcp --require-token ``` @@ -360,6 +361,16 @@ diff. Port-forward accepts loopback addresses only (`localhost`, `127.0.0.1`, or can hold API connections for its lifetime, but it creates no cloud resources or persistent local cache. +On macOS, `sith desktop` runs the same embedded fleet IDE in a native Wails v2 window. It uses an +in-process WebView origin (`wails://wails`), so it does not open a TCP listener. The **Import folder** +control appears only in that window and opens a native directory chooser; it passes the selection to +the identical bounded, in-memory kubeconfig importer used by `sith ui --kubeconfig-dir`. The UI +receives success, cancellation, or a sanitized failure category—never the selected absolute path or +kubeconfig content. Build +an ad-hoc-signed Apple Silicon development bundle with `make desktop-build`; public releases remain +blocked on Developer ID signing, notarization, stapling, and E9 release provenance, so this is not yet +a distributed replacement for Lens. + Each active lens holds one Kubernetes watch per reachable context after its initial list. A two-minute safety rediscovery recovers contexts that were offline at launch; it is not the primary resource refresh path. Very large context/lens counts therefore trade API-server connection and diff --git a/cmd/sith-desktop/frontend/.gitkeep b/cmd/sith-desktop/frontend/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/cmd/sith-desktop/frontend/.gitkeep @@ -0,0 +1 @@ + diff --git a/cmd/sith-desktop/main.go b/cmd/sith-desktop/main.go new file mode 100644 index 0000000..8e4c77a --- /dev/null +++ b/cmd/sith-desktop/main.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Sith desktop starts the native macOS shell for the local fleet IDE. +package main + +import ( + "os" + + "github.com/ArdurAI/sith/internal/cli" +) + +func main() { + os.Exit(cli.ExecuteDesktop()) +} diff --git a/cmd/sith-desktop/wails.json b/cmd/sith-desktop/wails.json new file mode 100644 index 0000000..f4ed803 --- /dev/null +++ b/cmd/sith-desktop/wails.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "Sith", + "outputfilename": "Sith", + "frontend:dir": "frontend", + "frontend:install": "", + "frontend:build": "true", + "author": { + "name": "ArdurAI", + "email": "security@ardur.ai" + }, + "info": { + "companyName": "ArdurAI", + "productName": "Sith", + "productVersion": "0.0.0-dev", + "comments": "Local-first Kubernetes fleet IDE" + } +} diff --git a/docs/adr/0010-native-local-desktop-shell.md b/docs/adr/0010-native-local-desktop-shell.md new file mode 100644 index 0000000..65cc649 --- /dev/null +++ b/docs/adr/0010-native-local-desktop-shell.md @@ -0,0 +1,46 @@ +# ADR 0010: Use a Wails v2 native shell for the local fleet IDE + +- Status: Accepted +- Date: 2026-07-14 + +## Context + +Sith already provides a build-free, loopback-only browser IDE through `sith ui`. +Operators also need a macOS application that feels local, including a native folder +chooser for a directory of kubeconfig files. The desktop form must retain the same +source-abstract engine, cache, local-operation boundaries, and privacy posture. + +## Decision + +Use Wails v2 as a thin macOS shell around the existing Go web UI handler. + +- Wails v2 is the upstream stable release line; Wails v3 is alpha and is not used. +- The app serves `webui.Application` through the Wails in-process asset server at + the exact `wails://wails` origin. It opens no TCP listener. +- The existing API handler, strict Host/Origin checks, per-process CSRF capability, + CSP, cache, hydrator, and local operation client remain the only implementation. +- The sole native binding opens a directory chooser. It returns success, + cancellation, or a sanitized failure category to the UI; the selected path + and kubeconfig contents never cross the UI bridge, persist, or enter diagnostics. +- A successful selection builds a new bounded importer session before atomically + replacing the current in-memory session. A failing selection leaves the current + session intact. + +## Consequences + +The normal CLI remains browser-capable through `sith ui`, while `sith desktop` +opens the same fleet IDE as a local macOS window. `make desktop-build` produces a +development ARM64 `.app` with the stable `com.ardurai.sith` bundle identifier and +an ad-hoc signature. It is deliberately not a public release artifact until E9 +supplies Developer ID signing, notarization, stapling, and release provenance. + +The first native shell does not add complete Lens parity, telemetry, an updater, +remote control-plane access, Windows/Linux desktop support, or a second Kubernetes +client. Its Wails dependency and macOS runtime therefore become explicit package +review and release-gate responsibilities. + +## References + +- https://wails.io/docs/introduction/ +- https://wails.io/docs/guides/dynamic-assets/ +- https://wails.io/docs/guides/signing/ diff --git a/docs/adr/README.md b/docs/adr/README.md index 0e7221c..5cd0dcf 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,6 +19,7 @@ decision rests on an external fact, that fact is web-verified and cited (see als | [0007](0007-local-mcp-transport-auth.md) | Local MCP transport, scope, and authentication | Accepted | | [0008](0008-deterministic-advisory-brain.md) | Deterministic local advisory brain and evidence contract | Accepted | | [0009](0009-release-supply-chain.md) | Reproducible and identity-bound release supply chain | Accepted | +| [0010](0010-native-local-desktop-shell.md) | Native local desktop shell | Accepted | Planning ADRs remain **Proposed** until their implementation lane accepts or rejects them. Implementation-specific ADRs may be **Accepted** when the corresponding shipped slice provides diff --git a/go.mod b/go.mod index 0b98af2..4bb888f 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.10.2 + github.com/wailsapp/wails/v2 v2.12.0 github.com/zalando/go-keyring v0.2.8 go.yaml.in/yaml/v3 v3.0.4 golang.org/x/term v0.45.0 @@ -25,7 +26,9 @@ require ( ) require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bep/debounce v1.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect @@ -40,6 +43,7 @@ require ( github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect @@ -52,27 +56,45 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect github.com/moby/spdystream v0.5.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.21.0 // indirect diff --git a/go.sum b/go.sum index d80cef2..cf62e0f 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,15 @@ charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= @@ -42,6 +46,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= @@ -77,6 +83,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -92,10 +100,32 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= @@ -112,6 +142,10 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -123,11 +157,14 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= @@ -148,6 +185,18 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -174,22 +223,34 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= diff --git a/internal/cli/desktop.go b/internal/cli/desktop.go new file mode 100644 index 0000000..9264e2a --- /dev/null +++ b/internal/cli/desktop.go @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + + "github.com/spf13/cobra" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/connector/kubeconfig" + "github.com/ArdurAI/sith/internal/fleetcache" + "github.com/ArdurAI/sith/internal/hydrate" + "github.com/ArdurAI/sith/internal/localops" + "github.com/ArdurAI/sith/internal/webui" +) + +type desktopOptions struct { + kubeconfigDir string +} + +func newDesktopCommand(reader connector.Reader, local localops.Client) *cobra.Command { + options := &desktopOptions{} + command := &cobra.Command{ + Use: "desktop", + Short: "Open the native local fleet IDE on macOS", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if err := validateDesktopDependencies(reader, local, options.kubeconfigDir); err != nil { + return err + } + return runDesktop(command.Context(), reader, local, options.kubeconfigDir) + }, + } + command.Flags().StringVar(&options.kubeconfigDir, "kubeconfig-dir", "", "import kubeconfig files from this directory for this local desktop session") + return command +} + +type desktopSourceFactory func(string) (connector.Reader, localops.Client, error) + +type desktopSession struct { + cancel context.CancelFunc + application *webui.Application + handler webui.LocalHandler +} + +func newDesktopSession(parent context.Context, reader connector.Reader, local localops.Client) (*desktopSession, error) { + ctx, cancel := context.WithCancel(parent) + store := fleetcache.New() + hydrator, err := hydrate.New(reader, store) + if err != nil { + cancel() + return nil, err + } + application, err := webui.New(ctx, store, hydrator, local) + if err != nil { + cancel() + return nil, err + } + handler, err := application.Handler(webui.DesktopOrigin) + if err != nil { + _ = application.Close() + cancel() + return nil, err + } + go runDesktopHydration(ctx, store, hydrator.Run) + return &desktopSession{cancel: cancel, application: application, handler: handler}, nil +} + +const desktopHydrationStopped = "live cache refresh stopped; re-import the folder or restart Sith" + +func runDesktopHydration(ctx context.Context, store *fleetcache.Store, run func(context.Context) error) { + if err := run(ctx); err != nil && ctx.Err() == nil { + // The cache/API exposes only a closed operational category. Raw watch + // errors can carry cluster-specific details and do not cross this boundary. + store.EndSync(errors.New(desktopHydrationStopped)) + } +} + +// quitDesktopOnCancellation defers native shutdown until Wails has supplied +// its application context, while allowing normal application shutdown to win. +func quitDesktopOnCancellation(parent context.Context, started <-chan context.Context, stopped <-chan struct{}, quit func(context.Context)) { + select { + case <-parent.Done(): + select { + case appContext := <-started: + quit(appContext) + case <-stopped: + } + case <-stopped: + } +} + +func (session *desktopSession) close() { + if session == nil { + return + } + session.cancel() + _ = session.application.Close() +} + +// desktopHost swaps complete in-memory sessions after a native folder choice. +// It never persists or returns the selected filesystem path. +type desktopHost struct { + ctx context.Context + newSource desktopSourceFactory + + mu sync.RWMutex + closed bool + session *desktopSession + handler *webui.InProcessHandler +} + +func newDesktopHost(ctx context.Context, reader connector.Reader, local localops.Client) (*desktopHost, error) { + if reader == nil || local == nil { + return nil, fmt.Errorf("construct local fleet desktop: Kubernetes access is unavailable") + } + host := &desktopHost{ + ctx: ctx, + newSource: desktopDirectorySource, + } + session, err := newDesktopSession(ctx, reader, local) + if err != nil { + return nil, err + } + host.session = session + host.handler = webui.NewInProcessHandler(session.handler) + return host, nil +} + +func desktopDirectorySource(directory string) (connector.Reader, localops.Client, error) { + adapter, err := kubeconfig.New(kubeconfig.WithDirectory(directory)) + if err != nil { + return nil, nil, err + } + return adapter, adapter, nil +} + +func validateDesktopDependencies(reader connector.Reader, local localops.Client, directory string) error { + if strings.TrimSpace(directory) != "" { + return nil + } + if reader == nil || local == nil { + return fmt.Errorf("local fleet desktop requires a Kubernetes reader and local operations client") + } + return nil +} + +func (host *desktopHost) Handler() webui.LocalHandler { + return host.handler +} + +func (host *desktopHost) importDirectory(directory string) error { + if strings.TrimSpace(directory) == "" { + return fmt.Errorf("import selected kubeconfig directory") + } + reader, local, err := host.newSource(directory) + if err != nil { + return fmt.Errorf("import selected kubeconfig directory") + } + next, err := newDesktopSession(host.ctx, reader, local) + if err != nil { + return fmt.Errorf("open selected kubeconfig directory") + } + host.mu.Lock() + if host.closed { + host.mu.Unlock() + next.close() + return fmt.Errorf("open selected kubeconfig directory") + } + previous := host.session + drained := host.handler.Replace(next.handler) + host.session = next + host.mu.Unlock() + go closeDesktopSessionAfter(drained, previous) + return nil +} + +func closeDesktopSessionAfter(drained <-chan struct{}, session *desktopSession) { + if drained == nil || session == nil { + return + } + <-drained + session.close() +} + +func (host *desktopHost) Close() { + host.mu.Lock() + if host.closed { + host.mu.Unlock() + return + } + host.closed = true + session := host.session + host.session = nil + host.handler.Replace(nil) + host.mu.Unlock() + session.close() +} diff --git a/internal/cli/desktop_darwin.go b/internal/cli/desktop_darwin.go new file mode 100644 index 0000000..4006b5d --- /dev/null +++ b/internal/cli/desktop_darwin.go @@ -0,0 +1,101 @@ +//go:build darwin + +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "fmt" + "sync" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/runtime" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/localops" + "github.com/ArdurAI/sith/internal/webui" +) + +// DesktopBridge is the only native capability exposed to the embedded UI. +// It returns a boolean, never the selected directory path. +type DesktopBridge struct { + ctx context.Context + host *desktopHost +} + +// ChooseKubeconfigDirectory opens the native directory picker and replaces the +// in-memory source only after the existing bounded importer accepts it. +func (bridge *DesktopBridge) ChooseKubeconfigDirectory() (bool, error) { + directory, err := runtime.OpenDirectoryDialog(bridge.ctx, runtime.OpenDialogOptions{ + Title: "Import kubeconfig folder", + CanCreateDirectories: false, + ShowHiddenFiles: false, + }) + if err != nil { + // Native dialog errors may include local filesystem details, so never + // return the underlying error across the WebView bridge. + return false, fmt.Errorf("select kubeconfig directory") + } + if directory == "" { + return false, nil + } + if err := bridge.host.importDirectory(directory); err != nil { + return false, err + } + return true, nil +} + +func runDesktop(ctx context.Context, reader connector.Reader, local localops.Client, directory string) error { + if directory != "" { + var err error + reader, local, err = desktopDirectorySource(directory) + if err != nil { + // Import errors can contain a selected local path; the CLI receives + // a stable category rather than that private detail. + return fmt.Errorf("import selected kubeconfig directory") + } + } + host, err := newDesktopHost(ctx, reader, local) + if err != nil { + return err + } + bridge := &DesktopBridge{host: host} + started := make(chan context.Context, 1) + stopped := make(chan struct{}) + var stopOnce sync.Once + stop := func() { stopOnce.Do(func() { close(stopped) }) } + go quitDesktopOnCancellation(ctx, started, stopped, runtime.Quit) + err = wails.Run(&options.App{ + Title: "Sith — Fleet IDE", + Width: 1440, + Height: 900, + MinWidth: 960, + MinHeight: 640, + BackgroundColour: &options.RGBA{R: 16, G: 24, B: 32, A: 255}, + OnStartup: func(appContext context.Context) { + bridge.ctx = appContext + select { + case started <- appContext: + case <-stopped: + } + }, + OnShutdown: func(context.Context) { + stop() + host.Close() + }, + Bind: []interface{}{bridge}, + EnableDefaultContextMenu: false, + AssetServer: &assetserver.Options{ + Middleware: webui.InProcessMiddleware(host.Handler()), + }, + }) + stop() + host.Close() + if err != nil { + return fmt.Errorf("start local fleet desktop: %w", err) + } + return nil +} diff --git a/internal/cli/desktop_execute.go b/internal/cli/desktop_execute.go new file mode 100644 index 0000000..acff144 --- /dev/null +++ b/internal/cli/desktop_execute.go @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "fmt" + "os" + "os/signal" + + "github.com/ArdurAI/sith/internal/connector/kubeconfig" +) + +// ExecuteDesktop runs the packaged macOS application entry point. +func ExecuteDesktop() int { + adapter := kubeconfig.Default() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + if err := runDesktop(ctx, adapter, adapter, ""); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + return 1 + } + return 0 +} diff --git a/internal/cli/desktop_other.go b/internal/cli/desktop_other.go new file mode 100644 index 0000000..67bbe8d --- /dev/null +++ b/internal/cli/desktop_other.go @@ -0,0 +1,17 @@ +//go:build !darwin + +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "fmt" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/localops" +) + +func runDesktop(context.Context, connector.Reader, localops.Client, string) error { + return fmt.Errorf("local fleet desktop is currently available only on macOS") +} diff --git a/internal/cli/desktop_test.go b/internal/cli/desktop_test.go new file mode 100644 index 0000000..51b9b7c --- /dev/null +++ b/internal/cli/desktop_test.go @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "sync" + "testing" + "time" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleet" + "github.com/ArdurAI/sith/internal/fleetcache" + "github.com/ArdurAI/sith/internal/localops" + "github.com/ArdurAI/sith/internal/webui" +) + +func TestDesktopHostServesTheExistingUIWithoutATCPListener(t *testing.T) { + t.Parallel() + host, err := newDesktopHost(t.Context(), &cacheReader{}, &fakeLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(host.Close) + indexRequest := httptest.NewRequest(http.MethodGet, webui.DesktopOrigin+"/", nil) + indexRequest.Host = "wails" + index := httptest.NewRecorder() + host.Handler().ServeHTTP(index, indexRequest) + match := regexp.MustCompile(`name="sith-csrf-token" content="([^"]+)"`).FindStringSubmatch(index.Body.String()) + if index.Code != http.StatusOK || len(match) != 2 { + t.Fatalf("desktop index = %d/%s", index.Code, index.Body.String()) + } + request := httptest.NewRequest(http.MethodGet, webui.DesktopOrigin+"/api/v1/meta", nil) + request.Host = "wails" + request.Header.Set("X-Sith-CSRF", match[1]) + request.Header.Set("Origin", webui.DesktopOrigin) + response := httptest.NewRecorder() + host.Handler().ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"telemetry":false`) { + t.Fatalf("desktop response = %d/%s", response.Code, response.Body.String()) + } +} + +func TestDesktopFolderImportUsesTheSharedSourceSeam(t *testing.T) { + t.Parallel() + host, err := newDesktopHost(t.Context(), &cacheReader{}, &fakeLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(host.Close) + selected := t.TempDir() + "/team-kubeconfigs" + called := "" + host.newSource = func(directory string) (connector.Reader, localops.Client, error) { + called = directory + return &cacheReader{}, &fakeLocalClient{}, nil + } + if err := host.importDirectory(selected); err != nil { + t.Fatal(err) + } + if called != selected { + t.Fatalf("selected directory = %q, want %q", called, selected) + } +} + +func TestDesktopFolderImportRedactsFailure(t *testing.T) { + t.Parallel() + host, err := newDesktopHost(t.Context(), &cacheReader{}, &fakeLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(host.Close) + selected := t.TempDir() + "/team-kubeconfigs" + host.newSource = func(directory string) (connector.Reader, localops.Client, error) { + return nil, nil, fmt.Errorf("unreadable %s", directory) + } + if err := host.importDirectory(selected); err == nil || strings.Contains(err.Error(), selected) { + t.Fatalf("import failure = %v, want redacted error", err) + } +} + +func TestDesktopFolderImportKeepsTheActiveSessionWhenReplacementCannotStart(t *testing.T) { + t.Parallel() + host, err := newDesktopHost(t.Context(), &cacheReader{}, &fakeLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(host.Close) + previous := host.session + selected := t.TempDir() + "/team-kubeconfigs" + host.newSource = func(string) (connector.Reader, localops.Client, error) { + return &cacheReader{}, nil, nil + } + if err := host.importDirectory(selected); err == nil || strings.Contains(err.Error(), selected) { + t.Fatalf("import failure = %v, want redacted replacement error", err) + } + if host.session != previous { + t.Fatal("failed import replaced the active desktop session") + } + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, webui.DesktopOrigin+"/", nil) + request.Host = "wails" + host.Handler().ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("active session status after failed import = %d", response.Code) + } +} + +func TestDesktopFolderImportCannotReviveAClosedHost(t *testing.T) { + t.Parallel() + host, err := newDesktopHost(t.Context(), &cacheReader{}, &fakeLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(host.Close) + started := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + releaseSource := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(releaseSource) + host.newSource = func(string) (connector.Reader, localops.Client, error) { + close(started) + <-release + return &cacheReader{}, &fakeLocalClient{}, nil + } + result := make(chan error, 1) + go func() { result <- host.importDirectory(t.TempDir() + "/team-kubeconfigs") }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("import did not reach source construction") + } + host.Close() + releaseSource() + select { + case err := <-result: + if err == nil { + t.Fatal("closed desktop host accepted a replacement session") + } + case <-time.After(time.Second): + t.Fatal("closed desktop host did not finish the interrupted import") + } + if host.session != nil { + t.Fatal("closed desktop host retained a session") + } + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, webui.DesktopOrigin+"/", nil) + request.Host = "wails" + host.Handler().ServeHTTP(response, request) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("closed desktop handler status = %d, want %d", response.Code, http.StatusServiceUnavailable) + } +} + +func TestDesktopDirectorySourceRejectsUnsafeInputWithoutPathLeak(t *testing.T) { + t.Parallel() + unsafe := t.TempDir() + "/missing-kubeconfigs" + if _, _, err := desktopDirectorySource(unsafe); err == nil || strings.Contains(err.Error(), unsafe) { + t.Fatalf("desktopDirectorySource() error = %v, want safe rejection", err) + } +} + +func TestDesktopDependenciesAllowAnExplicitDirectorySource(t *testing.T) { + t.Parallel() + if err := validateDesktopDependencies(nil, nil, t.TempDir()); err != nil { + t.Fatalf("validateDesktopDependencies() error = %v, want explicit directory accepted", err) + } + if err := validateDesktopDependencies(nil, nil, ""); err == nil { + t.Fatal("validateDesktopDependencies() error = nil, want missing default source rejected") + } +} + +func TestDesktopHydrationFailureIsSanitizedInTheFleetCache(t *testing.T) { + t.Parallel() + store := fleetcache.New() + runDesktopHydration(context.Background(), store, func(context.Context) error { + return fmt.Errorf("watch /private/kubeconfigs/team.yaml failed") + }) + snapshot := store.Query(fleet.LocalWorkspace, fleetcache.Query{}) + if snapshot.LastError != desktopHydrationStopped || strings.Contains(snapshot.LastError, "/private/") { + t.Fatalf("hydration failure = %q, want sanitized category", snapshot.LastError) + } +} + +func TestQuitDesktopOnCancellationAfterStartup(t *testing.T) { + t.Parallel() + parent, cancel := context.WithCancel(t.Context()) + defer cancel() + started := make(chan context.Context, 1) + stopped := make(chan struct{}) + quit := make(chan struct{}, 1) + go quitDesktopOnCancellation(parent, started, stopped, func(context.Context) { quit <- struct{}{} }) + started <- context.Background() + cancel() + select { + case <-quit: + case <-time.After(time.Second): + t.Fatal("desktop cancellation did not request native shutdown") + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index b80cf14..a8c93fa 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -144,6 +144,7 @@ func newRootCommand(runtime backend, stdout, stderr io.Writer) *cobra.Command { newVersionCommand(options), newClustersCommand(options, runtime.source), newUICommand(runtime.reader, runtime.local), + newDesktopCommand(runtime.reader, runtime.local), newHubCommand(), } if runtime.reader != nil { diff --git a/internal/privacy/boundary_test.go b/internal/privacy/boundary_test.go index cdb3b9e..056a95e 100644 --- a/internal/privacy/boundary_test.go +++ b/internal/privacy/boundary_test.go @@ -43,7 +43,9 @@ var approvedNetworkImports = map[string]map[string]bool{ "internal/mcpserver/server.go": {"net": true, "net/http": true, "net/url": true}, "internal/observability/metrics.go": {"net/http": true}, "internal/webui/api.go": {"net/http": true}, - "internal/webui/server.go": {"net": true, "net/http": true, "net/url": true}, + // In-process Wails WebView routing; it has no socket listener or egress path. + "internal/webui/desktop.go": {"net/http": true}, + "internal/webui/server.go": {"net": true, "net/http": true, "net/url": true}, } var approvedFilesystemWrites = map[string]map[string]bool{ diff --git a/internal/webui/assets/app.js b/internal/webui/assets/app.js index 22199ac..8c88bac 100644 --- a/internal/webui/assets/app.js +++ b/internal/webui/assets/app.js @@ -1,6 +1,7 @@ "use strict"; const csrfToken = document.querySelector('meta[name="sith-csrf-token"]').content; +const desktopHydrationFailure = "live cache refresh stopped; re-import the folder or restart Sith"; const state = { meta: null, snapshot: null, @@ -18,7 +19,7 @@ const dom = Object.fromEntries([ "query-mode", "context-list", "board-heading", "board-kicker", "result-count", "fleet-rows", "empty-state", "coverage-line", "inspector-empty", "inspector-content", "inspector-kind", "inspector-name", "inspector-address", "inspector-facts", "operation-grid", "refresh-button", - "forwards-button", "forward-count", "toast-region", "action-dialog", "dialog-title", + "forwards-button", "forward-count", "import-folder-button", "toast-region", "action-dialog", "dialog-title", "dialog-kicker", "dialog-body", "dialog-actions", "dialog-close", "loading-template", ].map((id) => [id, document.getElementById(id)])); @@ -85,7 +86,9 @@ function renderSnapshot() { const snapshot = state.snapshot; const coverage = snapshot.coverage || {}; dom["coverage-count"].textContent = `${coverage.reachable || 0} of ${coverage.requested || 0} contexts answering`; - dom["coverage-detail"].textContent = snapshot.state === "offline" ? "Offline — last-known fleet remains visible." : coverageText(coverage); + const coverageDetail = snapshot.state === "offline" ? "Offline — last-known fleet remains visible." : coverageText(coverage); + const safeDesktopFailure = snapshot.last_error === desktopHydrationFailure ? snapshot.last_error : ""; + dom["coverage-detail"].textContent = safeDesktopFailure ? `${coverageDetail} · ${safeDesktopFailure}` : coverageDetail; dom["coverage-line"].textContent = coverageText(coverage); dom["board-heading"].textContent = state.correlate || state.query ? "Fleet results" : `${state.lens}s`; dom["board-kicker"].textContent = state.correlate ? "Correlation answer" : state.query ? "Filtered cache" : "Aggregated lens"; @@ -417,6 +420,15 @@ dom["query-mode"].addEventListener("click", () => { }); dom["refresh-button"].addEventListener("click", async () => { try { await api("/api/v1/sync", {method: "POST", body: "{}"}); toast("Fleet refresh scheduled."); } catch (error) { toast(error.message, "error"); } }); dom["forwards-button"].addEventListener("click", showForwards); +const directoryPicker = window.go?.cli?.DesktopBridge?.ChooseKubeconfigDirectory; +if (typeof directoryPicker === "function") { + dom["import-folder-button"].hidden = false; + dom["import-folder-button"].addEventListener("click", async () => { + try { + if (await directoryPicker()) window.location.reload(); + } catch (error) { toast(error.message || "Unable to import folder.", "error"); } + }); +} dom["dialog-close"].addEventListener("click", () => dom["action-dialog"].close()); dom["action-dialog"].addEventListener("close", () => { state.logAbort?.abort(); state.logAbort = null; }); document.addEventListener("keydown", (event) => { diff --git a/internal/webui/assets/index.html b/internal/webui/assets/index.html index dfe89c7..479ffd8 100644 --- a/internal/webui/assets/index.html +++ b/internal/webui/assets/index.html @@ -24,7 +24,8 @@

Sith / live

warming contexts Cache rows appear as clusters answer. -
+
+
diff --git a/internal/webui/desktop.go b/internal/webui/desktop.go new file mode 100644 index 0000000..e9469a0 --- /dev/null +++ b/internal/webui/desktop.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 + +package webui + +import ( + "net/http" + "sync" +) + +// LocalHandler is a same-process HTTP request handler. It exists so a native +// WebView can reuse the hardened web UI without opening a TCP listener. +type LocalHandler interface { + ServeHTTP(http.ResponseWriter, *http.Request) +} + +// InProcessHandler routes requests to the active local UI session. A replaced +// session remains leased only by requests that selected it before the swap. +type InProcessHandler struct { + mu sync.Mutex + current *inProcessSession +} + +type inProcessSession struct { + handler LocalHandler + active uint64 + retired bool + drained chan struct{} +} + +// NewInProcessHandler constructs a handler whose active UI session can be +// replaced without starting a network listener. +func NewInProcessHandler(initial LocalHandler) *InProcessHandler { + handler := &InProcessHandler{} + if initial != nil { + handler.current = newInProcessSession(initial) + } + return handler +} + +func (handler *InProcessHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) { + session := handler.acquire() + if session == nil { + http.Error(response, "local desktop is shutting down", http.StatusServiceUnavailable) + return + } + defer handler.release(session) + session.handler.ServeHTTP(response, request) +} + +// Replace atomically selects the next local UI session. It returns a channel +// that closes once requests leased to the previous session have drained. A nil +// handler makes future requests fail closed without blocking new requests on an +// old, slow operation. +func (handler *InProcessHandler) Replace(next LocalHandler) <-chan struct{} { + handler.mu.Lock() + previous := handler.current + if next == nil { + handler.current = nil + } else { + handler.current = newInProcessSession(next) + } + if previous == nil { + handler.mu.Unlock() + return nil + } + previous.retired = true + if previous.active == 0 { + close(previous.drained) + } + drained := previous.drained + handler.mu.Unlock() + return drained +} + +func newInProcessSession(next LocalHandler) *inProcessSession { + return &inProcessSession{handler: next, drained: make(chan struct{})} +} + +func (handler *InProcessHandler) acquire() *inProcessSession { + handler.mu.Lock() + defer handler.mu.Unlock() + if handler.current == nil { + return nil + } + handler.current.active++ + return handler.current +} + +func (handler *InProcessHandler) release(session *inProcessSession) { + handler.mu.Lock() + defer handler.mu.Unlock() + session.active-- + if session.retired && session.active == 0 { + close(session.drained) + } +} + +// InProcessMiddleware adapts a local handler to Wails without using the +// framework default asset route or opening a listener. +func InProcessMiddleware(handler LocalHandler) func(http.Handler) http.Handler { + return func(http.Handler) http.Handler { return handler } +} diff --git a/internal/webui/desktop_test.go b/internal/webui/desktop_test.go new file mode 100644 index 0000000..77a237f --- /dev/null +++ b/internal/webui/desktop_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +package webui + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +type blockingLocalHandler struct { + started chan<- struct{} + release <-chan struct{} +} + +func (handler blockingLocalHandler) ServeHTTP(response http.ResponseWriter, _ *http.Request) { + close(handler.started) + <-handler.release + response.WriteHeader(http.StatusNoContent) +} + +func TestInProcessHandlerReplacesWithoutBlockingNewRequests(t *testing.T) { + t.Parallel() + started := make(chan struct{}) + release := make(chan struct{}) + handler := NewInProcessHandler(blockingLocalHandler{started: started, release: release}) + served := make(chan struct{}) + go func() { + handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "wails://wails/", nil)) + close(served) + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("initial in-process request did not start") + } + drained := handler.Replace(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusNoContent) + })) + select { + case <-drained: + t.Fatal("previous session drained while its request was in flight") + case <-time.After(20 * time.Millisecond): + } + fresh := httptest.NewRecorder() + handler.ServeHTTP(fresh, httptest.NewRequest(http.MethodGet, "wails://wails/", nil)) + if fresh.Code != http.StatusNoContent { + t.Fatalf("replacement handler status = %d, want %d", fresh.Code, http.StatusNoContent) + } + close(release) + select { + case <-served: + case <-time.After(time.Second): + t.Fatal("initial in-process request did not finish") + } + select { + case <-drained: + case <-time.After(time.Second): + t.Fatal("previous session did not drain after its request completed") + } +} diff --git a/internal/webui/server.go b/internal/webui/server.go index 1919108..f2faaf2 100644 --- a/internal/webui/server.go +++ b/internal/webui/server.go @@ -27,6 +27,10 @@ import ( const ( csrfHeader = "X-Sith-CSRF" localMode = "local" + + // DesktopOrigin is Wails' in-process macOS WebView origin. It never binds a + // TCP listener and is accepted only by the native desktop host. + DesktopOrigin = "wails://wails" ) //go:embed assets/* @@ -85,11 +89,16 @@ func New(ctx context.Context, store *fleetcache.Store, syncer Syncer, local loca }, nil } -// Handler returns the hardened frontend/API handler for one exact listener URL. +// Handler returns the hardened frontend/API handler for one exact local origin. func (application *Application) Handler(baseURL string) (http.Handler, error) { parsed, err := url.Parse(baseURL) - if err != nil || parsed.Scheme != "http" || parsed.Host == "" || parsed.Path != "" { - return nil, fmt.Errorf("configure web UI handler: base URL must be an http origin") + if err != nil || parsed.Host == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("configure web UI handler: base URL must be an exact local origin") + } + if baseURL != DesktopOrigin { + if parsed.Scheme != "http" || ValidateLoopbackAddress(parsed.Hostname()) != nil { + return nil, fmt.Errorf("configure web UI handler: base URL must be a loopback http origin") + } } mux := http.NewServeMux() mux.HandleFunc("GET /", application.serveIndex) diff --git a/internal/webui/server_test.go b/internal/webui/server_test.go index 3427e42..77517ce 100644 --- a/internal/webui/server_test.go +++ b/internal/webui/server_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "net/http" "net/http/httptest" "slices" @@ -88,6 +89,54 @@ func TestHandlerEnforcesHostOriginCapabilityAndSecurityHeaders(t *testing.T) { } } +func TestHandlerAllowsOnlyTheExplicitDesktopOrigin(t *testing.T) { + t.Parallel() + application := testApplication(t) + handler, err := application.Handler(DesktopOrigin) + if err != nil { + t.Fatalf("Handler(%q) error = %v", DesktopOrigin, err) + } + request := httptest.NewRequest(http.MethodGet, DesktopOrigin+"/api/v1/meta", nil) + request.Host = "wails" + request.Header.Set(csrfHeader, application.token) + request.Header.Set("Origin", DesktopOrigin) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("desktop meta status/body = %d/%s", recorder.Code, recorder.Body.String()) + } + for _, origin := range []string{"https://wails.localhost", "http://example.com", "wails://attacker"} { + if _, err := application.Handler(origin); err == nil { + t.Errorf("Handler(%q) error = nil", origin) + } + } +} + +func TestDesktopFolderBridgeIsOptInAndDoesNotExposePaths(t *testing.T) { + t.Parallel() + index, err := fs.ReadFile(embeddedAssets, "assets/index.html") + if err != nil { + t.Fatal(err) + } + script, err := fs.ReadFile(embeddedAssets, "assets/app.js") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(index), `id="import-folder-button" class="quiet-action" type="button" hidden`) { + t.Fatal("desktop import control is not hidden by default") + } + if !strings.Contains(string(script), "window.go?.cli?.DesktopBridge?.ChooseKubeconfigDirectory") || + !strings.Contains(string(script), "if (await directoryPicker()) window.location.reload()") { + t.Fatal("desktop bridge is not opt-in or does not reload after a successful source swap") + } + if !strings.Contains(string(script), `snapshot.last_error === desktopHydrationFailure`) { + t.Fatal("desktop UI does not allowlist a sanitized hydration failure") + } + if strings.Contains(string(script), "selectedDirectory") || strings.Contains(string(script), "kubeconfigDir") { + t.Fatal("desktop bridge must not retain a selected local path in the UI") + } +} + func TestSnapshotReadsCacheOnlyAndRefreshIsExplicit(t *testing.T) { t.Parallel() syncer := &webSyncer{} diff --git a/sessions/2026-07-14-f11-native-desktop-shell.md b/sessions/2026-07-14-f11-native-desktop-shell.md new file mode 100644 index 0000000..64d248c --- /dev/null +++ b/sessions/2026-07-14-f11-native-desktop-shell.md @@ -0,0 +1,104 @@ +# F11.8 native local desktop shell + +Issue: [#166](https://github.com/ArdurAI/sith/issues/166) + +Branch: `gnanirahulnutakki/feat/f11-native-desktop-shell` + +Base: `origin/dev` at `c6fa47bb63a269025bfcb3ab9f8042bb02d71edb` + +## [G] Goal + +Add the first native macOS form of Sith's local fleet IDE without creating a +second Kubernetes client, a TCP listener, account state, telemetry, or a path +leak across the UI bridge. + +## [D] Decision + +- ADR 0010 adopts stable Wails v2 as a thin native shell; Wails v3 remains + alpha and is not selected. +- The native WebView serves the existing `webui.Application` at the exact + `wails://wails` origin through Wails' in-process asset-server middleware. +- The only native bridge method opens a directory chooser. It returns success, + cancellation, or a sanitized failure category only; the chosen path and + kubeconfig contents never enter JavaScript, diagnostics, or persistent state. +- A choice creates a bounded kubeconfig-import source and complete replacement + in-memory session before atomically replacing the active handler. Failure + retains the active session. +- A replacement switches routing immediately. Requests already leased to the + prior session drain before it closes, so one slow request cannot block the + new fleet view. +- `make desktop-build` creates an Apple Silicon development bundle with stable + `com.ardurai.sith` identity and an ad-hoc signature. Developer ID signing, + notarization, stapling, and release provenance remain E9 follow-up work. + +## [A] Red-team review + +- The source passes explicit `wails://wails` only to the existing hardened + Host/Origin/CSRF/CSP handler; all other non-loopback origins remain rejected. +- `InProcessHandler` leases the selected session briefly under a mutex, then + releases the routing lock before invoking it. Replacement routes new requests + immediately and closes the prior application only after its leased requests drain. +- `sith desktop --kubeconfig-dir` constructs the bounded directory source + before the desktop host, avoiding default-kubeconfig hydration before the + explicit import validates. +- Browser mode has no `window.go` bridge, so the import control remains hidden. +- CodeRabbit's initial staged review found a major close/import race and a + minor error-wrapping suggestion. The major race is fixed with terminal host + state and a deterministic regression test. The minor is intentionally not + applied: native dialog and kubeconfig errors can contain local paths, so the + bridge and CLI return stable redacted categories. +- The explicit hosted CodeRabbit review on PR #167 then found valid packaging, + dependency-override, non-starving session-handoff, hydration-status, native + signal-shutdown, and documentation gaps. All are fixed in the review + checkpoint with focused regression tests. The tool's suggested `-nomodsync` + spelling was verified against Wails v2.12.0 and corrected to its actual + `-nosyncgomod` flag. + +## [T] Tests and evidence + +- Focused `go test -race -count=1 ./internal/cli ./internal/webui`: PASS. +- Replacement-preserves-session and in-flight-handler-replacement tests: PASS; + the pair is stable across 50 local repetitions. +- Final `make ci`: PASS (format, vet, lint, reachable-vulnerability scan with + no findings, race tests, safety scripts, performance, binary e2e in 18.265s, + and production build). +- Final `make e2e-isolation`: PASS (forced PostgreSQL RLS tests and 50,000-case + cross-workspace selector fuzz campaign). +- Final `make release-check`: PASS (two verified Darwin/Linux amd64/arm64 + snapshots, SPDX SBOMs, formula rendering, and deterministic digests). +- Final `make e2e-kind`: PASS in 158.742 seconds for real two-cluster fleet + fanout and OCI image contracts. `kind get clusters` and the Sith-named Docker + container check were empty afterward. +- Final `make desktop-build WAILS=/Volumes/EXTENDED/MacData/go/bin/wails`: + PASS with Wails CLI v2.12.0. The resulting app is ARM64, bundle identifier + `com.ardurai.sith`, `Signature=adhoc`, and `TeamIdentifier=not set`. +- `go run ./cmd/sith desktop --help`: PASS with the expected desktop and + `--kubeconfig-dir` contract. +- `git diff --check`: PASS before review/staging. + +## [T] Review checkpoint evidence + +- PR #167 initial hosted CI: PASS (`build · vet · gofmt · lint · test · e2e` + in 10m46s; reproducible archives/SBOM/formula in 5m9s; all CodeQL analyses + and the explicit CodeRabbit review completed). +- Review-fix final `make ci`: PASS (binary e2e 24.797s and production build). +- Review-fix `make e2e-isolation`, `make release-check`, and `make e2e-kind`: + PASS; the final real kind suite took 154.406s and cleaned every temporary + cluster. +- Review-fix desktop bundle: Wails v2.12.0, `-m -nosyncgomod`, verified ARM64, + `com.ardurai.sith`, and ad-hoc signature: PASS. The Wails build left + `go.mod` unchanged. +- The local CodeRabbit pass found an unbounded wait in the close/import test; + it is corrected with one-second bounds and idempotent source-release cleanup, + then verified by focused race tests and the final full CI run. +- Hosted Linux CI then caught the cancellation-helper test compiling against a + macOS-only definition. The helper is now shared while Wails startup remains + Darwin-only; focused race suites and the final `make ci` pass after that + correction. + +## [C] Checkpoint + +- Initial signed/DCO/GSTACK implementation checkpoint: `78af530`. +- Review-fix implementation checkpoint: `b99820e`. +- Linux-CI portability correction, fresh peer pass, signed/DCO checkpoint, + hosted CI, merge, and exact post-merge CI remain to be recorded.