From 08fbc035cfc11500b481606b8a4f9ac99f12d301 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 12:05:30 +0100 Subject: [PATCH 01/12] build(server): stamp the binary version and add fetch-llama strict mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two prerequisites for packaging a macOS .app, both of which make a release build meaningfully different from a developer build. Version stamping. `make build` passed no ldflags, so every non-Docker build (make bundle, install-server.sh --mode native, and now the .app) shipped a cix-server that could not report what it was — only server/Dockerfile stamped -X main.version. Mirror the Dockerfile's flags exactly: -trimpath, -w, and no -s, since govulncheck -mode=binary needs the Go symbol table. The version source needed fixing too. `git describe --tags` has no --match filter, and this repo has three tag streams; on develop it resolved to a *CLI* tag, so a locally built *server* image was labelled with a CLI version. CI never hit this because release-server.yml passes VERSION= explicitly, which is why it went unnoticed. Filter to server/v* and strip the prefix. fetch-llama strict mode. The script records a missing checksum and continues, which is right when a contributor is bootstrapping a LLAMA_VERSION bump and wrong in a build that ships: the recorded value is computed from the download it is meant to verify, so it verifies nothing. LLAMA_STRICT=1 fails instead, and does so before spending the ~50 MB download. Default stays 0. The header comment claimed downstream CI already failed hard on this; nothing did. Co-Authored-By: Claude Opus 5 --- .gitignore | 7 ++++++ server/Makefile | 40 +++++++++++++++++++++++++++--- server/scripts/fetch-llama.sh | 46 +++++++++++++++++++++++++++++------ 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 8fa5f157..58efa0e7 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,13 @@ cli/dist/ # Server build artifacts + runtime logs server/dist/ server/exec.log + +# macOS app build artifacts (mac/scripts/build-app.sh, make-dmg.sh). +# The committed inputs live in mac/Resources/ and mac/Info.plist.in; everything +# under mac/dist/ is generated. Disk images are never tracked — the release +# workflow publishes them as GitHub release assets. +mac/dist/ +*.dmg # Root scratch dir only — anchored: a bare "tmp" would silently ignore any # nested file/dir named tmp (same pitfall as the docs/ pattern below). /tmp/ diff --git a/server/Makefile b/server/Makefile index 8b9a95f3..96a4a879 100644 --- a/server/Makefile +++ b/server/Makefile @@ -23,7 +23,29 @@ GOFLAGS ?= IMAGE_REPO ?= dvcdsys/code-index IMAGE_TAG ?= go-cu128 -VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo "0.0.0-dev") + +# Version stamped into the binary (-X main.version) and into Docker images. +# +# The tag stream must be filtered. `git describe --tags` without --match picks +# whichever namespace happens to be nearest, and this repo has three of them +# (server/v*, cli/v*, mac/v*) — on develop it resolved to a CLI tag, so every +# locally built *server* image was labelled with a *CLI* version. CI never hit +# this (release-server.yml passes VERSION= explicitly from the tag), which is +# why it went unnoticed; `make build` / `bundle` / `docker-build-cuda*` did. +# +# Note the tag must be reachable from HEAD for describe to see it. Release +# workflows always pass the version in explicitly rather than deriving it. +SERVER_VERSION ?= $(shell git describe --tags --match 'server/v*' 2>/dev/null | sed 's|^server/v||') +ifeq ($(strip $(SERVER_VERSION)),) +SERVER_VERSION := 0.0.0-dev +endif +VERSION ?= $(SERVER_VERSION) + +# -w drops DWARF only. Do NOT add -s: govulncheck -mode=binary needs the Go +# symbol table for symbol-level analysis; on a stripped binary it falls back to +# module-level reporting and flags advisories for packages that are not linked. +# Same rationale, and same flags, as server/Dockerfile:98-106 and release-cli.yml. +LDFLAGS ?= -w -X main.version=$(SERVER_VERSION) # Floating dev tag — overwritten on every `docker-build-cuda-dev` push. Used # by the operator to deploy "current local work" onto the RTX 3090 box for @@ -51,9 +73,9 @@ DASHBOARD_OUT := $(ROOT)/internal/httpapi/dashboard/dist help: @echo "Targets:" - @echo " build — dashboard-build + go build cmd/cix-server into dist/$(BUNDLE_NAME)/cix-server" + @echo " build — dashboard-build + go build cmd/cix-server into dist/$(BUNDLE_NAME)/cix-server (stamps version $(SERVER_VERSION))" @echo " test — go test ./... (no llama-server required)" - @echo " fetch-llama — download + SHA256-verify llama.cpp $(LLAMA_VERSION) for $(OS)-$(ARCH)" + @echo " fetch-llama — download + SHA256-verify llama.cpp $(LLAMA_VERSION) for $(OS)-$(ARCH) (LLAMA_STRICT=1 to require a recorded checksum)" @echo " bundle — build + fetch-llama, assemble dist/$(BUNDLE_NAME)/ tree" @echo " run — bundle + launch cix-server (reads .env from repo root, sets CIX_LLAMA_BIN_DIR)" @echo " test-gate — run the Phase 3 parity gate (requires fetch-llama + GGUF)" @@ -74,18 +96,28 @@ help: # `go build` embeds internal/httpapi/dashboard/dist/ via go:embed — the # dashboard must be built first so the binary picks up fresh assets. +# +# The ldflags are not optional cosmetics: without them `cix-server -v` reports +# the zero value of main.version, so every non-Docker build (make bundle, the +# macOS .app, install-server.sh --mode native) shipped an unidentifiable binary. build: dashboard-build mkdir -p $(BUNDLE_DIR) - $(GO) build $(GOFLAGS) -o $(BUNDLE_DIR)/cix-server ./cmd/cix-server + $(GO) build $(GOFLAGS) -trimpath -ldflags "$(LDFLAGS)" -o $(BUNDLE_DIR)/cix-server ./cmd/cix-server test: $(GO) test ./... +# LLAMA_STRICT=1 turns the first-run "record the checksum" convenience into a +# hard failure. Release builds set it so an upstream asset that was swapped +# after the pin was recorded cannot be silently shipped. +LLAMA_STRICT ?= 0 + fetch-llama: LLAMA_VERSION=$(LLAMA_VERSION) \ LLAMA_REPO=$(LLAMA_REPO) \ LLAMA_OS=$(OS) \ LLAMA_ARCH=$(ARCH) \ + LLAMA_STRICT=$(LLAMA_STRICT) \ DEST_DIR=$(LLAMA_DIR) \ CHECKSUMS_FILE=$(ROOT)/scripts/llama-checksums.txt \ $(ROOT)/scripts/fetch-llama.sh diff --git a/server/scripts/fetch-llama.sh b/server/scripts/fetch-llama.sh index 76673114..b2a30bae 100755 --- a/server/scripts/fetch-llama.sh +++ b/server/scripts/fetch-llama.sh @@ -9,6 +9,7 @@ # LLAMA_ARCH — "arm64" (Phase 3 only supports arm64) # DEST_DIR — target directory for the slimmed binary set # CHECKSUMS_FILE — path to scripts/llama-checksums.txt +# LLAMA_STRICT — "1" to require a pre-recorded checksum (default "0") # # First-run bootstrap flow # ------------------------ @@ -16,11 +17,20 @@ # for the asset is unknown. Rather than fail, we compute the SHA256 after the # download and APPEND it to CHECKSUMS_FILE, printing a very visible message. # The expectation is that the contributor then commits that checksum file -# update in the same PR that bumps LLAMA_VERSION — downstream CI fails hard -# if the asset's SHA256 does not match an existing line. +# update in the same PR that bumps LLAMA_VERSION. # # Every subsequent run on the same LLAMA_VERSION uses the recorded checksum # as the authoritative verifier; mismatches fail. +# +# Strict mode (LLAMA_STRICT=1) +# ---------------------------- +# Record-on-first-run is the right behaviour for a developer bootstrapping a +# bump, and the wrong behaviour for a build that ships. In a release build a +# missing checksum row means "trust whatever the network just handed us" — +# the recorded value is derived from the download itself, so it verifies +# nothing. Release workflows set LLAMA_STRICT=1 so an unpinned LLAMA_VERSION +# fails the build instead, and the fix is to run `make fetch-llama` locally +# and commit the resulting llama-checksums.txt line. set -euo pipefail @@ -30,6 +40,7 @@ set -euo pipefail : "${LLAMA_ARCH:?LLAMA_ARCH is required}" : "${DEST_DIR:?DEST_DIR is required}" : "${CHECKSUMS_FILE:?CHECKSUMS_FILE is required}" +: "${LLAMA_STRICT:=0}" if [[ "$LLAMA_OS" != "darwin" || "$LLAMA_ARCH" != "arm64" ]]; then echo "fetch-llama.sh: only darwin-arm64 is supported in Phase 3 (got $LLAMA_OS-$LLAMA_ARCH)" >&2 @@ -41,6 +52,30 @@ fi ASSET="llama-${LLAMA_VERSION}-bin-macos-arm64.tar.gz" URL="https://github.com/${LLAMA_REPO}/releases/download/${LLAMA_VERSION}/${ASSET}" +# Look the pin up BEFORE downloading, so strict mode fails in a second rather +# than after pulling ~50 MB it is going to reject anyway. +EXPECTED_SHA="" +if [[ -f "$CHECKSUMS_FILE" ]]; then + EXPECTED_SHA=$(awk -v a="$ASSET" '$2 == a { print $1 }' "$CHECKSUMS_FILE" || true) +fi + +if [[ "$LLAMA_STRICT" == "1" && -z "$EXPECTED_SHA" ]]; then + cat >&2 < Date: Mon, 10 Aug 2026 12:05:47 +0100 Subject: [PATCH 02/12] feat(mac): package cix as a signed macOS app released on mac/v* tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third, independent tag stream. `mac/vX.Y.Z` builds a single Apple Silicon .app carrying cix-server, the cix CLI and a Metal-accelerated llama-server, published as a drag-to-Applications DMG. The versions of the two bundled binaries are stamped separately and recorded in Info.plist, because the app is versioned by what it does, not by what it happens to contain. This release is the pipeline, not the product. The menu-bar interface — start/stop, provider status, dashboard link, password reset, autostart and self-update — lands in later mac/v* releases. What ships now is a bundle that assembles, signs and verifies, plus enough of a front end (`cix-launcher -report`) to prove each component is the one the build intended. Layout is constrained, not chosen. Every executable sits in Contents/MacOS, including llama/: codesign --verify --strict rejects executable code under Resources/, and cix-server resolves llama-server at filepath.Dir(os.Executable())/llama, so keeping them siblings means CIX_LLAMA_BIN_DIR never has to be set. Signing is ad-hoc and bottom-up. There is no paid Apple Developer membership, so there is nothing to notarize — but ad-hoc signing is still mandatory, since Apple Silicon refuses to run unsigned code at all. `xattr -cr` runs on every build, not once: server/Makefile already records that macOS 26 amfid SIGKILLs an ad-hoc-signed binary whose dylibs carry a stale signature or a com.apple.provenance xattr, with empty stderr, and every cp into the staging tree recreates those conditions. --deep is avoided (deprecated, and unreliable for a bundle with four executables and ~35 dylibs directly in MacOS/). Consequences that are documented rather than worked around: Gatekeeper blocks the first launch and macOS 15+ removed the right-click→Open shortcut, so the System Settings route ships in the DMG, the release body and doc/MACOS_APP.md; Homebrew Cask is not an option, as support for casks failing Gatekeeper ends 2026-09-01; and there is no Intel build, because upstream llama.cpp publishes no macOS x86_64 asset — build-app.sh refuses rather than producing a bundle that dies at the first embedding. The launcher lives at cli/launcher/ inside the cli module so it can import cli/internal/{client,config} directly — Go's internal rule is directory-scoped, and a separate module would force duplicating both packages. A non-darwin stub keeps `go build ./...` green on ubuntu in ci-cli.yml. Icons are placeholders, replaceable by dropping in three files with no code change. make-placeholder-icons.py regenerates them so they are reproducible rather than mystery binaries; it is stdlib-only and no build ever runs it. Co-Authored-By: Claude Opus 5 --- .github/workflows/release-mac.yml | 194 +++++++++++++++++++++++++ cli/launcher/bundle_darwin.go | 104 ++++++++++++++ cli/launcher/dialog_darwin.go | 59 ++++++++ cli/launcher/main_darwin.go | 86 +++++++++++ cli/launcher/main_other.go | 19 +++ cli/launcher/version.go | 23 +++ doc/MACOS_APP.md | 153 ++++++++++++++++++++ mac/Info.plist.in | 70 +++++++++ mac/README.md | 117 +++++++++++++++ mac/Resources/AppIcon.icns | Bin 0 -> 73062 bytes mac/Resources/menubar.png | Bin 0 -> 123 bytes mac/Resources/menubar@2x.png | Bin 0 -> 188 bytes mac/scripts/build-app.sh | 142 ++++++++++++++++++ mac/scripts/make-dmg.sh | 100 +++++++++++++ mac/scripts/make-placeholder-icons.py | 200 ++++++++++++++++++++++++++ mac/scripts/sign-app.sh | 83 +++++++++++ 16 files changed, 1350 insertions(+) create mode 100644 .github/workflows/release-mac.yml create mode 100644 cli/launcher/bundle_darwin.go create mode 100644 cli/launcher/dialog_darwin.go create mode 100644 cli/launcher/main_darwin.go create mode 100644 cli/launcher/main_other.go create mode 100644 cli/launcher/version.go create mode 100644 doc/MACOS_APP.md create mode 100644 mac/Info.plist.in create mode 100644 mac/README.md create mode 100644 mac/Resources/AppIcon.icns create mode 100644 mac/Resources/menubar.png create mode 100644 mac/Resources/menubar@2x.png create mode 100755 mac/scripts/build-app.sh create mode 100755 mac/scripts/make-dmg.sh create mode 100755 mac/scripts/make-placeholder-icons.py create mode 100755 mac/scripts/sign-app.sh diff --git a/.github/workflows/release-mac.yml b/.github/workflows/release-mac.yml new file mode 100644 index 00000000..0dee01fc --- /dev/null +++ b/.github/workflows/release-mac.yml @@ -0,0 +1,194 @@ +name: "Release: macOS app" + +# Triggered by macOS-app tags (e.g. `mac/v0.1.0`). This is a third, independent +# tag stream alongside `server/v*` and `cli/v*`: the .app is versioned by what +# the app itself does, not by the versions of the two binaries it happens to +# bundle. Those are stamped separately (see the "Resolve versions" step) and +# recorded in the bundle's Info.plist. +# +# Cut these tags on `main`. `git describe` walks ancestors, so a tag cut on +# develop resolves to whatever server/v* is reachable from there — which on this +# repo has been several releases behind what actually shipped. +on: + push: + tags: + - "mac/v*" + workflow_dispatch: + inputs: + ref: + description: "Ref to build (branch, tag, or SHA)" + required: true + version: + description: "App version, without the mac/v prefix (e.g. 0.1.0)" + required: true + +permissions: + contents: write + +jobs: + build: + name: Build cix.app + DMG + # The bundled llama-server is macOS arm64 only (upstream ships no macOS + # x86_64 asset), so the whole app is Apple Silicon only and must be built + # natively. macos-latest is arm64. + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ github.event.inputs.ref || github.ref }} + # Full history: the server and CLI versions come from `git describe` + # against tags that are not the tag being built. + fetch-depth: 0 + + - name: Resolve versions + id: ver + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + MAC_VERSION="${{ github.event.inputs.version }}" + else + MAC_VERSION="${GITHUB_REF_NAME#mac/v}" + fi + + SERVER_TAG="$(git describe --tags --match 'server/v*' --abbrev=0 2>/dev/null || true)" + CLI_TAG="$(git describe --tags --match 'cli/v*' --abbrev=0 2>/dev/null || true)" + + # Guard, not a nicety. Without a reachable tag on each stream the + # binaries would ship stamped 0.0.0-dev, which is indistinguishable + # from a local developer build and useless in a bug report. + if [ -z "$SERVER_TAG" ] || [ -z "$CLI_TAG" ]; then + echo "::error title=Unreleasable commit::A mac/v* tag must be cut from a commit with both a server/v* and a cli/v* tag reachable. Found server='${SERVER_TAG:-none}' cli='${CLI_TAG:-none}'. Cut the tag on main." + exit 1 + fi + + { + echo "mac=$MAC_VERSION" + echo "server=${SERVER_TAG#server/v}" + echo "cli=${CLI_TAG#cli/v}" + } >> "$GITHUB_OUTPUT" + + echo "app=$MAC_VERSION server=$SERVER_TAG cli=$CLI_TAG" + + - name: Set up Go + uses: actions/setup-go@v7 + with: + # The server module has the higher `go` directive of the two, and a + # newer toolchain builds the CLI module fine. One install, both builds. + go-version-file: server/go.mod + cache-dependency-path: | + server/go.sum + cli/go.sum + + - name: Set up Node + # The server binary embeds the built dashboard via go:embed, so a + # release build needs the frontend toolchain even though nothing about + # the .app is JavaScript. + uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + cache-dependency-path: server/dashboard/package-lock.json + + - name: Build cix.app + env: + MAC_VERSION: ${{ steps.ver.outputs.mac }} + SERVER_VERSION: ${{ steps.ver.outputs.server }} + CLI_VERSION: ${{ steps.ver.outputs.cli }} + run: mac/scripts/build-app.sh + + - name: Verify the bundle + run: | + set -euo pipefail + APP=mac/dist/cix.app + + # Ad-hoc signatures do not satisfy Gatekeeper, but they do have to be + # internally consistent — --strict is what catches a bundle whose + # sealed resources drifted from what is on disk. + codesign --verify --strict --verbose=2 "$APP" + + # Each binary reports its own version. This is the check that catches + # a bundle assembled from stale server/dist artefacts. + "$APP/Contents/MacOS/cix-launcher" -report + + # Every @rpath dependency of llama-server must be present. Missing + # ones only fail at dyld load time, on the user's machine, with an + # abort — which is how the b10238 library-layout change was found. + missing=0 + for dep in $(otool -L "$APP/Contents/MacOS/llama/llama-server" \ + | awk '/@rpath\//{sub(/^.*@rpath\//,"",$1); print $1}'); do + if [ ! -e "$APP/Contents/MacOS/llama/$dep" ]; then + echo "::error title=Broken bundle::llama-server dependency not bundled: $dep" + missing=1 + fi + done + [ "$missing" -eq 0 ] + + - name: Build DMG + env: + MAC_VERSION: ${{ steps.ver.outputs.mac }} + run: mac/scripts/make-dmg.sh + + - name: Compute checksums + working-directory: mac/dist + # The self-updater (a later release) verifies a downloaded DMG against + # this file. Without a Developer ID signature it is the only integrity + # check there is, so it ships from the first release onward. + run: shasum -a 256 ./*.dmg > checksums.txt + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: cix-macos-arm64 + path: | + mac/dist/*.dmg + mac/dist/checksums.txt + + - name: Create GitHub release + if: github.event_name == 'push' + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + with: + name: "macOS app ${{ steps.ver.outputs.mac }}" + files: | + mac/dist/*.dmg + mac/dist/checksums.txt + generate_release_notes: true + # Server releases own the "latest" pointer — the Docker image is the + # primary deliverable of this project. Mac installs filter by the + # `mac/` tag prefix. + make_latest: "false" + body: | + ## cix for macOS — Apple Silicon + + Bundles `cix-server` ${{ steps.ver.outputs.server }}, the `cix` CLI + ${{ steps.ver.outputs.cli }}, and a Metal-accelerated `llama-server` + for local embeddings. Everything runs on your machine. + + ### Install + + 1. Download the `.dmg` below and open it. + 2. Drag **cix.app** onto **Applications**. + 3. Open it from Applications. + + ### First launch will be blocked — this is expected + + cix is open source and is **not** signed with a paid Apple Developer + certificate, so macOS refuses the first launch with "cix cannot be + verified". + + To allow it: **System Settings → Privacy & Security**, scroll to + **Security**, then click **Open Anyway** next to the message about cix. + + On macOS 15 and later the old right-click → Open shortcut no longer + works — you have to use System Settings. You only need to do this + once per installed version. + + ### Verify the download + + ```bash + shasum -a 256 -c checksums.txt + ``` + + Requires macOS 13 or later on Apple Silicon. There is no Intel build: + upstream llama.cpp publishes no macOS x86_64 release asset. diff --git a/cli/launcher/bundle_darwin.go b/cli/launcher/bundle_darwin.go new file mode 100644 index 00000000..4baae218 --- /dev/null +++ b/cli/launcher/bundle_darwin.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// bundle describes the .app this launcher is running from. +// +// The layout is fixed by mac/scripts/build-app.sh and by one hard runtime +// constraint: cix-server resolves its llama-server via +// filepath.Dir(os.Executable())/llama, so llama/ must sit next to cix-server, +// which means every executable lives in Contents/MacOS/ — Resources/ is not an +// option (codesign --verify --strict rejects executables there). +// +// cix.app/Contents/ +// MacOS/ cix-launcher cix-server cix llama/{llama-server,*.dylib} +// Resources/ AppIcon.icns menubar.png menubar@2x.png +type bundle struct { + Root string // …/cix.app + MacOS string // …/cix.app/Contents/MacOS + Resources string // …/cix.app/Contents/Resources + Server string // …/Contents/MacOS/cix-server + CLI string // …/Contents/MacOS/cix + LlamaDir string // …/Contents/MacOS/llama +} + +// errNotBundled is returned when the launcher runs from a plain directory +// (`go run ./launcher`, `go build -o /tmp/x`) rather than from inside a .app. +var errNotBundled = errors.New("not running from an application bundle") + +// locateBundle derives the bundle layout from the running executable. +// +// os.Executable() is resolved through symlinks deliberately: /usr/local/bin/cix +// is a symlink into the bundle (so it follows updates), and a future +// launcher symlink would otherwise resolve the layout relative to /usr/local/bin. +func locateBundle() (bundle, error) { + exe, err := os.Executable() + if err != nil { + return bundle{}, err + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + + macOS := filepath.Dir(exe) + contents := filepath.Dir(macOS) + root := filepath.Dir(contents) + + if filepath.Base(macOS) != "MacOS" || filepath.Base(contents) != "Contents" || !strings.HasSuffix(root, ".app") { + return bundle{}, errNotBundled + } + + return bundle{ + Root: root, + MacOS: macOS, + Resources: filepath.Join(contents, "Resources"), + Server: filepath.Join(macOS, "cix-server"), + CLI: filepath.Join(macOS, "cix"), + LlamaDir: filepath.Join(macOS, "llama"), + }, nil +} + +// isTranslocated reports whether macOS is running the bundle from a read-only +// randomised copy instead of where the user put it. +// +// Gatekeeper does this to any quarantined, unsigned-by-Developer-ID app opened +// from outside /Applications — typically straight out of ~/Downloads. The app +// appears to work, but the bundle path is a throwaway, so anything the launcher +// writes into it (or any launchd job pointing at it) breaks the moment the +// translocated copy is reaped. Detect it and say so, rather than half-working. +func isTranslocated(b bundle) bool { + return strings.Contains(b.Root, "/AppTranslocation/") +} + +// binaryVersion runs ` ` and returns its first line of output. +// +// Both bundled binaries answer a version flag and exit immediately +// (cix-server -v, cix --version), so the timeout is a guard against a wedged +// exec rather than an expected wait. +func binaryVersion(bin, flag string) string { + if _, err := os.Stat(bin); err != nil { + return "missing" + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, bin, flag).Output() + if err != nil { + return "unknown" + } + + line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n") + if line == "" { + return "unknown" + } + return line +} diff --git a/cli/launcher/dialog_darwin.go b/cli/launcher/dialog_darwin.go new file mode 100644 index 00000000..f23ec4b4 --- /dev/null +++ b/cli/launcher/dialog_darwin.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" +) + +// osascript is the dialog mechanism for the whole launcher. +// +// The menu-bar library (Phase 2) has no dialog API, and pulling in a second GUI +// toolkit to draw three alerts would double the bundle for no gain. AppleScript +// alerts are native, need no linkage, and survive the app being LSUIElement. +// +// Two rules, both load-bearing: +// - Every string that reaches AppleScript goes through quoteAS. Text here is +// not always ours — email addresses, server errors and file paths all end up +// in dialogs, and an unescaped quote turns a message into a syntax error at +// best and an injected statement at worst. +// - Every invocation is bounded. A modal that never returns would wedge the +// launcher with no window to close, because there is no Dock icon. + +// quoteAS renders a Go string as an AppleScript string literal. +func quoteAS(s string) string { + r := strings.NewReplacer(`\`, `\\`, `"`, `\"`) + // AppleScript has no escape for a literal newline inside a quoted string; + // concatenating `return` is the idiomatic way to express one. + parts := strings.Split(s, "\n") + for i, p := range parts { + parts[i] = `"` + r.Replace(p) + `"` + } + return strings.Join(parts, " & return & ") +} + +// alert shows a modal informational alert and blocks until it is dismissed. +func alert(title, message string) error { + script := fmt.Sprintf( + `display alert %s message %s as informational buttons {"OK"} default button "OK"`, + quoteAS(title), quoteAS(message), + ) + return runOsascript(2*time.Minute, script) +} + +func runOsascript(timeout time.Duration, script string) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "osascript", "-e", script) + out, err := cmd.CombinedOutput() + if err != nil { + if ctx.Err() != nil { + return fmt.Errorf("osascript timed out after %s", timeout) + } + return fmt.Errorf("osascript: %w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/cli/launcher/main_darwin.go b/cli/launcher/main_darwin.go new file mode 100644 index 00000000..1955e60f --- /dev/null +++ b/cli/launcher/main_darwin.go @@ -0,0 +1,86 @@ +package main + +import ( + "flag" + "fmt" + "os" + "strings" +) + +// cix-launcher — the executable behind cix.app. +// +// Scope of this build: the .app packaging pipeline (mac/v* tag → DMG). The +// menu-bar interface, launchd control and self-updater land in later releases. +// What ships today is a correctly signed bundle carrying cix-server, the cix +// CLI and a Metal-enabled llama-server, plus enough of a front end to tell the +// user what they have and prove the bundle is intact. +func main() { + showVersion := flag.Bool("v", false, "print version and exit") + report := flag.Bool("report", false, "print the bundle report to stdout instead of showing a dialog") + flag.Parse() + + if *showVersion { + fmt.Printf("cix-launcher %s\n", version) + return + } + + b, err := locateBundle() + if err != nil { + // Running outside a .app is a developer scenario (`go run ./launcher`), + // not a user-facing error — there is no bundle to report on, so say so + // on the terminal that is definitely attached and stop. + fmt.Fprintf(os.Stderr, "cix-launcher %s: %v\n", version, err) + os.Exit(1) + } + + if isTranslocated(b) { + msg := "macOS is running cix from a temporary read-only copy, so it cannot manage a server.\n\n" + + "Move cix.app to your Applications folder and open it from there." + if *report { + fmt.Fprintln(os.Stderr, msg) + os.Exit(1) + } + _ = alert("Move cix.app to Applications", msg) + os.Exit(1) + } + + body := bundleReport(b) + + // A .app has no terminal attached: writing to stdout on a double-click is + // indistinguishable from crashing. -report exists so the same information + // is scriptable for CI and for the DMG verification steps. + if *report { + fmt.Println(body) + return + } + + if err := alert("cix "+displayVersion(), body); err != nil { + fmt.Fprintf(os.Stderr, "cix-launcher: %v\n", err) + os.Exit(1) + } +} + +// bundleReport asks each bundled binary for its own version rather than +// printing what the build script believed it packaged. That difference is the +// point: it catches a bundle assembled from stale dist/ artefacts, which is the +// failure this pipeline is most likely to produce. +func bundleReport(b bundle) string { + var sb strings.Builder + + fmt.Fprintf(&sb, "Launcher: cix-launcher %s\n", version) + fmt.Fprintf(&sb, "Server: %s\n", binaryVersion(b.Server, "-v")) + fmt.Fprintf(&sb, "CLI: %s\n", binaryVersion(b.CLI, "--version")) + + if _, err := os.Stat(b.LlamaDir); err == nil { + fmt.Fprintf(&sb, "Embeddings: bundled llama-server (Metal)\n") + } else { + fmt.Fprintf(&sb, "Embeddings: MISSING — %s not found\n", b.LlamaDir) + } + + sb.WriteString("\nThe menu-bar interface is not part of this release. ") + sb.WriteString("To run the server now:\n\n") + fmt.Fprintf(&sb, " %s\n", b.Server) + sb.WriteString("\nSee doc/MACOS_APP.md for the required environment variables.") + + return sb.String() +} diff --git a/cli/launcher/main_other.go b/cli/launcher/main_other.go new file mode 100644 index 00000000..53bae654 --- /dev/null +++ b/cli/launcher/main_other.go @@ -0,0 +1,19 @@ +//go:build !darwin + +package main + +import ( + "fmt" + "os" +) + +// cix-launcher wraps macOS-specific plumbing (menu bar, launchd, osascript, +// codesign) and has no meaning on other platforms. It still has to *compile* +// everywhere: ci-cli.yml runs `go build ./...` and `go vet ./...` on +// ubuntu-latest, and a package with no buildable files there fails with +// "build constraints exclude all Go files" — which reads like a broken repo +// rather than a deliberate platform restriction. +func main() { + fmt.Fprintf(os.Stderr, "cix-launcher %s: macOS only — use `cix` and `cix-server` directly on this platform.\n", displayVersion()) + os.Exit(1) +} diff --git a/cli/launcher/version.go b/cli/launcher/version.go new file mode 100644 index 00000000..75efa34a --- /dev/null +++ b/cli/launcher/version.go @@ -0,0 +1,23 @@ +package main + +// version is stamped at build time with -ldflags "-X main.version=X.Y.Z". +// +// It tracks the `mac/vX.Y.Z` tag stream, NOT `server/v*` or `cli/v*`: the .app +// is released independently of the two binaries it bundles, and the self-updater +// (Phase 4) compares this value against the `mac/v*` releases on GitHub. +// +// The literal "dev" is meaningful, not just a placeholder — an unstamped build +// must never offer itself an "update" to a published release that is probably +// older than the working tree it was built from. +var version = "dev" + +// isDevBuild reports whether this binary was built without a release stamp. +func isDevBuild() bool { return version == "dev" || version == "" } + +// displayVersion renders the version for human-facing text. +func displayVersion() string { + if isDevBuild() { + return "development build" + } + return "v" + version +} diff --git a/doc/MACOS_APP.md b/doc/MACOS_APP.md new file mode 100644 index 00000000..643cac82 --- /dev/null +++ b/doc/MACOS_APP.md @@ -0,0 +1,153 @@ +# cix for macOS + +`cix.app` packages the cix server, the `cix` CLI and a Metal-accelerated +`llama-server` into a single drag-to-install application for Apple Silicon. + +> **Scope of the current release.** The packaging pipeline ships first: the app +> bundles and signs correctly, and every component reports its own version. The +> menu-bar interface — start/stop, provider status, dashboard link, password +> reset, autostart and self-update — lands in subsequent `mac/v*` releases. +> Until then the bundled binaries are run directly, as described below. + +## Requirements + +- macOS 13 (Ventura) or later +- Apple Silicon (M1 or newer) + +There is no Intel build. Upstream llama.cpp publishes a single macOS release +asset, `macos-arm64`; pairing an x86_64 server binary with an arm64 +`llama-server` produces a bundle that installs cleanly and then fails at the +first embedding, so `mac/scripts/build-app.sh` refuses to build one. + +## Install + +1. Download `cix--arm64.dmg` from the + [releases page](https://github.com/dvcdsys/code-index/releases) and open it. +2. Drag **cix.app** onto **Applications**. +3. Open it from Applications — not from the mounted disk image. + +Verify the download first if you like: + +```bash +shasum -a 256 -c checksums.txt +``` + +### The first launch is blocked, and that is expected + +macOS will refuse to open the app the first time, reporting that it "cannot be +verified" or "is damaged". Neither is true. cix is open source and is signed +**ad-hoc** rather than with a paid Apple Developer certificate, so it has no +Gatekeeper trust. + +To allow it: + +1. **System Settings → Privacy & Security** +2. Scroll down to **Security**. There is a message about cix being blocked. +3. Click **Open Anyway** and confirm. + +This is once per installed version. + +On macOS 15 and later, right-clicking the app and choosing **Open** no longer +works as a shortcut for this — the System Settings route is the only one. + +### Why not Homebrew? + +`brew install --cask` is not an option for this app. Homebrew ends support for +casks that fail Gatekeeper on 2026-09-01 and is removing the `--no-quarantine` +flag that used to work around it. Distributing through a cask would mean paying +for a Developer ID, which this project does not do. + +### Open the app from /Applications, not from the disk image + +If a quarantined app is opened from anywhere other than `/Applications`, +Gatekeeper runs it from a randomised read-only copy ("App Translocation"). It +appears to work, but the bundle path is temporary, so anything written into it — +or any background job pointing at it — breaks when the copy is discarded. The +launcher detects this and asks you to move the app rather than half-working. + +## What is inside + +``` +cix.app/Contents/ + Info.plist + MacOS/ + cix-launcher the app itself + cix-server indexing + search server + cix command-line client + llama/ Metal-accelerated llama-server + its libraries + Resources/ + AppIcon.icns menubar.png menubar@2x.png +``` + +Everything executable lives in `Contents/MacOS/`, including `llama/`. That is +not a style choice: `codesign --verify --strict` rejects executable code under +`Resources/`, and `cix-server` looks for `llama-server` at +`/llama`, so keeping them siblings means `CIX_LLAMA_BIN_DIR` +never has to be set. + +The versions of all three components are recorded in `Info.plist` under the +`CIXServerVersion`, `CIXCLIVersion` and `CIXLlamaVersion` keys, and each binary +also reports its own: + +```bash +/Applications/cix.app/Contents/MacOS/cix-launcher -report +``` + +## Running the server from the current release + +The server refuses to start against an empty database unless it is told which +admin account to create — it will not invent one silently. On first run: + +```bash +export CIX_DATA_DIR="$HOME/.cix/data" +export CIX_SQLITE_PATH="$HOME/.cix/data/cix.db" +export CIX_PORT=21847 +export CIX_BOOTSTRAP_ADMIN_EMAIL="you@example.com" +export CIX_BOOTSTRAP_ADMIN_PASSWORD="choose-a-strong-one" + +/Applications/cix.app/Contents/MacOS/cix-server +``` + +The dashboard is then at , and you will be +required to change that bootstrap password at first login. On later runs the two +`CIX_BOOTSTRAP_ADMIN_*` variables are no longer needed. + +Cold starts are slow and quiet: loading the embedding model takes 30–60 seconds +warm and can take several minutes the first time, and the ready banner only +appears at the end. A server that has not answered yet is usually still starting. + +To use the CLI against it, put it on your `PATH` as a **symlink**, so it keeps +pointing at the current bundle after an update: + +```bash +ln -sf /Applications/cix.app/Contents/MacOS/cix /usr/local/bin/cix +``` + +### Forgotten password + +`cix-server` can reset a password offline, without the server being stopped: + +```bash +/Applications/cix.app/Contents/MacOS/cix-server -reset-password you@example.com +``` + +It prints a generated temporary password. Point it at the same `CIX_DATA_DIR` / +`CIX_SQLITE_PATH` the server uses, or it will not find the database. + +## Building it yourself + +```bash +MAC_VERSION=0.1.0-dev mac/scripts/build-app.sh +MAC_VERSION=0.1.0-dev mac/scripts/make-dmg.sh +``` + +See [`mac/README.md`](../mac/README.md) for the build pipeline, the signing +order and why each step is the way it is. + +## Uninstalling + +```bash +rm -rf /Applications/cix.app +rm -f /usr/local/bin/cix # if you created the symlink +rm -rf ~/.cix # config, database and index data +``` diff --git a/mac/Info.plist.in b/mac/Info.plist.in new file mode 100644 index 00000000..10208a3b --- /dev/null +++ b/mac/Info.plist.in @@ -0,0 +1,70 @@ + + + + + + CFBundleName + cix + CFBundleDisplayName + cix + CFBundleIdentifier + com.cix.launcher + CFBundleExecutable + cix-launcher + CFBundleIconFile + AppIcon + CFBundlePackageType + APPL + CFBundleSignature + ???? + CFBundleInfoDictionaryVersion + 6.0 + CFBundleShortVersionString + @SHORT_VERSION@ + CFBundleVersion + @BUNDLE_VERSION@ + LSMinimumSystemVersion + 13.0 + LSUIElement + + LSApplicationCategoryType + public.app-category.developer-tools + NSHighResolutionCapable + + NSHumanReadableCopyright + MIT licensed. https://github.com/dvcdsys/code-index + CIXServerVersion + @SERVER_VERSION@ + CIXCLIVersion + @CLI_VERSION@ + CIXLlamaVersion + @LLAMA_VERSION@ + + diff --git a/mac/README.md b/mac/README.md new file mode 100644 index 00000000..bef76f7f --- /dev/null +++ b/mac/README.md @@ -0,0 +1,117 @@ +# mac/ — the cix.app build pipeline + +Everything needed to turn the repo into a signed, drag-to-install macOS app. +User-facing installation and usage documentation lives in +[`doc/MACOS_APP.md`](../doc/MACOS_APP.md); this file is about the build. + +``` +mac/ + Info.plist.in bundle metadata template (@PLACEHOLDER@ tokens) + Resources/ icons — placeholders, safe to replace + scripts/build-app.sh assemble + sign mac/dist/cix.app + scripts/sign-app.sh ad-hoc codesign, innermost code first + scripts/make-dmg.sh wrap the app in a drag-to-Applications DMG + scripts/make-placeholder-icons.py regenerate the placeholder artwork + dist/ build output (gitignored) +``` + +## Build + +```bash +MAC_VERSION=0.1.0-dev mac/scripts/build-app.sh +MAC_VERSION=0.1.0-dev mac/scripts/make-dmg.sh +``` + +`build-app.sh` reads these environment variables, all optional locally and all +set explicitly by CI: + +| Variable | Meaning | +|---|---| +| `MAC_VERSION` | version of the app itself, from the `mac/vX.Y.Z` tag | +| `SERVER_VERSION` | stamped into `cix-server` (default: nearest `server/v*` tag) | +| `CLI_VERSION` | stamped into `cix` (default: nearest `cli/v*` tag) | +| `OUT_DIR` | build directory (default `mac/dist`) | +| `SKIP_SERVER_BUILD` | `1` reuses an existing `server/dist` bundle — much faster when iterating on the launcher | +| `SKIP_SIGN` | `1` skips codesign; the result **will not run** | + +Release builds pass the versions in rather than deriving them, because +`git describe` is not reliable on this repo: the three tag streams interleave, +and at least one shipped server tag sits on a commit reachable from no branch. +CI additionally fails the build outright if no `server/v*` and `cli/v*` tag are +reachable from the tagged commit. + +## Three tag streams + +`server/v*`, `cli/v*` and `mac/v*` are released independently. The app version +describes what the app does; the versions of the two binaries it bundles are +stamped separately and recorded in `Info.plist` as `CIXServerVersion`, +`CIXCLIVersion` and `CIXLlamaVersion`. + +Cut `mac/v*` tags on `main`. `git describe` walks ancestors, so a tag cut on +`develop` resolves to whatever `server/v*` happens to be reachable from there, +which has been several releases behind what actually shipped. + +## Signing: ad-hoc, bottom up + +There is no paid Apple Developer membership, so there is no identity to sign +with and nothing to notarize. Ad-hoc signing (`codesign --sign -`) is still +mandatory — on Apple Silicon the kernel refuses to run an unsigned executable at +all. It gets the code running; it does not satisfy Gatekeeper, hence the +first-launch instructions shipped in the DMG. + +`sign-app.sh` signs dylibs first, then each executable, then the bundle. Two +things about it are not stylistic: + +- **`xattr -cr` runs first, on every build.** `server/Makefile` records the + failure this avoids: on macOS 26, amfid `SIGKILL`s an ad-hoc-signed binary + whose linked dylibs carry a stale signature or a `com.apple.provenance` + extended attribute — with *empty stderr*. The process simply dies. Every `cp` + into the staging tree recreates those conditions. +- **No `--deep`.** Apple deprecated it, and it is unreliable for a bundle that + carries four executables and ~35 dylibs directly in `Contents/MacOS` rather + than as nested `.app`/`.framework` bundles. Explicit bottom-up ordering is + verifiable at each step. + +`--options runtime` is deliberately not used: the hardened runtime only pays off +alongside notarization, and it risks breaking `llama-server`. + +## DMG + +A plain UDZO image containing `cix.app`, an `/Applications` symlink and a +`READ ME FIRST.txt` with the Gatekeeper instructions. No custom window layout: +positioning icons requires a read-write image driven through Finder over +AppleScript in a real GUI session, which is flaky-to-impossible on a CI runner. +A pre-baked `.DS_Store` can be added later without changing the script. + +The app is copied with `ditto`, not `cp -R`. `cp -R` drops extended attributes +and signature-relevant metadata, which turns a verified bundle into one that +fails `codesign --verify --strict` after the round trip through the image. + +## Icons + +`mac/Resources/` holds three committed files: + +| File | Size | Notes | +|---|---|---| +| `menubar.png` | 18×18 | template image — black + alpha only | +| `menubar@2x.png` | 36×36 | template image | +| `AppIcon.icns` | — | full icns | + +They are placeholders. Replacing them means dropping in new files with the same +names and sizes; no code changes anywhere. + +The menu-bar images **must** be template images. macOS recolours template images +to match the menu bar (dark mode, tinting, inactive state) and reads only their +alpha channel — a coloured PNG renders as a solid smudge. + +`scripts/make-placeholder-icons.py` regenerates the current placeholders. It is +stdlib-only and is never run by a build; it exists so the placeholders are +reproducible rather than mystery binaries. + +## Apple Silicon only + +Upstream llama.cpp publishes one macOS asset, `macos-arm64`, and +`server/scripts/fetch-llama.sh` refuses anything else. An x86_64 server binary +bundled with an arm64 `llama-server` assembles cleanly, signs cleanly, and dies +at the first embedding request — so `build-app.sh` checks `uname -m` and stops +with an explanation instead. diff --git a/mac/Resources/AppIcon.icns b/mac/Resources/AppIcon.icns new file mode 100644 index 0000000000000000000000000000000000000000..7d6e28c5dc67132004d305323338f821c03fdb7b GIT binary patch literal 73062 zcmeFa2UJwe+Ag?z(?A1~qr@gk4k|%nizt$j9HkW{BRNRyRuL2l1`x>xBnl{!1zJH- zlqiUTWRxU1$DV4wbLN~m=fD4~S?jF1_s;OG5BIKJ`K|Y@dh4lY_jYx>;s+skoLn8{ zjzSRG`YmHaZCYwhY6ya8b> zJM!mM-SoU^tu7vh>@A5FUE}W)+TBIDY#k9X!G>iklbSz-B~bcVH!!ZA-(ZHty7AS+ z_B7uH8~wREUdIYGCAY3vz-O2EK8{dv9Ho#goKp_Rvq)?Ct!lwb_ZAH2zQf$wt* z{a45dtC%VWkCV|q<|`Yq5cNk+3uNbVah$8WdtCu`Sv@AOSr&d#dso3Pc?*dP5$Sct9oQ6SzX>+?Eam&I%T-|ZQmyH{~M*k7|M zBd@m5lR^+Tgp)vRP+k6Gi)O(NEsG$+BskL=SP(B?qCc=?pD>~O-VPPLF+XatR55zZ(D|_t4!L2>dUy>83{uoC7kg(wPp6f&u8u;~*TMQOa zYrSi_a@|Ty>)q2MGJST(OZ;AXAxcDHWjz?l(!gw*i<)dYibRgQriIt1!UBCAZcBH* z{nhoJ=IuB7(Xn!+K|{Z|-9rRdS)r?^IXJHE97V3yUIemX9A0w2<27PeT1xJcm_D2~ zAEUvYZD*J$f@rz>NZi597j7~B2A#@pR9X}}YJ&2OvGLq*Lhp@tkr3PtnDL>J1bwqs_MzIXx?>{)J_yCanWRRe>Ce$uXiqx zHn#41^C$8V)r}JdX)I@{g~;#08OFnW_e#iq&sbArlZ;L0w4qk9Cia@EqpTvZTh9N& z>~SC&Zr|)1)h~Pof&YlvrSL>cxsv(GSa;W1}v&i$5?Gxa2o6+W3C zaJlr7Ie)uHjR~`zm}Z)^WY1|?>VZ7|=`#!0Js#BLF={DlJ}s`%IiGDE)#BGf&*Dil z{iNf;)F1#v$+?*aFHP|1?H@{b4rqF}tyRK+$Jo>QB!@hZc)|t{*C~e3$$Ci8o?cK^FOOWeSc;&%6fp zdy#8ja^v(O6e&@BhX?`~pO#yxkoT{rGhQJGvQm_B7m9EQs@pl;VKB2cPku;J)9#RT zwa*kH{lPa{h)g~CJd97Vn)q-Us>@XIkiY!e>-w44Tel$(Lg^Jdm<3~3pHo>Zfa_U{ z>bkBoo6W{7jPQ8&+jeVhAt&+7IYvTOQI%aCR+cTJ-H5IepAN|dxiSS{c#jh3#ibC`b$ zO2|rMSNZY#y+g>Ft7je!nw}fe0t>I_Q@NMRVVH+4_|H&hQ(NDj6eMS0yuzw)3YmX2 z(c%-s#dsHDQ{g<#WPz=-2(25o4Ztgl!)wW-kpKgCm?Lj{RBG3q7|!yGJIiALWD^p?{$-66Of zqrnoSX$+*MX&-wVg35I@5GukN)`jjsdfnq?Nu#&=%^!Ub zWoI|5M{_S1W|}@Jx@U!r6SmA>{(j%&u5`kGWDM)|=0M&M`91TO8$+*_ zrYx1WdfizQ>KTbm$Hod8RE)XEh6}vhmTE6QT*nP;n?zVJLfsA%Qx+9eNSYVd8$YsTq4#!1<^CyoD747gz6wDM;`?HOy(cTe?(PYMWF7K#N);;m z>rBlqn}}WM3KOcSNm=n5Tm`1N!rp*wsV~$8%l;C!0$7Ad{d-{$BWq|Q@j^pMznYrS zzhR)j^ZLo`DQCkI=a`}A*D{n&3{7SZ6UAJXAOF@?rXs9f=%}%CZ4NI{h=G{37U_7y zgbP+3^0q%*2vw&DtD2ykL)>Maxu~YdAoX#Fz-T@2>Pp;^CZST99~^|^ubGZ<_EiX! zj@}DY2B+{~pj!dl#GI2yJ(ntBomEel+uJ8gVOd6-cs^2CEW zEQ~wFax(8VOCMEg*y~q?rD6$Wzs-Fonwnyw4`+rvB(y2%74NJK*k4e;N)J<3U*P7s z?|5>~^m?C5hq^3x?At##HvpEW@d#TkxOE>Q@{Js>b zJk-33fvZLzUOb}ib>b1UMA7>6QViB4*{5tgI<0E%Y=_mi%RUhW6V%ex5XA{MEri_h zeE)IrAvY7kouvPpCGIR*4Nru|KMg1w#cL5r>+wH41G!@m#PsmrJp+<&kAU_)13TY5 zCsOD?upvM||L_b*H9(N_G^Y*(F>e0dGdP#W-E+9PxsUR(PF*;|6HTjg_hw9sn=mK& z-Fe-p`|LV3lajUg1mx*m^+)QzpYPi8eSC{nxZ?LoS{Bc@U1`(TuioO4rT>*$b9G5X zwD|V7YaQ-m_loX z{t$Kckhr8lIWoZt#)A0fGw{j|SElT}W~>Ab*~=kU3ykbqzy6Nm|!6Yi0A$#JL~|_Z)Oe z#?-uSsHkfd9qvwpTo`yg=!tik~KH`Itk35N-6?J*x4aGC>-bbU2jo?%UkEF{sKLnwHPth=<9jV)j??Mvlch?{F=BkGB z_VZ_RPyW&rlhtL0$^t7`VqIC_MMhC6c3nou^o{HCNkMgu4nfod&Fs$4w}#^$3P4W3 z-VP^sp^>8^#=3^c25PKsudK!!zDk)%9;7aR_7J(hW`*4cR5nWv4OLPFT2=%T&b4&QRUNU)qEk>m&&es>9p_EQtg1Dq@BwV8g}XA{>Pm*mhrE(wGTeBYyH|?}`BRM=Bu#o8v?0)EmY^AMXis_|jz`w*4tO z<#2UM=o4R>BfN6NcZl+HQX5p$~$ysWhZ# zqNq!zUq4MT_Ruone7wb|1^uDM$X>sR|C&?(@n%S4&F2aNl7;K`T@Ky6bSeRe;x!|g znf*jA2Frnux%f=3DC?>xjWBd|HORjCmUl&v{aUHt9`eom7%oK2WtWGMAAOtNPzB#k zGKM?8^y|2*o!{!;E1zqg*kz0nvPk9s7|n!ZsOX?TqD#-fSCAOu^ivF$1$*!|-d^`jD|Jz2 zelEB~EIVQ(Jm|D**%hDypFJrG@rGQ|c#3CL$n1nf_|y+@lM*%QZqao`{R`du;Bm+cg_(2PGi4g<6`dG>f z(}5_u^gFj0J__KVOLs4*A-Nea5Vz7{X8y#}5aOdiV+cwKjf0fDkBVrbV<9;4^vzWU zZw!R+9=@c11H*H?6+N!ls1%%I zC11uw>c#o&QlC`xexw+S6cyl72Kb>{GSP#)*oHcA8Ij*ZUQWX(@nfGlD#I3Q# zRbTCUTlp5@YuT|AJaLUTM1~Ea<3DnLRStcRGiB3%q$i>Kx1NQ+tk))r7IBenJNfHID0uFV)B0Ij-*Pq zbpBH4?aODMM2?+-=Eg%J6S3((ecclSU4;06F%H-6bjf7JSpO2Wt$z24`f-&)EhBW3 zcmbw=Oxt*(1ehe|JVA*LIju#AgBG&ZWG`%MY|qH%C?kKOApBtwQ5pLPMg3E+@6mu- z7rsTsRzqXxMk>kk5ndaV-#J;q(^8iq{d5Wzu2340S2dvw`&n^zzFR%VdKUG`LqoR8 zdnLZR57`F_L(3@4h0fwmR@jzf@EzSBU5Mpbr0UfI*_2HBqHUx}fi@ut`l!~P3c0A` z?(yO0c&^VDzkM*x=zRq8?R?wV;Pgs7*D5JRJ>Zgb?o*$X4x}A5&XAlpbVo-Eo6-4v zJbFx-Klx3q9)iJ-e7;U2M5r@IqNR_@lhwjm;nm{3K*}=p_+rE}Tu7Dt$gg=0xaR>$t~^YC_b!pAd|deK2YCXQ)41 z$%T)L&0Q0-XFjXG5}+`pj#~S1KbMK>`ugY|%Wy8eM#`I|E}eTr=R_vOI+m)#=Ig3> z5H3|3%i{>9Ay*3j-N+5hx>$!_vtgB*l(z)bt`Sr;6PCM}@Iwver1dD@%;eK}H6+aZ zFc&gvZ%f>fZVWH&yl;C&kA;~xRt3?am1vd)<0IhPx8{T{5Opa(&mplN+O|a*IJ5|k z9$@XrSM6P+j8dP`!n_>u^qB3X<^k>VW9UHs@DhDJ3NY9?B11 zXByK*k|wFW&lg94Zn93X^B1L={WN4{p0>0DEx!J z%J-d{vTT2*{+kshqY93_3$h+QtFqAYwWN->>U3%$CuwtjKaPjb7bju?&+;M&2Fj8%$ruu1w7m+13AgQm`_gAdeOGcfk8`IH1Vj0*h z<)iGkWoFH8;4TV0sYM29UZ4?!CNvM1+o*6(^TUPbmdfm3GW^KDP-;gd2A#8y>Gha@ z>#)}{JAmDN7F3bLxh6p&kiJQuEWuBnoiuyfx8m$7->cfUXP)o1+j>x?n?Tk>pY zd+!_?-J}ob(m*(8Tx&sx@POt%UX1EIe`-2{~{vlb; zYN;7w(t03t&NM>0-)u=&hD21!jj{T`|{$x*eJEMh3_|x7ho21wAaNCN1os^3(D8 zrtbU;;kTxvF^$T74XqKf&-8A{SY=E)v>!$4f{oPdbeIF!W18q77(fle4O&>6d9c5| zg}ZnDQ-hhw_KaJ_D>%`{Q?ujsWi?+9ar7-fOp6vWW zp~wVn%z}^S*@zL|i(RwXG~q&=RqIUSQ*xP4n^FdPL55G}84G4@UylB9UIlSBPM+W8 z`>Pw+zJg>i1}h1C{H&~(`K^*)eMx=Pn1)>aL&r5rd)>a0Vm}IWRM1Kl=1NYL+s%w> zLR)Gs7iBNZr&$OiRbD8B^qd!ML-~I>-{WUdd&^6w;nL?Tw^a}ymptYlJnpQl`%(Gj z;wshaE&Y!B!OG$D);Ip=!yNliOClehNcOUzLOODOtAGRs{R5VgRXTvB4q&MRSn2?l zI)J4PV5tLG>HwDdkKtqou+#x8bpT5pz)}aW)B!AY081UfQU|cq0W9@@q5b@Ku+(t~ zf_YDZHzN`uNHz)w$zFq?tY{*Xg#+PRL^4@Grv?`g`hR=@*&$hwalbfF?0)fy5U2

{q_u42dXlNCG?&j~0W(K~p5e#G>pd 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 VdCM0Qa3J_X!9O$?FT&5M{{Zfb*meK_ literal 0 HcmV?d00001 diff --git a/mac/Resources/menubar.png b/mac/Resources/menubar.png new file mode 100644 index 0000000000000000000000000000000000000000..c1014ebb34c759d1c7963bba82444a3958215708 GIT binary patch literal 123 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+1|-AI^@Rf|Cr=m0kcwN$2@-}6Yy|$R3(Y^C zexY8-&@X}E2(L>L^RAYOOaj&q4yZJKuQ-(aw?V?ibbTOifbOrA(-c-tb8Qo3V0e@7 V<0!NBu_w?p22WQ%mvv4FO#ldBCY}HQ literal 0 HcmV?d00001 diff --git a/mac/Resources/menubar@2x.png b/mac/Resources/menubar@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..995310b27a2a10f0a832de91248baa18df5ee8c1 GIT binary patch literal 188 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbBnmt_{Ln>~)ogOH7K!L}3VpEH! z16NQ%3(FFPM4<@vg}QfBqwmH3D^~q-X$r?r$;c2h?dY>17rBDA`lPXXo38xSuyE1b zOFSmVYa8D!+8B9PILB$v9qu)nqGC4^|6EFMS$TM8`mN`@2W=%&R&d_DxP0L&_7KPB m)f);_<=^o%*@$pfs*3Y#8|(0UXVe3o!{F)a=d#Wzp$Py}=tS=T literal 0 HcmV?d00001 diff --git a/mac/scripts/build-app.sh b/mac/scripts/build-app.sh new file mode 100755 index 00000000..e3e6f211 --- /dev/null +++ b/mac/scripts/build-app.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# build-app.sh — assemble mac/dist/cix.app from the three Go binaries plus the +# bundled llama-server. +# +# Usage: mac/scripts/build-app.sh +# +# Environment (all optional; CI sets the versions explicitly): +# MAC_VERSION version of the .app itself, from the mac/vX.Y.Z tag +# SERVER_VERSION stamped into cix-server (default: nearest server/v* tag) +# CLI_VERSION stamped into cix (default: nearest cli/v* tag) +# OUT_DIR build directory (default: mac/dist) +# SKIP_SERVER_BUILD "1" reuses an existing server/dist bundle +# SKIP_SIGN "1" skips codesign (leaves an unrunnable bundle; debug only) +# +# Versions are passed in rather than derived here because `git describe` cannot +# be trusted on this repo: the tag streams interleave, and server/v0.12.8 sits on +# a commit reachable from no branch. A release must state what it is building. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +OUT_DIR="${OUT_DIR:-$REPO_ROOT/mac/dist}" +APP="$OUT_DIR/cix.app" +SERVER_BUNDLE="$REPO_ROOT/server/dist/cix-darwin-arm64" + +# --- host check ------------------------------------------------------------- +# Upstream llama.cpp publishes one macOS asset, macos-arm64, and +# server/scripts/fetch-llama.sh hard-refuses anything else. Building the Go +# binaries for x86_64 and pairing them with an arm64 llama-server produces a +# bundle that assembles cleanly, signs cleanly, and dies at first embedding — +# so refuse here instead, where the message can say why. +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "build-app: macOS only (got $(uname -s))" >&2 + exit 1 +fi +if [[ "$(uname -m)" != "arm64" ]]; then + echo "build-app: Apple Silicon only — upstream llama.cpp ships no macOS x86_64 asset (got $(uname -m))" >&2 + exit 1 +fi + +# --- versions --------------------------------------------------------------- +describe_or_dev() { + local pattern="$1" prefix="$2" v + v="$(git describe --tags --match "$pattern" 2>/dev/null | sed "s|^$prefix||")" || true + printf '%s' "${v:-0.0.0-dev}" +} + +MAC_VERSION="${MAC_VERSION:-dev}" +SERVER_VERSION="${SERVER_VERSION:-$(describe_or_dev 'server/v*' 'server/v')}" +CLI_VERSION="${CLI_VERSION:-$(describe_or_dev 'cli/v*' 'cli/v')}" +LLAMA_VERSION="$(sed -n 's/^LLAMA_VERSION[[:space:]]*?=[[:space:]]*//p' server/Makefile | head -n1)" +if [[ -z "$LLAMA_VERSION" ]]; then + echo "build-app: could not read LLAMA_VERSION from server/Makefile" >&2 + exit 1 +fi + +echo "build-app: app=$MAC_VERSION server=$SERVER_VERSION cli=$CLI_VERSION llama=$LLAMA_VERSION" + +# --- 1. server + bundled llama --------------------------------------------- +# LLAMA_STRICT=1: a release must not accept an unpinned upstream asset. See the +# strict-mode notes in server/scripts/fetch-llama.sh. +if [[ "${SKIP_SERVER_BUILD:-0}" == "1" ]]; then + echo "build-app: SKIP_SERVER_BUILD=1 — reusing $SERVER_BUNDLE" + [[ -x "$SERVER_BUNDLE/cix-server" ]] || { echo "build-app: no prebuilt server at $SERVER_BUNDLE" >&2; exit 1; } +else + # `make bundle` → `build` → `dashboard-build` runs npm scripts but never + # installs. On a clean checkout (which is every CI run) that surfaces as a + # missing-binary error from npm rather than "you need to install deps". + if [[ ! -d server/dashboard/node_modules ]]; then + echo "build-app: installing dashboard dependencies" + make -C server dashboard-deps + fi + make -C server bundle SERVER_VERSION="$SERVER_VERSION" LLAMA_STRICT=1 +fi + +# --- 2. cix CLI ------------------------------------------------------------- +# -w without -s: govulncheck -mode=binary needs the Go symbol table. Same +# reasoning, and same flags, as .github/workflows/release-cli.yml. +echo "build-app: building cix CLI" +(cd cli && go build \ + -trimpath \ + -ldflags "-w -X 'github.com/dvcdsys/code-index/cli/cmd.Version=${CLI_VERSION}'" \ + -o "$OUT_DIR/stage/cix" .) + +# --- 3. launcher ------------------------------------------------------------ +echo "build-app: building cix-launcher" +(cd cli && go build \ + -trimpath \ + -ldflags "-w -X 'main.version=${MAC_VERSION}'" \ + -o "$OUT_DIR/stage/cix-launcher" ./launcher) + +# --- 4. assemble ------------------------------------------------------------ +# Rebuild the bundle from scratch. `cp` merges into an existing tree, so an +# incremental assembly accumulates artefacts from every previous build — the +# exact failure server/Makefile's `bundle` target had to fix for llama/. +echo "build-app: assembling $APP" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" + +# Everything executable lives in Contents/MacOS, including llama/. Two reasons: +# codesign --verify --strict rejects executable code under Resources/, and +# cix-server resolves llama-server at filepath.Dir(os.Executable())/llama — so +# keeping them siblings means CIX_LLAMA_BIN_DIR never has to be set. +cp "$SERVER_BUNDLE/cix-server" "$APP/Contents/MacOS/cix-server" +cp -R "$SERVER_BUNDLE/llama" "$APP/Contents/MacOS/llama" +cp "$OUT_DIR/stage/cix" "$APP/Contents/MacOS/cix" +cp "$OUT_DIR/stage/cix-launcher" "$APP/Contents/MacOS/cix-launcher" +cp mac/Resources/AppIcon.icns mac/Resources/menubar.png mac/Resources/menubar@2x.png \ + "$APP/Contents/Resources/" + +sed \ + -e "s|@SHORT_VERSION@|${MAC_VERSION}|g" \ + -e "s|@BUNDLE_VERSION@|${MAC_VERSION}|g" \ + -e "s|@SERVER_VERSION@|${SERVER_VERSION}|g" \ + -e "s|@CLI_VERSION@|${CLI_VERSION}|g" \ + -e "s|@LLAMA_VERSION@|${LLAMA_VERSION}|g" \ + mac/Info.plist.in > "$APP/Contents/Info.plist" + +# Catch an unsubstituted token before it ships as a literal @TOKEN@ version. +if grep -q '@[A-Z_]*@' "$APP/Contents/Info.plist"; then + echo "build-app: unsubstituted token left in Info.plist:" >&2 + grep -n '@[A-Z_]*@' "$APP/Contents/Info.plist" >&2 + exit 1 +fi +plutil -lint "$APP/Contents/Info.plist" + +# Classic-era metadata. LaunchServices no longer requires PkgInfo, but codesign +# and some archive tooling still expect it beside Info.plist, and it costs 8 bytes. +printf 'APPL????' > "$APP/Contents/PkgInfo" + +rm -rf "$OUT_DIR/stage" + +# --- 5. sign ---------------------------------------------------------------- +if [[ "${SKIP_SIGN:-0}" == "1" ]]; then + echo "build-app: SKIP_SIGN=1 — bundle is UNSIGNED and will be killed on launch" +else + mac/scripts/sign-app.sh "$APP" +fi + +echo "build-app: ready — $APP" +du -sh "$APP" | sed 's/^/build-app: size /' diff --git a/mac/scripts/make-dmg.sh b/mac/scripts/make-dmg.sh new file mode 100755 index 00000000..5a4ccfcd --- /dev/null +++ b/mac/scripts/make-dmg.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# make-dmg.sh — wrap a built cix.app in a drag-to-Applications disk image. +# +# Usage: mac/scripts/make-dmg.sh [path/to/cix.app] +# +# Environment: +# MAC_VERSION version string for the volume name and filename (default: dev) +# OUT_DIR output directory (default: mac/dist) +# +# No custom window layout. Positioning icons in a DMG means creating a +# read-write image, mounting it, driving Finder over AppleScript to set the +# background and icon coordinates, then converting to compressed read-only. +# That needs a real GUI session; on a CI runner it is flaky at best. A plain +# UDZO image with the app and an /Applications symlink conveys the same +# instruction and always builds. A pre-baked .DS_Store can be added later +# without touching this script. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OUT_DIR="${OUT_DIR:-$REPO_ROOT/mac/dist}" +APP="${1:-$OUT_DIR/cix.app}" +MAC_VERSION="${MAC_VERSION:-dev}" +DMG="$OUT_DIR/cix-$MAC_VERSION-arm64.dmg" + +if [[ ! -d "$APP" ]]; then + echo "make-dmg: no such bundle: $APP" >&2 + exit 1 +fi + +STAGE="$(mktemp -d -t cix-dmg-XXXXXX)" +trap 'rm -rf "$STAGE"' EXIT + +# ditto, not cp -R: it preserves extended attributes and, critically, the +# signature-relevant metadata. cp -R silently drops some of it, which turns a +# verified bundle into one that fails `codesign --verify --strict` after the +# round trip through the image. +ditto "$APP" "$STAGE/cix.app" +ln -s /Applications "$STAGE/Applications" + +# Gatekeeper instructions have to travel with the download. Without a Developer +# ID there is no way to make a first launch quiet, and on macOS 15+ the old +# right-click → Open escape hatch no longer works — the user has to go through +# System Settings. Someone who does not know that concludes the app is broken. +cat > "$STAGE/READ ME FIRST.txt" < Privacy & Security + - Scroll to Security. There will be a message about cix being blocked. + - Click "Open Anyway", then confirm. + + You only have to do this once per installed version. + + (On macOS 15 and later, right-clicking the app and choosing Open no longer + works as a shortcut for this — use System Settings.) + +WHAT IS INSIDE + cix.app/Contents/MacOS/ + cix-launcher the app itself + cix-server the indexing + search server + cix the command-line client + llama/ a Metal-accelerated llama-server for local embeddings + + The app runs entirely on your machine. Nothing is uploaded anywhere unless + you configure an external embedding provider yourself. + +DOCS + https://github.com/dvcdsys/code-index/blob/main/doc/MACOS_APP.md +EOF + +echo "make-dmg: creating $DMG" +rm -f "$DMG" +hdiutil create \ + -volname "cix $MAC_VERSION" \ + -srcfolder "$STAGE" \ + -fs HFS+ \ + -format UDZO \ + -imagekey zlib-level=9 \ + -quiet \ + "$DMG" + +echo "make-dmg: verifying image" +hdiutil verify "$DMG" + +# Ad-hoc sign the image too. It buys no Gatekeeper trust, but it makes +# tampering after publication detectable with codesign rather than only by +# checksum, and it costs one command. +codesign --force --sign - "$DMG" + +echo "make-dmg: ok — $DMG" +shasum -a 256 "$DMG" diff --git a/mac/scripts/make-placeholder-icons.py b/mac/scripts/make-placeholder-icons.py new file mode 100755 index 00000000..0df2c8c3 --- /dev/null +++ b/mac/scripts/make-placeholder-icons.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Generate the placeholder icon set for cix.app. + +These are placeholders. The three files this writes into mac/Resources/ are +committed, so neither CI nor a normal build ever runs this script — replacing +the artwork means dropping in new files with the same names and sizes, with no +code change anywhere. + + mac/Resources/menubar.png 18x18 template image (black + alpha only) + mac/Resources/menubar@2x.png 36x36 template image + mac/Resources/AppIcon.icns full icns, built via iconutil + +"Template image" is a hard requirement for the menu bar, not a style choice: +macOS recolours template images to match the menu bar (dark mode, tinting, +inactive state) and only looks at the alpha channel. A coloured PNG there +renders as a solid smudge. So the menu-bar glyphs below write pure black +pixels and vary only alpha. + +Pure stdlib (zlib + struct) — deliberately no Pillow, so the script runs on a +clean machine. iconutil ships with macOS. +""" + +from __future__ import annotations + +import pathlib +import shutil +import struct +import subprocess +import sys +import tempfile +import zlib + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +RESOURCES = REPO_ROOT / "mac" / "Resources" + +# Slate background for the app icon; the glyph is white on top of it. +BG = (30, 41, 59) +FG = (241, 245, 249) + +# Sizes iconutil expects in an .iconset directory. Anything missing is simply +# absent from the icns, which macOS then scales badly — so emit the full set. +ICONSET = [ + ("icon_16x16.png", 16), + ("icon_16x16@2x.png", 32), + ("icon_32x32.png", 32), + ("icon_32x32@2x.png", 64), + ("icon_128x128.png", 128), + ("icon_128x128@2x.png", 256), + ("icon_256x256.png", 256), + ("icon_256x256@2x.png", 512), + ("icon_512x512.png", 512), + ("icon_512x512@2x.png", 1024), +] + + +def write_png(path: pathlib.Path, width: int, height: int, pixels: bytearray) -> None: + """Write an 8-bit RGBA PNG. `pixels` is width*height*4 bytes, row-major.""" + + def chunk(tag: bytes, data: bytes) -> bytes: + return ( + struct.pack(">I", len(data)) + + tag + + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + ) + + # Filter byte 0 (None) per scanline — no filtering, smallest possible code. + raw = b"".join( + b"\x00" + bytes(pixels[y * width * 4 : (y + 1) * width * 4]) + for y in range(height) + ) + ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) + path.write_bytes( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(raw, 9)) + + chunk(b"IEND", b"") + ) + + +def blend(pixels: bytearray, width: int, x: int, y: int, rgb, alpha: float) -> None: + """Source-over one pixel. Callers pass fractional alpha for antialiasing.""" + if alpha <= 0: + return + alpha = min(alpha, 1.0) + i = (y * width + x) * 4 + dst_a = pixels[i + 3] / 255.0 + out_a = alpha + dst_a * (1 - alpha) + if out_a <= 0: + return + for c in range(3): + src = rgb[c] / 255.0 + dst = pixels[i + c] / 255.0 + pixels[i + c] = int(round((src * alpha + dst * dst_a * (1 - alpha)) / out_a * 255)) + pixels[i + 3] = int(round(out_a * 255)) + + +def rounded_rect(pixels, width, x0, y0, w, h, radius, rgb, samples=4): + """Draw an antialiased rounded rectangle by supersampling coverage.""" + step = 1.0 / samples + for py in range(int(y0), int(y0 + h) + 1): + if py < 0 or py >= width: + continue + for px in range(int(x0), int(x0 + w) + 1): + if px < 0 or px >= width: + continue + hits = 0 + for sy in range(samples): + for sx in range(samples): + fx = px + (sx + 0.5) * step + fy = py + (sy + 0.5) * step + if inside_rounded(fx, fy, x0, y0, w, h, radius): + hits += 1 + if hits: + blend(pixels, width, px, py, rgb, hits / (samples * samples)) + + +def inside_rounded(fx, fy, x0, y0, w, h, r) -> bool: + if fx < x0 or fx > x0 + w or fy < y0 or fy > y0 + h: + return False + # Clamp the sample into the inner rectangle; the leftover offset is the + # distance to the nearest corner arc centre. + cx = min(max(fx, x0 + r), x0 + w - r) + cy = min(max(fy, y0 + r), y0 + h - r) + dx, dy = fx - cx, fy - cy + return dx * dx + dy * dy <= r * r + + +def draw_glyph(pixels, size, rgb, scale=1.0, offset=(0.0, 0.0)): + """Three left-aligned bars — a stylised index. + + Coordinates are expressed on a 100x100 design grid and mapped onto `size`, + so every raster below is the same drawing rather than three lookalikes. + """ + unit = size / 100.0 * scale + ox = offset[0] * size / 100.0 + oy = offset[1] * size / 100.0 + bar_h = 14.0 + radius = bar_h / 2.0 + for i, (top, bar_w) in enumerate(((16.0, 68.0), (43.0, 44.0), (70.0, 68.0))): + rounded_rect( + pixels, + size, + 16.0 * unit + ox, + top * unit + oy, + bar_w * unit, + bar_h * unit, + radius * unit, + rgb, + ) + + +def make_menubar(size: int) -> bytearray: + pixels = bytearray(size * size * 4) + # Template image: black glyph, transparent elsewhere. macOS reads alpha only. + draw_glyph(pixels, size, (0, 0, 0)) + return pixels + + +def make_app_icon(size: int) -> bytearray: + pixels = bytearray(size * size * 4) + # macOS icon grid: the artwork occupies ~80% of the canvas, with the + # squircle corner radius at ~22.4% of the artwork's edge. + inset = size * 0.10 + art = size - inset * 2 + rounded_rect(pixels, size, inset, inset, art, art, art * 0.2237, BG) + draw_glyph(pixels, size, FG, scale=0.80, offset=(10.0, 10.0)) + return pixels + + +def main() -> int: + if shutil.which("iconutil") is None: + print("make-placeholder-icons: iconutil not found — run this on macOS", file=sys.stderr) + return 1 + + RESOURCES.mkdir(parents=True, exist_ok=True) + + for name, size in (("menubar.png", 18), ("menubar@2x.png", 36)): + write_png(RESOURCES / name, size, size, make_menubar(size)) + print(f"wrote mac/Resources/{name} ({size}x{size})") + + with tempfile.TemporaryDirectory() as tmp: + iconset = pathlib.Path(tmp) / "AppIcon.iconset" + iconset.mkdir() + # Cache by pixel size: the iconset asks for several sizes twice. + rendered: dict[int, bytearray] = {} + for name, size in ICONSET: + if size not in rendered: + rendered[size] = make_app_icon(size) + write_png(iconset / name, size, size, rendered[size]) + subprocess.run( + ["iconutil", "-c", "icns", str(iconset), "-o", str(RESOURCES / "AppIcon.icns")], + check=True, + ) + print("wrote mac/Resources/AppIcon.icns") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/mac/scripts/sign-app.sh b/mac/scripts/sign-app.sh new file mode 100755 index 00000000..c6449a37 --- /dev/null +++ b/mac/scripts/sign-app.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# sign-app.sh — ad-hoc sign cix.app, innermost code first. +# +# Usage: mac/scripts/sign-app.sh +# +# Why ad-hoc and not Developer ID +# ------------------------------- +# This is an open-source project with no paid Apple Developer membership, so +# there is no identity to sign with and nothing to notarize. Ad-hoc signing +# (`--sign -`) is still mandatory: on Apple Silicon every executable must carry +# *some* valid signature or the kernel refuses to run it outright. Ad-hoc gets +# the code running; it does not satisfy Gatekeeper, which is why the DMG ships +# with first-run instructions. +# +# Why not --deep +# -------------- +# `codesign --deep` is deprecated by Apple and unreliable for a bundle like this +# one, which carries four executables and a pile of dylibs directly in +# Contents/MacOS rather than as nested .app/.framework bundles. Signing bottom +# up is explicit, ordered, and each step is verifiable. +# +# Why xattr -cr first +# ------------------- +# server/Makefile records the failure this prevents: on macOS 26, amfid SIGKILLs +# an ad-hoc-signed binary whose linked dylibs carry a stale signature or a +# com.apple.provenance xattr, and it does so with EMPTY STDERR — the process +# just dies. Every `cp` into the staging tree recreates those conditions, so the +# strip has to happen on every build, not once at install time. +set -euo pipefail + +APP="${1:-}" +if [[ -z "$APP" ]]; then + echo "usage: $(basename "$0") " >&2 + exit 2 +fi +if [[ ! -d "$APP" ]]; then + echo "sign-app: no such bundle: $APP" >&2 + exit 1 +fi + +echo "sign-app: stripping extended attributes from $APP" +xattr -cr "$APP" + +MACOS_DIR="$APP/Contents/MacOS" + +sign_one() { + local target="$1" + [[ -e "$target" ]] || { echo "sign-app: missing signing target: $target" >&2; exit 1; } + codesign --force --sign - "$target" +} + +# 1. Libraries. llama-server links these by @rpath; if a dylib's signature is +# stale relative to the executable that loads it, the load fails at dyld +# time — after codesign has happily reported success on the executable. +shopt -s nullglob +dylibs=("$MACOS_DIR"/llama/*.dylib) +shopt -u nullglob +if [[ ${#dylibs[@]} -eq 0 ]]; then + echo "sign-app: no dylibs found under $MACOS_DIR/llama — the bundle is incomplete" >&2 + exit 1 +fi +echo "sign-app: signing ${#dylibs[@]} dylib(s)" +for lib in "${dylibs[@]}"; do + sign_one "$lib" +done + +# 2. Executables, leaf-most first. +for bin in llama/llama-server cix cix-server cix-launcher; do + echo "sign-app: signing $bin" + sign_one "$MACOS_DIR/$bin" +done + +# 3. The bundle last — this seals everything above into Contents/_CodeSignature. +echo "sign-app: signing bundle" +codesign --force --sign - "$APP" + +# --strict is the point of this step: the lenient default accepts a bundle whose +# sealed resources no longer match what is on disk, which is exactly the state a +# partially re-copied bundle ends up in. +echo "sign-app: verifying" +codesign --verify --strict --verbose=2 "$APP" + +echo "sign-app: ok" From 3b3a61cc9ab6a14ec2e9906c740869e8523cfef9 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 12:30:42 +0100 Subject: [PATCH 03/12] feat(mac): use the real cix icon set and lay out the disk image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the placeholder artwork with the delivered CIX icon set and puts the DMG window together the way the design assumes. The set is split by role. Cream magnifier = the app, red magnifier with a "+" = the installer; that inversion is the only thing telling the two files apart in a Downloads folder, so the red one becomes the DMG's volume icon and never the app's. The menu-bar glyph is a pure black-plus-alpha template image, which is a requirement rather than a style: macOS recolours template images for dark mode and for the pressed state and reads nothing but the alpha channel. The .icns files are built from the iconsets at package time rather than committed, so the PNGs stay the single source of truth. The export pipeline could not write "@" into filenames, so the retina files arrived as -2x.png and were renamed to @2x.png, which is what iconutil requires. make-dmg.sh now builds a read-write image, applies the designed window layout through Finder, then compresses — the layout lives in the volume's .DS_Store and cannot be written into a read-only image after the fact. Finder needs a GUI session that a CI runner may not have, and an automation-consent prompt would hang the build rather than fail it, so the step runs under a watchdog and DMG_LAYOUT decides what failure means: auto (warn, ship unstyled), require (abort), off (skip). Default is auto until a real release proves the runner can do it. The image ships two visible items and no third. A READ ME FIRST.txt with the Gatekeeper instructions was tried and dropped: the block happens after the user has dragged the app to Applications and ejected the image, so the file is on screen exactly when it is not needed and gone when it is. Those instructions stay in the release body, next to the download button, and in doc/MACOS_APP.md. Two behaviours cost real debugging and are recorded where they bite: - The volume icon has to be installed AFTER the Finder pass. Staging it up front looks like it works — hdiutil copies the file, SetFile sets the flag — and then Finder removes both while laying out the window, shipping a generic disk icon. Found by bisecting the steps. - Finder's item `position` is the top-left of the cell, not its centre, and a y below roughly 68 is not clamped for that one item: Finder translates *every* item down by the difference, sliding the app and the symlink off the arrow. Verified by reading the finished image back: content 640x420, icon size 104, cix.app at (175,250), Applications at (465,250), volume icon flag set, and the bundle still passing `codesign --verify --strict` after the round trip. Co-Authored-By: Claude Opus 5 --- doc/MACOS_APP.md | 3 +- mac/Info.plist.in | 4 +- mac/README.md | 94 +++++-- mac/Resources/AppIcon.icns | Bin 73062 -> 0 bytes mac/Resources/README.md | 107 ++++++++ .../cix-installer.iconset/icon_128x128.png | Bin 0 -> 2584 bytes .../cix-installer.iconset/icon_128x128@2x.png | Bin 0 -> 5748 bytes .../cix-installer.iconset/icon_16x16.png | Bin 0 -> 346 bytes .../cix-installer.iconset/icon_16x16@2x.png | Bin 0 -> 571 bytes .../cix-installer.iconset/icon_256x256.png | Bin 0 -> 5748 bytes .../cix-installer.iconset/icon_256x256@2x.png | Bin 0 -> 14122 bytes .../cix-installer.iconset/icon_32x32.png | Bin 0 -> 571 bytes .../cix-installer.iconset/icon_32x32@2x.png | Bin 0 -> 1168 bytes .../cix-installer.iconset/icon_512x512.png | Bin 0 -> 14122 bytes .../cix-installer.iconset/icon_512x512@2x.png | Bin 0 -> 39343 bytes mac/Resources/cix.iconset/icon_128x128.png | Bin 0 -> 2810 bytes mac/Resources/cix.iconset/icon_128x128@2x.png | Bin 0 -> 6244 bytes mac/Resources/cix.iconset/icon_16x16.png | Bin 0 -> 345 bytes mac/Resources/cix.iconset/icon_16x16@2x.png | Bin 0 -> 575 bytes mac/Resources/cix.iconset/icon_256x256.png | Bin 0 -> 6244 bytes mac/Resources/cix.iconset/icon_256x256@2x.png | Bin 0 -> 15046 bytes mac/Resources/cix.iconset/icon_32x32.png | Bin 0 -> 575 bytes mac/Resources/cix.iconset/icon_32x32@2x.png | Bin 0 -> 1126 bytes mac/Resources/cix.iconset/icon_512x512.png | Bin 0 -> 15046 bytes mac/Resources/cix.iconset/icon_512x512@2x.png | Bin 0 -> 40134 bytes mac/Resources/dmg/dmg-background.png | Bin 0 -> 12982 bytes mac/Resources/dmg/dmg-background@2x.png | Bin 0 -> 35879 bytes mac/Resources/menubar.png | Bin 123 -> 0 bytes mac/Resources/menubar/cixTemplate-18.png | Bin 0 -> 220 bytes mac/Resources/menubar/cixTemplate-36.png | Bin 0 -> 276 bytes mac/Resources/menubar/cixTemplate-44.png | Bin 0 -> 337 bytes mac/Resources/menubar/cixTemplate-88.png | Bin 0 -> 603 bytes mac/Resources/menubar@2x.png | Bin 188 -> 0 bytes mac/scripts/build-app.sh | 13 +- mac/scripts/make-dmg.sh | 248 +++++++++++++----- mac/scripts/make-placeholder-icons.py | 200 -------------- 36 files changed, 376 insertions(+), 293 deletions(-) delete mode 100644 mac/Resources/AppIcon.icns create mode 100644 mac/Resources/README.md create mode 100644 mac/Resources/cix-installer.iconset/icon_128x128.png create mode 100644 mac/Resources/cix-installer.iconset/icon_128x128@2x.png create mode 100644 mac/Resources/cix-installer.iconset/icon_16x16.png create mode 100644 mac/Resources/cix-installer.iconset/icon_16x16@2x.png create mode 100644 mac/Resources/cix-installer.iconset/icon_256x256.png create mode 100644 mac/Resources/cix-installer.iconset/icon_256x256@2x.png create mode 100644 mac/Resources/cix-installer.iconset/icon_32x32.png create mode 100644 mac/Resources/cix-installer.iconset/icon_32x32@2x.png create mode 100644 mac/Resources/cix-installer.iconset/icon_512x512.png create mode 100644 mac/Resources/cix-installer.iconset/icon_512x512@2x.png create mode 100644 mac/Resources/cix.iconset/icon_128x128.png create mode 100644 mac/Resources/cix.iconset/icon_128x128@2x.png create mode 100644 mac/Resources/cix.iconset/icon_16x16.png create mode 100644 mac/Resources/cix.iconset/icon_16x16@2x.png create mode 100644 mac/Resources/cix.iconset/icon_256x256.png create mode 100644 mac/Resources/cix.iconset/icon_256x256@2x.png create mode 100644 mac/Resources/cix.iconset/icon_32x32.png create mode 100644 mac/Resources/cix.iconset/icon_32x32@2x.png create mode 100644 mac/Resources/cix.iconset/icon_512x512.png create mode 100644 mac/Resources/cix.iconset/icon_512x512@2x.png create mode 100644 mac/Resources/dmg/dmg-background.png create mode 100644 mac/Resources/dmg/dmg-background@2x.png delete mode 100644 mac/Resources/menubar.png create mode 100644 mac/Resources/menubar/cixTemplate-18.png create mode 100644 mac/Resources/menubar/cixTemplate-36.png create mode 100644 mac/Resources/menubar/cixTemplate-44.png create mode 100644 mac/Resources/menubar/cixTemplate-88.png delete mode 100644 mac/Resources/menubar@2x.png delete mode 100755 mac/scripts/make-placeholder-icons.py diff --git a/doc/MACOS_APP.md b/doc/MACOS_APP.md index 643cac82..36c2457e 100644 --- a/doc/MACOS_APP.md +++ b/doc/MACOS_APP.md @@ -76,7 +76,8 @@ cix.app/Contents/ cix command-line client llama/ Metal-accelerated llama-server + its libraries Resources/ - AppIcon.icns menubar.png menubar@2x.png + cix.icns app icon + cixTemplate.png menu-bar glyph (and @2x) ``` Everything executable lives in `Contents/MacOS/`, including `llama/`. That is diff --git a/mac/Info.plist.in b/mac/Info.plist.in index 10208a3b..8900d13d 100644 --- a/mac/Info.plist.in +++ b/mac/Info.plist.in @@ -38,8 +38,10 @@ com.cix.launcher CFBundleExecutable cix-launcher + CFBundleIconFile - AppIcon + cix CFBundlePackageType APPL CFBundleSignature diff --git a/mac/README.md b/mac/README.md index bef76f7f..08953174 100644 --- a/mac/README.md +++ b/mac/README.md @@ -6,13 +6,12 @@ User-facing installation and usage documentation lives in ``` mac/ - Info.plist.in bundle metadata template (@PLACEHOLDER@ tokens) - Resources/ icons — placeholders, safe to replace - scripts/build-app.sh assemble + sign mac/dist/cix.app - scripts/sign-app.sh ad-hoc codesign, innermost code first - scripts/make-dmg.sh wrap the app in a drag-to-Applications DMG - scripts/make-placeholder-icons.py regenerate the placeholder artwork - dist/ build output (gitignored) + Info.plist.in bundle metadata template (placeholder tokens) + Resources/ icon set + DMG artwork — see Resources/README.md + scripts/build-app.sh assemble + sign mac/dist/cix.app + scripts/sign-app.sh ad-hoc codesign, innermost code first + scripts/make-dmg.sh wrap the app in a drag-to-Applications DMG + dist/ build output (gitignored) ``` ## Build @@ -34,6 +33,8 @@ set explicitly by CI: | `SKIP_SERVER_BUILD` | `1` reuses an existing `server/dist` bundle — much faster when iterating on the launcher | | `SKIP_SIGN` | `1` skips codesign; the result **will not run** | +`make-dmg.sh` additionally takes `DMG_LAYOUT` — see "Disk image" below. + Release builds pass the versions in rather than deriving them, because `git describe` is not reliable on this repo: the three tag streams interleave, and at least one shipped server tag sits on a commit reachable from no branch. @@ -75,13 +76,51 @@ things about it are not stylistic: `--options runtime` is deliberately not used: the hardened runtime only pays off alongside notarization, and it risks breaking `llama-server`. -## DMG +## Disk image + +The image carries exactly two visible items — `cix.app` and an `/Applications` +symlink — over the designed background, with the red installer icon as the +volume icon. + +A third item, a `READ ME FIRST.txt` carrying the Gatekeeper instructions, was +tried and removed. The block happens *after* the user has dragged the app to +Applications and ejected the image, so the file is on screen exactly when it is +not needed and gone when it is. Those instructions belong in the release body, +next to the download button, and in `doc/MACOS_APP.md`. -A plain UDZO image containing `cix.app`, an `/Applications` symlink and a -`READ ME FIRST.txt` with the Gatekeeper instructions. No custom window layout: -positioning icons requires a read-write image driven through Finder over -AppleScript in a real GUI session, which is flaky-to-impossible on a CI runner. -A pre-baked `.DS_Store` can be added later without changing the script. +Getting the window laid out means creating a read-write image, mounting it, and +driving Finder over AppleScript — Finder stores the layout in the volume's +`.DS_Store`, which cannot be written into a compressed read-only image +afterwards. That needs a GUI session, which a CI runner may not have, and an +automation-consent prompt would *hang* the build rather than fail it. So the +step runs under a 90-second watchdog and `DMG_LAYOUT` decides what its failure +means: + +| value | behaviour | +|---|---| +| `auto` (default) | try; on failure warn (a `::warning::` under Actions) and ship a valid but unstyled image | +| `require` | try; on failure abort the release | +| `off` | skip the Finder pass entirely | + +Once a release has proved the runner can do it, `require` is the right setting. + +Three details that cost real debugging time: + +- **The volume icon must be installed after the Finder pass**, not staged up + front. Staging it looks like it works — `hdiutil` copies the file, `SetFile` + sets the flag — and then Finder removes both while laying out the window, and + the image ships with a generic disk icon. +- **Finder's item `position` is the top-left of the cell**, not its centre, and + a cell is the icon size plus its label (~124 px at icon size 104). +- **Positions below y≈68 are not clamped, they translate everything.** Asking + for y=22 moved *every* item down by 46 px, sliding the app and the + Applications symlink off the arrow they are aligned with. Both of these only + matter if someone adds an item — worth knowing before trying. + +The volume name is a constant `cix` with no version in it: the layout is stored +per volume and the AppleScript addresses the disk by name, so a version-stamped +volume would make both version-specific for nothing. The version is in the +filename. The app is copied with `ditto`, not `cp -R`. `cp -R` drops extended attributes and signature-relevant metadata, which turns a verified bundle into one that @@ -89,24 +128,23 @@ fails `codesign --verify --strict` after the round trip through the image. ## Icons -`mac/Resources/` holds three committed files: - -| File | Size | Notes | -|---|---|---| -| `menubar.png` | 18×18 | template image — black + alpha only | -| `menubar@2x.png` | 36×36 | template image | -| `AppIcon.icns` | — | full icns | +Real artwork, not placeholders — see [`Resources/README.md`](Resources/README.md) +for the full set, the brand palette and the rules of use. In short: -They are placeholders. Replacing them means dropping in new files with the same -names and sizes; no code changes anywhere. +- `Resources/cix.iconset/` → `iconutil` → `Contents/Resources/cix.icns` (app) +- `Resources/cix-installer.iconset/` → `iconutil` → the DMG volume icon +- `Resources/menubar/cixTemplate-{18,36}.png` → `Contents/Resources/cixTemplate{,@2x}.png` +- `Resources/dmg/dmg-background{,@2x}.png` → the disk image window background -The menu-bar images **must** be template images. macOS recolours template images -to match the menu bar (dark mode, tinting, inactive state) and reads only their -alpha channel — a coloured PNG renders as a solid smudge. +The `.icns` files are built rather than committed, so the PNG iconsets stay the +single source of truth. Replacing the artwork means dropping in files with the +same names — no code changes. -`scripts/make-placeholder-icons.py` regenerates the current placeholders. It is -stdlib-only and is never run by a build; it exists so the placeholders are -reproducible rather than mystery binaries. +Cream is the app, red is the installer; that inversion is the only thing telling +the two apart in a Downloads folder. The menu-bar glyphs must stay template +images (pure black plus alpha): macOS recolours them for dark mode and for the +pressed state and reads nothing but the alpha channel, so a coloured PNG renders +as a smudge. ## Apple Silicon only diff --git a/mac/Resources/AppIcon.icns b/mac/Resources/AppIcon.icns deleted file mode 100644 index 7d6e28c5dc67132004d305323338f821c03fdb7b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 73062 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

{q_u42dXlNCG?&j~0W(K~p5e#G>pd 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 VdCM0Qa3J_X!9O$?FT&5M{{Zfb*meK_ diff --git a/mac/Resources/README.md b/mac/Resources/README.md new file mode 100644 index 00000000..74f053fa --- /dev/null +++ b/mac/Resources/README.md @@ -0,0 +1,107 @@ +# cix · CodeIndeX — macOS icon set + +Icon assets for the cix desktop app, generated from the design source +`CodeIndeX Icons.dc.html`. Brand palette (from the CIX logo): cream `#F1E6CF` / +`#F7EEDC`, red `#D73E2C`, dark red `#7A2A1E`, ink `#1F140F`. + +The mark is a blocky, pixel-grid magnifier — same block-and-trace construction +as the CIX logotype. It simplifies as it gets smaller: at 128 px+ it carries the +offset "trace" outline and three code lines, at 64 px the outline drops, at +32/16 px only the ring and handle remain. + +> Two adaptations were made when importing this set into the repo. The export +> pipeline could not write `@` into filenames, so retina files arrived as +> `-2x.png`; they have been renamed to `@2x.png`, which is what `iconutil` +> requires. And the menu-bar wiring below was written for AppKit/Swift; this app +> is Go, so the equivalent is `systray.SetTemplateIcon(png, png)` — the +> "must be a template image" rule is unchanged and just as load-bearing. + +Nothing here is used at build time by name-lookup magic: `mac/scripts/build-app.sh` +and `mac/scripts/make-dmg.sh` name these paths explicitly, so replacing the +artwork is a matter of dropping in files with the same names. + +--- + +## 1. `cix.iconset/` — application icon + +The app's own icon: cream background, red magnifier. This is what the user sees +in Finder, in Get Info, and on the mounted disk image. + +| file | px | notes | +|---|---|---| +| `icon_512x512@2x.png` | 1024 | full detail: trace outline + 3 code lines | +| `icon_512x512.png`, `icon_256x256@2x.png` | 512 | full detail | +| `icon_256x256.png`, `icon_128x128@2x.png` | 256 | full detail | +| `icon_128x128.png` | 128 | full detail | +| `icon_32x32@2x.png` | 64 | outline dropped, 2 code lines | +| `icon_32x32.png`, `icon_16x16@2x.png` | 32 | ring + handle only, pixel-snapped | +| `icon_16x16.png` | 16 | ring + handle only, pixel-snapped | + +`build-app.sh` runs `iconutil -c icns` on this directory and installs the result +as `cix.app/Contents/Resources/cix.icns`, matching +`CFBundleIconFile` = `cix` in `mac/Info.plist.in`. + +Do **not** add your own rounded corners, shadow, or padding — the squircle, the +1-px ink border and the safe margin are already baked in at every size. + +## 2. `cix-installer.iconset/` — installer / disk-image icon + +Inverted colourway: red field, cream magnifier, blocky `+` in the bottom-right +corner. In Finder and in Downloads the installer must never be mistaken for the +app itself, so it is a colour inversion rather than a different drawing. + +`make-dmg.sh` converts it and installs it as the volume icon +(`.VolumeIcon.icns` plus the Finder custom-icon flag). + +## 3. `menubar/` — menu bar (status bar) icon + +Monochrome **template** images for the status item — the icon that sits in the +top bar next to the clock. Pure black + alpha; macOS itself inverts it for dark +menu bars and tints it while the menu is open. + +| file | use | +|---|---| +| `cixTemplate-18.png` | @1x — the standard 18×18 status-item glyph | +| `cixTemplate-36.png` | @2x for the 18 px glyph | +| `cixTemplate-44.png` | @2x for a 22 px glyph (roomier bar layouts) | +| `cixTemplate-88.png` | @4x / source for tracing a vector template | + +The 18 and 36 px files are copied into the bundle; 44 and 88 are kept here as +part of the source set. + +Never recolour the glyph in code, and never use the red app icon in the menu +bar — at 18 px colour turns to mush and breaks dark mode. + +## 4. `dmg/` — disk image window background + +`dmg-background.png` (640×420) and `@2x` (1280×840): cream sheet, red rule at +the top, CIX wordmark, and a blocky arrow pointing right — from `cix.app` +toward `Applications`. Icon placeholders are intentionally **not** drawn; +Finder puts the real icons on top. + +The layout the arrow points at, implemented in `make-dmg.sh`: + +- window content size 640×420 +- `cix.app` centre at **(175, 250)** +- `Applications` symlink centre at **(465, 250)** +- icon size 104, arranged by position, toolbar/sidebar/status bar hidden + +## 5. `web/` — not imported + +The archive also contains favicons and an animated installer icon for the +download page. Those belong to `site/`, not to the app bundle, and were left out +of this directory so that everything here is something the build actually +consumes. + +--- + +## Rules of use + +1. **Cream = the app, red = the installer.** Never swap them; that distinction + is the only thing separating the two files in a Downloads folder. +2. **Menu bar is always the black template**, never the coloured icon. +3. Keep the pixel grid: scale only by whole factors (×2, ×4). Smooth-scaling the + small sizes destroys the blocky construction. +4. Nothing here needs an extra shadow, gradient, gloss, or corner mask. +5. Need a size that is not here? Regenerate from the design source rather than + upscaling a PNG. diff --git a/mac/Resources/cix-installer.iconset/icon_128x128.png b/mac/Resources/cix-installer.iconset/icon_128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..cd33fd52dca7014d2f12b0b4604a1d97a848d72d GIT binary patch literal 2584 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 literal 0 HcmV?d00001 diff --git a/mac/Resources/cix-installer.iconset/icon_16x16@2x.png b/mac/Resources/cix-installer.iconset/icon_16x16@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a41219d0ba299957c7a6a5df4f196f9295d4ee67 GIT binary patch literal 571 zcmV-B0>u4^P)s{3zYS-|5;%~%?noxf%+7w_{B5$wXztfilFH5TVJ#Z))w0phl&_Y^467Ck_=~O6 zWO0s?{sI7TrqQY0y?-jDSV+kSeN;_EED>M<^CIX~GtG{QK#U+n05HsmsT2)SYR@gi zaN2e?@bW>gNa)D3qatD8GL&RWQ`I1b(Qe7}>RxvT3R=$2n_Yro8ELFip;fZ%+$y`M zh{sJ&!*7-G?9WnMS#`jUJtWPmK#Y?sb`Y$C32s&&Pb?j{b%^1EHh^2)0vHUD``ymv zXqCwR2F|+cLICR?w93y3z^Q;!frSdlcV52dvM&2?dOa5c*c4z}eFg>TpmP7i zCW_^-oSad literal 0 HcmV?d00001 diff --git a/mac/Resources/cix-installer.iconset/icon_256x256.png b/mac/Resources/cix-installer.iconset/icon_256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..b3d11d82a4e6e8c047935aba82c04948a0cfc107 GIT binary patch literal 5748 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 literal 0 HcmV?d00001 diff --git a/mac/Resources/cix-installer.iconset/icon_32x32.png b/mac/Resources/cix-installer.iconset/icon_32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..a41219d0ba299957c7a6a5df4f196f9295d4ee67 GIT binary patch literal 571 zcmV-B0>u4^P)s{3zYS-|5;%~%?noxf%+7w_{B5$wXztfilFH5TVJ#Z))w0phl&_Y^467Ck_=~O6 zWO0s?{sI7TrqQY0y?-jDSV+kSeN;_EED>M<^CIX~GtG{QK#U+n05HsmsT2)SYR@gi zaN2e?@bW>gNa)D3qatD8GL&RWQ`I1b(Qe7}>RxvT3R=$2n_Yro8ELFip;fZ%+$y`M zh{sJ&!*7-G?9WnMS#`jUJtWPmK#Y?sb`Y$C32s&&Pb?j{b%^1EHh^2)0vHUD``ymv zXqCwR2F|+cLICR?w93y3z^Q;!frSdlcV52dvM&2?dOa5c*c4z}eFg>TpmP7i zCW_^-oSad literal 0 HcmV?d00001 diff --git a/mac/Resources/cix-installer.iconset/icon_32x32@2x.png b/mac/Resources/cix-installer.iconset/icon_32x32@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d6351b4800cc236ec33b8dc6abd1ec6ac9003684 GIT binary patch literal 1168 zcmV;B1aJF^P)4^Xc@0v8U;9=PxbUfl;E@gTRY;scnK zz=?=rVg!wX0mNuuYijLDn?Ibq~^r}JE1=A0i}Tq{itUEg1RFg)!i#4!N?M@534fBT_e=Wq9k*?tKu z&B=BNY^3A7oHQCnKH7AilrKl!znm860(InB&0QoG@i!<_+Tu8zR7}t3U}*+}O%9kR$IIj!8B|kWVwQ@O@V? zTPQ(UICAxq4DMy)amf=7cI(mNN1INkhSm2gapFx{v*&@eyuBwR`@a_DnLD|B`L;e z1=R8m5H<0P3J^88TJlK+s70kX`vm1~v;t~*2M9{2)%`{VXm#=?=1T<#QqlYOIj^tx zPgUM_6X$+o`{mNeoM{C#qFxeWja7iRY#Tj4N!YQaHNN>dW+%m*)Mre9lO~CBU#tY6 zvP_E^N&xRJ>^(P~ly_`>Zh8(qUMK-{P?C)IaTiVw{G4O7=3y3|X9L$-sXIyJE(-B*UMiYO%x%<+RzY0;@0~&=3ir386i{Wd6 z&7iok6%>=0e$6V`U2Di)bjL4Ubh#%ILl^*}|33nD3*XW1c6A$+clNl9w1s`tRl?}i zcd}gt`jh}DAvZ&Ja&=wAK?D-7$6#aiVI6w4sQ#H!3BX`eeU}SPHY1WM0`RcG2rJCk zp#W5YbEPYX1CF{>aEiNm;bDYTGaU**106U}B9sbmTX7bG+$Z0!tEz?xHW*>mc0>Rg zaVV~Q%E`?}-4a>|HIN*PLA9Z5R7~fj^JMYL@(q_K-28A~4kp-O^x822bSIm{%<_je zg4;w};qCUX0@H& literal 0 HcmV?d00001 diff --git a/mac/Resources/cix-installer.iconset/icon_512x512.png b/mac/Resources/cix-installer.iconset/icon_512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..c513a3433d128a6ee3dde0c0335e5b38ed9efaae GIT binary patch literal 14122 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 literal 0 HcmV?d00001 diff --git a/mac/Resources/cix-installer.iconset/icon_512x512@2x.png b/mac/Resources/cix-installer.iconset/icon_512x512@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..b38417c42252b1c36aaf3182ca303e7b28fe1f8c GIT binary patch literal 39343 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 literal 0 HcmV?d00001 diff --git a/mac/Resources/cix.iconset/icon_128x128.png b/mac/Resources/cix.iconset/icon_128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..0b57332eb44088b296ec15ad648457cb3af83175 GIT binary patch literal 2810 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 tu@Ry literal 0 HcmV?d00001 diff --git a/mac/Resources/cix.iconset/icon_16x16.png b/mac/Resources/cix.iconset/icon_16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..d3bd7d893f7a860c7f233a18a1f5f4737652d078 GIT binary patch literal 345 zcmV-f0jBK{s`5mv+=a5Ovj27dwg{;HKbUJ6h;2QpDMx&_sxq zLR&=#3y<7g^74|B2*7VT?u}B(#J*W8-O$EDYd7}9K`ID3%x8`!{Emst*MBp%3 zp{xkUd}czWTDF94Xm+Qi;#`{%;V;*EqC1?7Fv%E~%F5>?hyxK(&>{|T r?*IS*|NpjM9zXy900v1!K~w_(^UIzm^@F*200000NkvXXu0mjfoX3-9 literal 0 HcmV?d00001 diff --git a/mac/Resources/cix.iconset/icon_16x16@2x.png b/mac/Resources/cix.iconset/icon_16x16@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9cc22e7adbdba45f8babf3f6df8572d25c074ee0 GIT binary patch literal 575 zcmV-F0>J%=P)NklmxsCu+4v2d! z&c$sI&xrWHer`&DzQFFWBLGh_s@Ge#IxC(LqcZ_c{v;zj$y8g}Er9VSnaVCM\V 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 tu@Ry literal 0 HcmV?d00001 diff --git a/mac/Resources/cix.iconset/icon_256x256@2x.png b/mac/Resources/cix.iconset/icon_256x256@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d8deceeca985b47cfa35ec77434dc76bcfef3fc4 GIT binary patch literal 15046 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 literal 0 HcmV?d00001 diff --git a/mac/Resources/cix.iconset/icon_32x32.png b/mac/Resources/cix.iconset/icon_32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..9cc22e7adbdba45f8babf3f6df8572d25c074ee0 GIT binary patch literal 575 zcmV-F0>J%=P)NklmxsCu+4v2d! z&c$sI&xrWHer`&DzQFFWBLGh_s@Ge#IxC(LqcZ_c{v;zj$y8g}Er9VSnaVCM\V zu~24DDkmowxn9}&#+rf&EU^dL915C#?=*0z2E|q&lqUWdVQg?lEfDA+76)UaGgcv$Guk+iLZAbraGa429CZ>) z0j*mE_Yew?sCjLa<}{T?fZQ9?OrAXOL+Vo{;sBbd(Ne^llO`j zd(JAmf9HBlUWdxa2_sLL?>t8C9(cl400zI`cwL;D$QEm&Ry;U3NT-Ge()MgNok|Ug z)%E4jn9NC#6Gomx!&h@M=Uji^VDV-CY3=Y=b>2;g69NE^iVWU(^H#y`|EP%7H3=-u zo}Cd`Dm5S{4Tq7BHk~Iq+p}3wSX>hQmj`n&!PY3mF#&KWEUXl_w?3xT3LgPC4vNaX zEf-4*0TXO6I`gj!KnD&e20Sb5bd2&fLcjzYjIe5+x&V*op5<`BF;4Ir6eieUgjKgI z0XQTm5nJvV9Cc^hUSUH7SalUB0Sbkc9I&?BGrY#x5J5i8#P-%_#cZJjWns(Jk2AQF zjmITVIM}T#eM4zF?G9LdI5AkuI~5_>|Gp8TBY%v!$-_`syme$x#0}1iM>DIZfR}S)zgw9%`UScFm$4i3k=r0`aTqjqbi%!a7qF`e z;HDFxVQe%dKx1Rj``*n>(XIuZJLov00t8LsG{+?Zw5q=Ibasw$mb|k)=(SrsxA}w4 zqsJu`pq1#<*Q5eWuYCWd0pZ6?&+j*0QUQJyi<^H^0j5`!&*A-;>G}P}ODe#xVsY~~ zUIAW@z_1BuT!63vdgYS{;H6O9`;AwC*CQ}&f*KbfY=B<*v@F2?`k=z?gAcd)y#T%X zTNc18P*nR90Zgu(vwVYIo6M(`SV;x6qEZ-qnpKpEyaH8#^F(%Dn@GqRPhXpe>lP!B z0B*se*q#Vra@BOnw)1K-pH^Z;x&llbjFAwaSw*?6uHW`Z2;djWkAKGsz}I6C%Hr!W z`i6#1GT8Z&R4Go-eh)^q1sxhKa2OO8mrV&9`Z$_ZvX`tg=le?I4s{jIq5uEys~_U%M)(Dx|e|oHW;0D zLIB;#A@L;lqKm;@qAS0<^EJ;~M}lGq!&W(M)AJwYR4VUOO5`bX&aAF4W$unY$}|Jl soPPiS0RR8E#H5S>000I_L_t&o0H-dIGlPfwbpQYW07*qoM6N<$g2jsoQ2+n{ literal 0 HcmV?d00001 diff --git a/mac/Resources/cix.iconset/icon_512x512.png b/mac/Resources/cix.iconset/icon_512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..d8deceeca985b47cfa35ec77434dc76bcfef3fc4 GIT binary patch literal 15046 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 literal 0 HcmV?d00001 diff --git a/mac/Resources/cix.iconset/icon_512x512@2x.png b/mac/Resources/cix.iconset/icon_512x512@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..84ca551dbdbffc11fc50ab75409851fffa2392cd GIT binary patch literal 40134 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

$`GkD`&RUFwnETcZ*s?d4PP2Q z$6X>2H-%}E!&@P@tGU6`&R&*9?v5>G0aqAbMtV7axvdRl(G_m(K9j(>(yPG+=<(V5 zAfqDe#iMC~duPt9`6R}3+yT7t*Q2o-RXiD8=AO+vEf*ukyk$9ydWOq(2x00r4tNnf zBYL0!y0f0kQMD#D3!(2{$RJoO#%lV)z$3LOqZWHY{WxESMJbU*YA;I#x!~*#j4DsX|9ur zJ~fZ5eF8`tbDHIAssZz5=B3evjF_~h2WffAHm7;yjUNyGDd2(s#^c~*cOgP{NMM|U zPU+R_p+qx=W;8`DYC<}b~%frPrf;+D(tY&}B7-k}C5p*;{Y0X8m3d-q_0v;F-al{+#RpW|BO^SzcE>i~Kj|@o-w1AkbixHYOx3!c%2ocd#_~9^)g& zzMC)uvi8^1+47Q6wX4*K?Nr=Gox=!8q+cQhya4p4SSc@&Wg}0=x;k6cX7JZ1%D#JR zMpGzJ$=>VdOU;Y;&zbUR6BRRFnM{o|YC1_5 z%pMr!vi}h41e5JkJI{NpltRH;9 zrIC_G<4xZ z!}-l0cRH`nGxABe>{#c~2jmDB$2Ry#$k07fb5D z`iw2w!#JB|ar8`cv1@;5Lm^i* zWOt34oIG2wWn*_<|G$MY<-Ll89?JWpIlIu;#NtaYu+{e^e@XospeL+RPCZO>h9iHy`RC_T^qfFD ziydd2V_4Ix8U1_k`Ne)}2lSGVJ`N&nhS}l9G-0{$+7=s&!W3D^FN3naD_5YY};DDArmMt$j%br8A{x3a43tu=b#GE^6q6-s} z5~)@lyh8PJ+ka0Eejblu4Oqkku`RxnCKl;nK;PW!-z(m--%cJq^ixmX65Gupmh8`w zdhl9*>j+tb6tFDBP}Px8Y&vthR$WyT7xp6X9`JwN{#O&JJIADμ2PIdu_wP5Oit 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 E2M57*RsaA1 literal 0 HcmV?d00001 diff --git a/mac/Resources/dmg/dmg-background.png b/mac/Resources/dmg/dmg-background.png new file mode 100644 index 0000000000000000000000000000000000000000..4d5a2a84a6da7be664e9667b61115f54bb8c483e GIT binary patch literal 12982 zcmY*gc|4Tg_a75Q5=DrV7ExJ}oiKe06`{z!mF#AaeHmj~KUrH)c1lQOH!`-0QOS~h z9m`}H4B2;ncg#FK-`_vIn&-La+Hp*~e0L?>1Wa@0?#h-ocB}=4Mxv02gC56B9%2W3@{U zP2=T%^S){fP?1VH^~&a@&dY1Rs!yHft2uDx)bV7I2PN>S0aRz|rAzI*W7Ehezl7EP zHH#UG%D|jV;>TfeOO@6Snf}uXOYzg6Z%-TIq7&UFo;`K;yfeV-x=()hkzL^owL#X& zDz#Oie^lX&%p4ghLB}}-b?q1!avKU7_wMtUAz`BY=oJ+K>k{sa8SdbjpXH|c&a_}I~ z3S)pT9O6Cv876;XFc$pS*2M`FpXNlq0YAPoMS_2?Rxl0;(nd2fx8A5aTvb}{bN$D2 z7V5%aSU*-CfCHA4=Nba(WCc38TjJSoU86~GFf%Zm z=Q5*V<;fHpS|!iG;Ckd46V)W@!W_XW+Y&9`oqWzJvc2#s5io%3NgOobEQ^*jH-qbm z!*w*f7~qEvalpmZ;${di5q1U+AMoMSq^V?pM{$Dz%H=BqRFt-7`3(lxv*ETi zAUqjEA5}d9Paa7dY`+H9WS$X(8Ms@71tY|hPi`Dw5Y;V*f?(5$DiUTbGz#pi64plQ??8LNV|Qttp; z1bK7;f0B!Uqr1OzL)KqsVGfFlZ3sAjoi0*!53J*!V*7z@aAuju&R~ndI*LCyU!}V( z<4F+AD9l%SFZ^@R%k8NPj|;+zvG+KkSzjWIO^&pE>;6lR|@-k-6|BQ2Op3|F%+HGX~|8O?hDLk}-RswciK)7*13z7l!zO z*aL@K?J<%SqH~h#npGzH04)7%I+c}FeuQZ$C-u)^U@ho8U&N~RFZ)pd(QCc~Q4~$sWOS+*BqIb!qA@lTsc^}fwO=r=(%^`SR0GOxfH@=;Zu$wHMoY+r*N9ONZIUt!M zR7ggyN$fz<1R%*}{Wlh3^Zc#k9wci}A9vf1Zw7$O7vZZ*U}bdQC<7V)Xp<9ke@6a; zfQi;f4=3o;Ga+LsV4m0IoBTBAdc#dlH+ce?iBvv_MqB_Q(Z%&Kft{!+6#+5_^zis2HMCq-Dgk8r^+O<;e*i>}6BEnacUU3^_>6LNq-8q~ z9fT&M-)TYEVyMA4NapTd=AfE*p7IkrbY%z8c{JJaKUlFA9fx54Vp(V?lD>cu4{rQB z^t=_M)T6ArcK_ zvR0b-cLI1`7swp{lm>AxfqCA^lp)u$dGRtd&%Z$C`LiZAureCU=PiItd7d)F@x4NV zFm6Z1*#=s!281gI4EF!<5xH$lQ$>&%%lr)>!(Sw@om>F8!N7go)<@?b?4(vS2M25| z$e9NM?FZv(xi)MPSF!=g>=qS-?e~FCQgbaW0c~&s89Ss7B=Zm8{Np!I$~bqNqY7XQ z6zSQs&CWMQReW$XNJwkisHc$37vP+S3pnrpAh`*ada~FYnpT~4-*_=hUc`3|V#&U% z2-p#hvOd5?dT!kUzS*j$yr2i$d0h}D$?Gc(I}scRX4z;hme@w*Z)lJy<8DBb{h`Eg z2>FK~8uyS;IXfFKZxBceFb_Q4z2jo%$9}U0JxpX+gLd9|W3W=Gl0L9f+B&RUK?)z3 zq{JYynTkJ|Cdu}$gcL5Gv()^k>S=7^-qz;O7uK&8XP7-+T9>wV>?;3MNkMiU!9TDo zVZ()Z5rr}3uPg5G3H9~ZlI>fXpdf7z1K>)*O1;AJ=bp3*>S|)unN84SyXIXy6;j#j z4^_24s;tfwj*LsBw!Jn_Ji1kj(Pl4S!?naVqEkLff0QT}&0hbe>M$|e*}sW4TrBsT zXfGW9cI{gv<8zk=#^T@$Fck%|_*7SbS}?b{!AeJ?K; z77GG)R}x%6Q1=*Ccyrj=;TsayVO!8WG{lyQeX%VuVecl<`~^+gUX zIn#~Y*(+aV9O}l~x+o&H(>Rw{%-0Z78Y=+a?<~k8k%lBj=1}H~zQd#HDBF$$n_jC0 zs@LZ%1I%KJYNs3;uzOO4MiUaeHs@5Y6)bfr^`x2B#E(wB9uYv_o4X*5nLJ$Vt8tWQ zCb#tbA-@#`a+MvO4z_p2^_#J7tc1 zY(t*Pio;S>Ov2@-)N}<3ngjsn=Rw3w9>}s~;qXznsw0sw-8P#c;r_M{gZpFitS*_X zwzEFbh_OTlPUfu>Tt6?z%kA&KPd&;Jf%nSgiub^acskd|I&(SuJreTnxn-Mi$NPMJ zKl-^pcU-e&*;GpN%1U`AY3u^rxas-z6Xc&7f%i~PoAq|~erf54za zI;x=6oVp}1b}~DsorS}m*?FzUdnVf6+KsZAR5V&Z2vew7QIfW6C>iF~zg3mY%%u*t zg~0j zH+X(^j)?UyvqYBOboTrEg=LE=Qdq^!?Yb$hJ*?V?g@ZR41UVuAe_EFZi&UNC**c-l z__j1umGF6WNR#Aa8l{S&>|dX_aKPr(7?W zVNuD&jaNx&3%K=Mh4$qeRqtJtD*=tGBQCp`SE`^&sp}dA>uP*P0jMtTp8VQkA+vkv z$JVlQx9bGbI=ZGOSqHuH(P80z(qVjA^-?j47@t@&_Ug=gyWM$z%G{g38kS$E_a(Z= zv67M=nYu5?)UTnaT3-XL5}rjjf)ILEzsc6Stg?(&YPXbFHJteEc69_y%{dHjtrpBz zS6W1Zl*IE9?`6DJJn!`!!sQ-Gp~C;$6)?dADwS|9M4MR`* zjF(QdWMWodPiFN$>6zxmRq(U6_s>3@Z!NNP1kor3zw5qXSeZu|Khxho`{$LzLV9gt zeP}=YNUVaClhh^|w=6u6uk@{ETvLL-VwifyE(iOJzuiY0IAievquFS=bTzvQ+qm~_ zpV^Yt*T?X-i_`+B^r)8LXiM${Cm1V4n7wfAb@WN{|MD+&V z^8U^4m7L9sVp~;f&Ec43Rgk2XFwNq})+eN=JKxnhtM)0?^=}aiYi)RNp3gRPyfHU1 z?Vs?WJ^fkAUJ&9=fYLa z)+>)yNju@pl`>tdL1b~6CueMM!a@|KRzV1F@4RC#fapo1`u}!JAArWp$dw4_Fscl` z-~4~Z$$f%OGhEj|%r!fYQ4=Av{_Oc=FJz4DoI1g`219O%`#U@6Y>)`f z6Uuk$`ENs~&R}qYcuwE?o6La@e4+rZc=1Deb7_&2 zQYOx(E_%N8m5!HwEdkA)i~onY+S;amyzR^<*54`D6!Y9jPYMIg?HZY0N!#6i%_xiN zeBOTXvm&yuv`ABoJ16GS5&ywbulm@bJFOWS@PVMyYn%RJME zw3B!bF>Gz>v(iWYo+p%rBzz{tb)&@;g=!C5bz}KRq1esmTlTId8JFLVQ0_Kc@6xe+ zslHVC`E9ja#oR)UEot9t&CQi#Ela3%^`4ZohwW6$-k*HtV&;9Lc-lYE&8}ym z*qIn#5lG?5ul@P{*oNz>52?h`Z4HU+nD+Qqr?U7WE8-;S2wEVb@<(^bf?F1WwDM|e zKE-22ZL53HP01Kf$qE^LF*eaM(Sa-Q^A6=u$XbV@s8K&m(!y; zgD5#?AH|Q})5_;ekcy}c{ltgre9xMernja^ujNda zQrfc`<SviIaP+v(On*dCBcCy7$Vl zz`qn1*`CBP^^1lU8!?(v9h#fN$Mh~Xs;vI~#)-7}Ic!5NgvkfRnBW#a%&*j`CM5b- zT_S&7s!%N-#sw-VPggxOS^V>(O()gog8ZgU&Q%vo|DNV4e@fI4%O{(fLAJLdpkV}bIP~z?)TO38{4!@*V9%#iMNb8F=lnEmM?b6p(j3CHaxh3kNW082t7>x)Z>E0 zUFX)Fc4Vw5Xi!ouCv8+FSu9u*pUWbNtzH}2au?^~u`(ZjN56;qS-% zi(MZKg(q!E_n}8AWY5tp_0oaLO)o#cjZy+>{T!!rUwO?(!|b2LYyWO;%zm;gZ2SA& z`b?t6-7KZa66;_=fPcH){h^u9J&FEla+MRf<{vpK<&q;x&0_r(9~J^h15V{_Ubr{S z?oFeGfy1lLc=`Q9)1BACHVN)71;^!9sh^o)t1$u_lY4OY?vQHI%P!U@Dht=g zRSF8+ufEyneEv%|9#t;me@kKP@l(46cGX3-u9yeRik$j~3w$;oJa*_%PK>e$Bp5q1 z^nNDS_G3oA#F?z)>}OsU`jDGPgc5sA^nVZzdD{Mpe=*XK^tvrU#^39sW7q1i5+%L@ z)56w8(GKYxP4u6BrH!Uc6sWpZnz$ME+_=*BLh~XEPH!f%#o|@Mv#@aCyJ(N6lS^%3@&6R8QSdR?|(-l}!Y2-Ip{KjZ)65RTNG zS+2A-H|nsYymVcOQtA|2X?00)Irn6~YilOaqeEjX)H#;|KsH$ECF zCu#?_ksU6Pm?pCbbM2R&3M7s+#C7DWx~=#5F9mxzlO*#4`%1a&%1mwtFTP81dluLb zukg|LVo^ckS*E zvUvQ4_d8~FH!sof5OQ&w!$x0Xk)6TtGs3YSlj?D5k7nfEIlg2ioORo6^CMq?FJ*4P zIk0=>`{lBQLu;My4rF$B)h6Sf;rK|reB|1f1d?KSO??SZRDRf9CLn##k98yuc+v{O?PWI5+jvtG`iEYGJrj(K5uv zu)1b~Cw?KVwD0Bnx@KY{ni$y#PaE7C(i-iSB_{F_FV-xpi?i}b95wEp^*hBuSyHQ+>=LZl~?8on$6#7%a5!Uvii#Tj@%hiPj64Dtd*>DhNiO9dg$%82#liMTx2P z^iQgDGMF4t5BP){zRYer$ANi%@_Apsyi^N{)3Rc35v@IiXH*wd?1;TD)R2}rmU!73QznYRzqq&vrIpA?b8SBdd|O&k z-_0;7hZ%#PoDr%j!(JzjNEj7nWUtOVN(yuFiQiK(kXt-n6BEl{F<--FcWDS&q``+V z>dBO^4;ztzH&}!jw$$zZyZB>uVdd!{kEls&yU2d!*|GUqDT}yhnTBYhccXGwOtZlu zQp8`*u;**7u`*q?hiqjZECkD{=pBm4u~)J6Mv8JVDFOTcWWz9Lg$N@(1L4O9`P&{3&N`5x5d%mzItun zQGAcvsQIn3)o_=ygbgnVH*OS?6mchjaB4iuZ!)jsdWcd0zmNq*b#~yP!%Bma-9m^- zwVl?`Wzvkn7yVUUoifRGiwLPzoEwSY(aqn0D$DOvMN?uMb>8CzI*jw3je{jdt9i~T ze!5t{mwg}RlJIJiww}^tp`Mqz+$w*{RJ;0^{cVW`XTq`Og|LEMGi=kw1$ODnC6~=k zf5f}~9^zU!%DdU_6EI`HM2PA7F~0A{~QZ5^9Q0lhr+-l-UT^7Bc-l#kSA7~}f}GC0jmmjv;rGpO z{?8iOJ|W|kL#<`ZHe!4< zgV~9jfARedD#=G&RJf%7P=egU(`=7=Kq43x0YJ% z$*eEZ3)mklp7>cCN3HuMs~JO}@8g=C(#L_KC3etd=Dnwfs$pnfTF- zEJFQRQ78^+TzIVa%e^;gvN_CJVly$@rR=KHg3nR1MnGZ!$Y3^@x)WCYOLhMFmV0=O zr%N&5vu`Ua&L3>?cNX^`Ybdz)%aqh*fhx=1_Ell=p@>npFq~!OLQzg?#MVZ?EU|g@ zwx1d)JJR->7^=F_rFv8>G0+da_=>?TEmbXQ#MHCH+*7S|{cVJ-#8Ut3tt)l+7F^0U zyPemH!c;3}#J*%;mITBaE=X_sM4+S^VBuwa$I1izaZj6dic!9g^0Fsw}$MyeEapz8oi?^DS3}IKE&9Lq3?D%d&`1PYd zZB@c0P}6V2RQyPb!J~QCaAAVvx$6PKD*Os}4_1bF&9eyG+L5{s-)iOG;1qQ;kc|#| zG(stov>y58tg>Gvz}a4DgKacb9-m*X`0jQ68wIKI$$6=k9{z|Z_x{beF3(>xV@N+Y zrICw-#Yyy%6Mn@fpkg+wf@0d_5mjZ!%;Gna<=&s}*O=%Z+bkw>pgie-TFUZ|7gm8qXyxQbEqneY@` z`I2`_*~*-Tfy;%| zIwfmm@P`_G^CmM$md$3ezr&;&eccz=aHAC)=3S%<;m^9w}A7H zHY>N7qg~60tdwno*39A+cPuF1IU8EplJfHJby2^i0{9&8rk+lN^WD!H{{WkAB2ofr)+-C7*;YfkHqw;E|MpZk9!M@ zgKQEMHclgGmswh%J`{f<-Vtb^)o(?TfYJ(u7V6#Cw$ehN)L~{gYNz^h0VuVJ6+=^% zVo$J)v0|IS?bQ1OMFFUkqXp_?@v(6xJJlbN6rl9$wH9#aNL6Z;3%BXV!;BUWw_@ni zAgK4uPpxEu3R)2j;R`B}vkeJ}P_IVXDM4`F63a=2?$jc|(nU#GWGO0O`)*ZLutb7d zp6CV_AN}?Ukt_wSucRbQIccVWi?wPn%QIn}4qBZGySZ22#szM5+qPML+gKoo1hydm z=?Vq^rIqD|L5ZzS^{F}?Fpe?m?f<{$`i-EPzeoWvZltTln`+8fdJy_RMksg^^Ff*e zG7dCFSov!g|5q|)0-)~IdV8FTFqoK;7SK3RD-bxI350!s) zg#hT+3minLu5P-)fIN_q1>orVAakA$M-{bcf`T{t?|O4D0R2$B{$=_?(}Z;wx&a(^ zoy(B5J>c@PC#XadP>xpJ9|t0B38p8Z`%h;>1guwFvqBegOU7F;YmKqYY3i(R!h=c~ z&w|@cgTP=m=yvlwwZJklc=SIwKuy-b=RR8)9S*HzaZ}bH&lm^NDTGyg)yAmBbH&f0 zPE~NAGLl+cCs&hYf%gtJi31L1Qq^O^!*ui7od|LU(=^9?Kwj#4(M!3Ur7Bnc67l8nUodE`Ur&R-Z zr_qf63_Xbmg1dgHCuP)@neBKV7ZilG--!-;0mFV`_L!aCU0ZD4^+A30xYaR6M`7>| z^^@%l&H4;Hz;Eef)v`OicPay1dmFv`A*)ZD+bRoTKFt#3ncz#JClkkWK!MjdTRh~W zRlvW6IQNs#dqBUmNXNi^yehQi=~Cm!gf%kmcrJzd<*n=%)ZAGuct@{?^F;30DJM2fd!yTy{m8qmrM>d+de z%s^9{6vH=wG6D@0tTvRtpidxkD8mz$MULkOZlo?km)c`6vL|Q|yia}tf1>q22vcJf z(FZ(IT3<3VLT{1LwFUx&S1w~zX+$y#PKX0VzRwSLqJba0m(@ciLmmP_DzIyNFfbsN zrS$@=-8r1?H4SsY^V^&m+)wQ~=p^Nb=)DjxKjKu_M>cvFuBpQ$tsPXA3uwy_DiiqC0R1FPV!JhqI?)ZVsB43} z>6pqekh&^@U?pfCO`znwwpB!(xORdWLB@*OG`RN!s5#s_;qefLwP3N|2BH|O8F$ES zr^(0-YXbVe8dCZ*|WN2n9*+qDN&k|peowl_(3ZO z5yPao;*GZMxe!rgi7TRV)3 zQx%M9G8TrF1whEAIIBbc4+cRT6(b$Kokgkm6>fhGcG2I9lm=~jf~#1s5gR9H8ZuDP ztdqj<-#VyeA@egmkUP$v(ER{2dZ8e>4-(M!1aK=>CEYTRNJYP&H^da*iO3=N^^q$zdx@k znQ0Q^2Z%7g-U>N0QXAy{zDV+U=#0Ra2AUhRZ)+<<)?H(|E&$VX{L}-j3=6iaUBkF- z^WQuOI?Kqcr3ZG_)5+t|h*Np(kC?eFsun%js4cQw5ux7%1Lgx1Icy-VVt;28Se8RW(IW z)u$00ivs5Ky=(=UYF-GCrU0W1ASC!&E$BE)qT(57b&?sxF}G8Lc^ zzrl#Njn6lCVmR3e_^Lxh<_gU^4^xDHIasVeDf2cYa|g(L)eZ|kK<~XnB=-OroLUi- z07Xg=XFz`*n`Z>w9Nn`35&f3;1z?>SQ9)l03O1btPc(94p zwBIl+tz}asfqDcMV%irB!yW~=EAMW<2w`XsO!;kl1p{?63pP7{(Y!{lT^ga({?xg*r0iExu?qkU9_}UE}*15EjpEKlIsh2`YBqI`}h!13GUa-hG3k%LG@Z*txZT!+2IJa&o!PP+j_c|#(X06^3p zvPLLo5dFaXikm(3(`jyY(ArKb8hve8K+Ik5aNzrf4&yv%t^EI;dAk3?4pZPG;`Qk;}Gc7rg- zVwpR^X>S*Jvgaa$7ZmtoIN&lDx3xM5nW`#)4EA@=_MXX+krZ@*=Q8jj;o=w*L@yUW zmHrIMf0TVlRjRyeaYt!E6#{ORRBI18fC2svXuZjcq9$#`_W1;H2|RhA^@!R|vyIIv zz-CdiJ8*}z=Uu@U!+>5g?G%)}3RgE01!x(~rN}{kO{5ZExOmr&k08d;S|0(eb>L{W zAT-~rK4=46H7VYE9FN zpfDB=kqi)0P0xOdLBXE`K(hGZD7SNBdJGQgta3t%wAA)f!iup5fFGi)>IWsRG0<4u z>3NH7hcd|-z+X4*)GKKVVSpl&T;g zy@e>Dw-`Evkh>=&l<(gA@Pj1h?C#7vWoLG0_lb^{+76ceEOc~qJ1$;0cbSfk5q#Wo zfoUuF_eSMR9UUDz-NkdKuXxdqW47hruzo8ziuByFBNoAMMD1wsxh;=_A78IGlK3U- z9rNddZ-Z<7bc}1fYlD(&eTbGKr<;!CkmQM78jp8fIU=~1jw6D1e_?c^CYIFNd)3U> zbE4~NQQK$ZF$-}vyY|nyE3;(m{7UQYQsb++afzo0ayE#|~jWd4qm z3(}b)i{W(iFa{=eencQ$&_kvRFIT_bN(_Wqa@v^HLysEm={q^07gpbF6r^V?JFkhl z!EC=f^E~ZW>f?TK_I=0c9^QSh-YwLsUzlGC#;{*3fv)2pPv8tQWjyd8>JY#7>hKAl zU@U=}vEJU$NC+v$8=C7qBXA5{bUZQxyN*5EwS`74m27@wkjF!oKnLbXDi^fhRp|F| z@FVzz=n6S#qcO49m?Q44zK3|p$VC^eNOTc^IKRnU^Lm})pz|z(bkZ(eM=>|(3pQ|0 z;c=1G%_iR-A0G2kWP8un|L^Z>(GIVYjKROsf211=cgSwZEaJT#yEX#C1GekXDq>L( zCNn01%Md0B`(R&>P?3bbW&un>V||{;=prbRSbr3VSb1q(oJZWkw(C zk8~aCmC~DKpkqP*9H3+w76|*pyVHO+2$1?Aem3f>Wg*~U$UQU#e12_uBOn{vu#H|o zp{lS9bSF%FnZra1B;LI<3PC@rZhu%i;C*l$KN6!;$Y`mMRB`>)b$iA^aK6T>|LrFoF^+CVtql`N_vGfi0wG>Sl@(>a|!jZ>oirM4L`-b_t;s+ zvTHdxM>os7m4lrbjJ0)_4ju2~i-*@`c6B#9HMQkB~M8OiNK8vDaWrrZ5=sGnMcEYZ&8^S86Q{0BP z0lVrEuDn#}sCeu}2XX-PByxcK8gJ~RjRbzNrN_|>Xe&36LC+<13YR*f^uKAsqfkx< zG>-n+$%8u9^jVu#haR@b8u9`tm7q`9f251!l-LC*d3_{-F8_%1w)J*zUIjYStgW0` zkZ$Kt(kTkbA0Gw^QW&K|Dve(;%cJG)Qby9yFB@Kejfs8l8DQ5;BAU!sE+RxWK7icO z3QYCJM>@m%iOQQPLU_Y`An{%RJs&2>y;)O>vV4Y+vs76aMgfkRdU#bS-;W)(ys}ZP zwhIB_A9*v#@R^@~>&6E_7+}&(JaL6i zyarQVzMF#Ds?Q*>>|OPgR3WRg>(H6PqRujDTrTU_+&hKen}FXB$)*3{*BRymloN3f z@cTew^!^&Z2qV}MO%*~Qb&iUEPZ4*S7emMyDi9RkbrkY99QD^2jvchr_&@jsldXvH z735}$EZyjp0Nn`^fZy+e?&sDzOt?i)qAA&VNS!e{Y?P)6rt~E=ARrzr89-Bxf4rA0 zV_;Pq?F(XIid*n7a8@U-%x-ivkRFTuxd3S7I-WphD40^e4hGlJ1UQv=gd_l?Qg(DT z6nk|z!C)g-A%XymLkZ=~A!p0>Ze|LC*&0l`hOg5CXr{6HM%h1D7^HT8A(&r|K3jvi z>?16U=4rVE08ueXaTKBuSumfClPiKAG-c!;y^DAYTLK!o)&rGYDTrP7*$u%T^k6G@ z;|uUViAj*|_4m(u>wKv1zC&{`35Ed3>q@h$5os_-%dp-g~KjvjqZ;UwhPpIaOI z-~uQc@+W^`;u1;_-}qqFh@64(%I>tbqN+Ys3_%YV$3fhNfFk0gsnHlp&(JGn39z-v z@+a{6BGp$jC~YilVPP9}2k{L;`QB(4f&;d+&gd%KU!I`qr-02*Hb z^E8ytqF+}kkzOfERYuT1-XFGr*^hgfzoEL210lvn6H3WH{6xx z$Sk5-_MdqU(q&vdaLqk^RU7pXU@9aDLD~T(?dtOm{OkQqGn&#=`IR{6xHOAnRh5zS zs}ny}9y(Bt^d!IVm_$fm-e#7dQD4@8QFo12ZJHW`j*{?V`3L13FI0^-^tFf#N4qVTc7{|BAXxE zchd=caH9v*5GwOmiwR*A2%NMHdftbc7!#b(bX$65o;VVpp!F@orC zYZOHtt`P8@qpbz-(;QdX4Tw6NBW1q5^*OM3tpQ+9;wGR@@*YnZg2|A^EmgZfnDH5e zHEB{MWKK~ z4m$t%3Rrwn@>z*B=oXo3Xt9RQ4#3Urx_gwMk>7ycd!x!)eg`0N#?h6_oJG7|YrXmb z_=>9_c+C7Pby#c@K_A}-z5#gY$bpW3YCZ{Elj^8AKW!NV;SPXEmEt}G0TI;0&`%5Q zAj1!00d}izx$FKjRN(kp>(vjCF2w*{im*|$-$am(0DTrP!-N-*SY!5_USp%h0MSo< zGw3#ml=bbD&rwDzV5(V9Nr4&c22)d7%{ivSo6~#>`aqeKfHHk#>&gW%OP_gp@KrIxjg25sG*VpW5%5uC z66%{Xm7wFSk0Ha2*aJGAnPCcBwR6C|GVBm7Y`mfeI*yt?bQ~J(V`^YNjT{IJ9Z4XZ zV9m#Orr4h`ZqgZckXR$kK$M(TtMP=othVZn89od09|N6TF$Wz_=^a7P*a#YBeN2m~ zbj(1rO- zjks9|i3Zar6eKoe`IG@#5CDm-FOO8*W_`S-jnpU^Oh5Ht`f2l?tI*mMIODh^_5kj0 zgN)6v)|T@m4T-XTh7ek2|1lCEQ8K8W10kWao8B4HBN|T$#{d$i-U?pR*F#)cgKG5y za8K?Hz&%Y$32w4C_Pvh+C854dfOxvJV$0U_uu9zW990$w5BLxUgq08>cc=~XjET_SM7J} z&wg@Cz^HI1ltSths{pNq2A1VBhHQ+Kg7$;-vh7aOeMKYs_v?wo>iCPS{gB0KWeag{ z)M0kP%LofFRLvO#y~I=7R}3_{QnPysQ!OSyVS1+pe4v(lBLkgjY6j7$qX+1X(u&Cx z_)l^VgbyaW9^jqo9L^7Hc9*J5!;m}g1arqtsfb-`f)O-Fgi{$0S^~z=hTLgQQ6X#A z;mu-&yeXld*}oV@jqIq+0Q~@9Nl2_VSGCrE1e8hy2U-DupF#Gj-z>C`hC@&v!wfB} zt~m>E2={(P@w2avCRos3f^cau1vm_H3N90v_Woz{0i+)M?Z7v=#u{y!lmp8yGw=f5 zgT{f9W=CIUUYqJd#1ghqJ4Yx3sV>$fX+XGc%<7@<{96FN+mGq846qFU?_iOC2pTlb ztYEEQ$mYrO!u;akVez1zH1tsCRbT)#7|mLrJpmXU z;}i;YVVD7H(KMx?eCQW{<_p56f`-iHjZF~LWZ_vlXqu-dEJ;Hy=r1Gh>Z&48Gi?Ab zS}uYE>M`W_A1~TV(Dxf4#d`Dm#-$`f`jSoTfMFL|PeP`8tnMrCnl(bBC&UN(0|si0 zLBC@o5wt`-uCE$0D%BK?T$aD98x9#+XA(M<9?94}rY* zX?=cmp7YL{Bc*~2CiIuUWbB*lYd0^n7BZ#*n@->WO+(+@yhvk?NkqhwjV4lPp}cs= zzyl&u;$tA?KTW1JR{%uKPa@iy7^tgpG*Y3J>$6VhCy!8;MnAD&VCCc59!Bb$U0Y)k@&HTF85@!5=Xc080Cf65lN{1RX z=#3z3ouM(KU6|KM{ZK*E{YGBE=3rf}21Ki6Y2esKHgAE<#&XLsqhJ!__$K@SK~vnf zT!6J`#`$mBpx?ClcfIH3?5(E^EUB`^@xq6|>rC zmypWR1#N|>x%D+k-k=OxN}>En?E&b(zT^*d!}?TRJ85iLyM^t;FBB$D8ngU#rl!zc z$f2|87TXViEWaMTEs#sXZwTzT0O%*s`%PwhZ!bNcf1mA|3g?j$m^n3MRKQ|ac2h?0 za-Q2*H(3@lCxCqJxEPAnMEhZQQZmqoF^lBo@SK&WL&?89v1Z;Mwq+C_$B^GPM0{2B zYiR6r3k-ZQ6iT(>qJbRQoXxko$N+`-zKK-3yr*|3M+Ajo& zM$4qk4h47@md$sM)RR}pNyHRZ-{=7M+PizAor*-2yneAYl{7#2Zh|CEjC>(48hrZ6 zn6Sg5NHCpPQ+>%OF+ZQbg~7w#d-$E|bTOVYp6~eA8ba@U3-Z3n}4$`tgA!)sVgMGHm#Ve!Ei>#NoEl@OHkip;mryL;qYDl+rc*#ml+Wm9Gz z7wxeBx=KsFki(~&lH;*GC{14*u19$0%Cfz5)VcsAB55L3N!(L*yfLTwqV{A#!}(Rg zC3L|Oqd|liQO0 zR+Zx@i)@-CEE-=RfSLV*w$mup)Nrfj3{w{llPh!@oiCl=Vq2T6$(mPye;IE+WA)e0 zeEKJ2IEt1q>Tm#P<>}8!R#I(_FlGu8CBa)ak5Y9gU-_Tw5=LI&~^sC;?bxaLZ1`cs~=x; z?V6(^(I=IbR7k2_B;?9`PB)S$ZX1d9F)?-?m)vr|BI3Mon0(^w;oXF@yQd>}s~sq~ z^=3MJq_mVozfHtv`NMvD6kgeOb~?V>bHN=>zTtBNeRuYMBd?ky2qnhQUXzF)=l%{$lK*|l8EnnCARMNA+yPm(V*BVU zJW@YyX=Y{^z3?)p+JbD|MaZ|>Hfq-tsvqZZ+ouJ-Y~FB`C5qG+-DO)HSDi~_jlS}i ztdH(X5^^yVtXLQrA}SL;$ra&>=;6hqqjKGY7M+WsP8R@B)2Jd^#sC%-V3r4LH0vOm zB#HwM??0HiI9b%Z1zU9ew`qIv)PU{8A;!3gxnALkr6K%qF(eO6HN*AeaiNbX#>GmD5tTwCd4TIpeB$VV(qi)+mLFTeEfPM61Z+Gbt9j@>za1d(tF zb{&d%gN%SG88LBFyZ*(PXMd3mbLTAfNMBK(hvGLi1>&Jrx1r#};TIH?1}-WkVhcxl zJ>z5h6zEZoZ;SSz!cS%Rb@qgQp-poqz_wPM<*p=(8-B{vfnf^LL}7YS`J7Ctao)G1 zx~@Gk%)KF)IkzK&M<_Kn!&lQd@8+)y{<5@HOC41}(ee7=H}?(KL3@~0cv=EMICXn8$>>icGsx^g3j5X zU!J`Mw_0l9C1Z&WCqJUfM!A(#L`{l3hL3mz)bcd9Obwic57EIGjtlckK=wZjQnvB$ znbf$m#o*8L9Xxw>1PDENr>fAJa2cj2tp6caL51nX)+Hy38;H9zgK#)SOY~d7>S|_K z_OY8#UwcgvwN*?rMCV6{$8fJVT>(UZfLJDQhgu4}9>ZluVM~}GFjuXuV4eZf66al+ zaWBRY%JTFgtBA+eKfvMsfS-OS1=G{B9g>Na%Py_Kwu%Rw?sCMJ?6I|$S)B-hqj8=C ziOKlHbz$l+QbD3TGIOC`i|_ebJ<+k=%=XBCr-cGKtg_O8A%;{&b!Z0yXLpX0Leoka zR)da~E0)jUcRHs2m7mho=uig}!?yNhO?094@3Za5ksj6VTNF71CO;Mdd&Ai1EJKU`{WbFTukF-^~u0z2Pez8QPND3ROTrEkIq#>N>-= zJ&lxDHtL{6ZnKs@C&sVcjs8yU>r#jDy0ez$?WK}ebe%B14L=S$8?6Qf4_PlDp{0`t z4$y8A)ZgtRnH~EApZ~nB!nMmmWOk3&4bqYuUJlNC{K+=tzw((f>qs{DkSee;r4RNc z!RU=3PtEXi-}cG}N3DX0Dx}UFYuuKzHeUG7p%0>LN+)NW z*LL+=PJ`wYj#&RazZU=UIGW~!Zk^rTF;i=iOjIFP9zDtq=deId zMn<}&-J=W#D833Nue7D@Pn?d>Z-bl)!j&Pf+Y_t$5_9L9!5)YAr~Mh*VyFK;ziTo7 zE*y$9lrF<}Qsbp#hb>!HfWnacX>s4}L+o&R^U`WQ|M#JIpKiC>bpP%SBkiDV84CeE ze|!yQb_5Jr_^M+`S<|KA{wKslKvt=TXrTxWS|5i=&^+5Lr=fGjwWnK42xHu$UFYxl zFxz+L2w{k&Y;{{P#Osy1YROj{ltt5(#%4=+>KrluA~KR5WuULkMbT=35YXC_cinpt zh}8s>G9N2O5BWu*r-K3H$a>9a#}Ws~HJb4GNnl7a$oNEwmfpdy#xS1xB7TRZR#E?Z z#>8YP6QY6A5b#}6swR~cq9(=@BwDiG&kxd{a*?0!QUXIMf>aFJ9j>osvb_lyfwRg3B196J8xf?j*-v_mAGXyPI7#^KtgjRCe^zc^<~!an+jK zDptPZ`0j7_J$vT~+Q|WuS`#@|#?!%(zn-{Ma}QF&a%cuCJtvC_UoQ3B*IsONTZtQ*OSX|C#h_q7(GS-EW|2Z%xAcqL z4DYMPc^ z!K`;_@EtGT^2*ipYQ&Gz@} zvawzx)+6uiGRC3@>Ye1JG#yZwyAJy3<)csIL<=X+!cG2pDXdyw&=1zfmQY>8x0oEN z`yM?aaVo-t^d74%9HyA4rTf$!5wy@-<&Gy>V%v1-!jdfBTc;YNH}pKup2b5tR3`ew{X)JpMT5&)Ajx7xLE5_#&Fag~Y*`8SG*jS*hvDm4q z`c`vcHtZd%u79CUb6wUvG-zoH=Q|H4h*Exgb2!u9$QX){tCvYdAfQ1XazK**HH*jtWCgSBel}eL zo&i$n{u8N9pNCXrCfugdZrq<*EH%T>wVtEd#mwzM=cXF;CMG`p=GxK=o` z1uPKm4Ez8U%>j*h7Fjlh@lY3z54dDZYT^b!EO>X?WDANd)Bdyd0-ykU9XDiSfNQ>EzIhD; zaf;y_WnF6v2zYifCGDdQ6o}Z(SV3D)Pt<_s1W*N?{ z;jn6$5WAN?oNi^T*tl>F^#rk-1Xg^-F!j$V@>&goK#%71?;%3Ub^%`-892zkDo!An zi!6JiY0|aa3zU~ya2Yhp^q96|x=I+e;PU-vmd9)QK<&TA0`c%YSlS319NJV>!@f5> zP#0p{@(EB4_4}@SY5bNQVF+0(m6AvU{AOP7g2Y?a&vS6Fean_kzIY4uj&OHSvb%m91B`sDt#~G&stNLg8wiB@GII z%sT+pqdq<f7V3mJ z%<(a+Ecp>!i%n%5s~(z?x!Xr?a-Q#Pz=1*M>{A0PC&3zM<~vSof6jY4&rVef?f8{H z#O5OGBD#r4CO9>tms`miL(B1Kb?i!)LDLcuFZ@a%j?14^?ArVYOVS=uLQWfuDB+i| zBZu*eku$$o8`O2#MO-pj(F+ALS=m^Okuvc)Te4HkJbYvn?=OG}7!y_9**Y>7DMLz= zGN11#X?yQ5RK}t2NNg3~wtqUBJ4WWapwy#)Arq`d{9>bA`hY8(q!TPqDCIl2QZlL# ziRwDn?LOb5rl_*=IYk5xo*WL#h=s`ltN4 zC4psl**DV9F526^VdzXG9Etb6T>M7ZazUTlnaquf^@>p@;(I%N%a2ebj5Ba=f z(SU*KjwCoN0!ky6xtKFeEL&!0n=4BMKUCaXo@n-*!!SPbf_V-ML?(}YDRgPMCSR@g zw@+`VSlrBaV7}RZ-qD|)gE7&rbRN^T<+|b@_nmv42j)y1FB19_N_wWAMmMu%6dyA4 z`@1;O+${bA$9(=dPBouLIxSx6rti3UpH#^V!@JYYEC-~52?a#OeWX5>1eXxM23i|KTK_A{U z+xa(7)17*YZ8NpFZ{_+GO*Hiq;f;l)`x95O1!*?5PX4oZ-G(Mmxm^cbIyjWNI}CRO zUF_M0kt7XPEqCAUG5>;|?3#&vDt-yRFoe9I%Os*M=vai26mf&=RxCK9*`3TQdFY+7 zT&go3XaY>Y{G>lY4{qo5xpiP!j<G= zHH;)qqfI*+dG>f|?Xp_wf24AO;B=(-U&-WXDhrR4V)r7(q>Lx<9>bn_#tBKH+4{UgfUIjh<%4NUVSCSseD4#mOA44}aUnS}V8mD;yUfem!icabm#4qCmo-w|FAuT_09g@r!Da7wPhqMBQg$ zGBtgriYpkmc^(wj?#qJXQdMNbM6Z3xb=Y?yaMU8xP`l)+AwP=+oQU#F@x_;>?jGnR zPQ+{ZPqH@E*8AznPcVvKvgX=TPaIECz)kr6IXzM!j+!_uZzmj@{VwkJ zCD*(^9=ORQ&iJJqw*}6yi@uZDPQho?XW-faDht}=H+&uW2fyqoJr`E3JR)TK&Kjk} zr&sf)Xnbi{&MM8kD6bj+TOg*R)3ZOT+C7gucNb&6*9(&?$a^!*9!meR!qx3Oy|0Wt z^Exw|r0uWhKW~DyHxtJ-=vE)VW{sap>2^Yw*OV(eFL@N=v=nle3dR#NoLsv@qzsVD z_w0;`^Bs10w+>vmV>(gK0aB-l92aY!J|S%<(QuW?`z`)R&Ie_Q79x2z{4Ajpv-u%+ z|D3U5sa{DE)74n)=p;uacI@>ZFC8);8ENpIth4Hvws^(oTk$+g&Y?GFf;F`*ESUVH z`p@FLl$zq-4y_iu6g+VduQK;TdqlC+eZSbX0b->2i#~$n&d}*`!peLsG_Q&Uo&Sb3w=Y{jN>LTT)QxR$ue z*e~0Rk?)o{``kP4kV!)3Oy^YyXJ!h-eXYC531=^JIh+6c*`DmB6Yei#MsB_`Q>~lV z9YAzj*3;4=@Xz1J=@Z+nD7s}_spMmCcE^!)V`Dz0FXh07k4`>yNjN;-fGbz3;b)BQkV2xoLuc zlKT>Na3y`|!)JI?ksn`~Agt>Rc{e@gHol?N!mks>;Z?et#XVkKGNqVaT6) z3LZqhJAYz`B@wCmo^zpvCw>r1yrzA4oj#f>ko=V5Odeh>-s!s{Y#!UETte!?@3T=9 zFClmas8mkKb{R5(Gk5fv%-JH<`oG7BkrrJq9t((SE099kmX=j?KX%(;NrO)2O|x(h z|7ul5p+Heiu9`VH$I+Z&Qnug!Z?Q|SuqOAa(H#rysUzxHNz2pG(`#jK#A4D*<(?tq04wCWbX9}jPN{r$Cyp2 zzzLS~srn5)=3QzB=0CjbO8rE-Taf)OSy*PJzdF4JS1QA@-TA08A9p0qwC`6tX1*u& z)Qz4h84a{^SK;NR_)nicaj9Y!WVp`uxDy?mukF+M^SM@h%&&jtiMFp#mpclBl5^>t z8W2)e%KR0^A>)Qse*dO>@{7NTqlKs3o3am+^IZ+^an93f?WRhGnTK^2FiGTY!DUR{pF39Hu2fo0-F%1edh zZ{e8a>U`2UdIgW1hSBiZLNUU3G|6u`@GM{CPQURwR;<@)i`4`un63={Ozjb8R0uI4}%Glk;E z?9msLNDstEyz}&Oa$D3B#)%2$`{#R4O_yY3IjwY>0kuscok#c`5v{kE{gWld_#uB{ z&*ITR%zQiH)UNjx;~fRCv7-8i$d>nTkZ+9Vn%zWI7uP#}o~WEPQ;ErJpx^d^#1^;c z^X$U#(wDm)rts=rD(206DZ|M439zi4$4WmT6FZ3aEI}b+R1f&_l@R%CA04=<%)!yF zZf+dkXB+>+z3iaWd={}mIWk;EEm%3bXx#5}Bl!q^zRM}Odg9YKF~%cHvJR&f*SF}B zX2Jx@A?P!IWMdP0@I~`+T6w|-8Ix&|1z(-#d^`2iI`S6U`8*e=CX7>gZg%H=d9`4Z zay3=HBxCHvh&`|Kz}Qf1bT2s^+3o6$RayS^xWoYWZ;mt4>-SW4{K}2Q)9Is2VsD6r z0oH{d6O^aokdtzelGX9#4z=L@$;}Ot+CDC~k>6$?c1q>;y+2o^G$U68iiXCsrR zthE;|B=S|~ihKN5{Jp^;I9949APWu;7oB1xS9MzOgi3ZlK$DWOM;?!5nPRWrdNTH2 z1mG-Ow1Nq5vhfcSm1){x=2SN8lPz{0AHeH`jl1r9Aacy+sUIOi0EA0tyKDzO*S8CE zk$%rs`t^sw%j*ltg_VNAI*1->Cvu_v1MPoR7EUd>g>bHTke>O$936Ua6p&uWIbO@ z+9^fk6C5${9%AcRsX1qF;n|=BeAVSYf6lV0r?#cp9=HlXZ{zo0Ni!{(z38=bTJF@T z)}^mwldo{l%+x83JnU8N_9%CkZ9RsYflK3au4neAB|0{NLOsr&IEoV$U=+ZCKt zyasl3DeJk{y-zg+m&sJ3=Hsh&H{-U13S;N0{ct;C78aaBits_(s#NU?R=Qk}Wyz;5 z+(aJpM{}$E;rc-6*h5yfDhaU;SDL>h5WAG7)$Y8<6KuY5(c^xNBgy}^U7oLgr?4ZP zomx=!ODI>@wX@jPHRY)n?03P5hY`- zHSKHfdpMEtc|u!p(KfxYZyVjQ#fo>tysK4emVNC_d!ia_O8!)e-b*B#&c@abxh>-C zzPxqCMEJ03XhwL#xtt0sIyJK@=0E2>wIm!OyE_rnb37u|b_a8-h73!r9Po(0*dmr( zVU$MbJJuCaSm(XnM0x&Gw?XO5;>-hWKezf_4_@n5+y5k(@OgiDIr`Pw)uCJA*y4Nb ziM_%3#ie6uEn5mp@&5ulVICpQRtDD;zHsMOSXmQ2e7SaJ7%#&%*ecTBc zm7U4oN{3`(wM<{yXOC-1{&`nZuU$l5Pg`L6LrF%Fztx+Bdlz2k_<0woCe?TksZB)n z)m3|)ZOX7_lPO5`H1C#67>4VOx)o1c8VitC9^@(7=`~(g9bl#OdT!Qh8d#Il6V2E3 zjKCgbPMwO-@*Ay-9sw&BcEM+Q0^f;$GKzUi&asJq-n;CJ>^Q!)$|wkfW%F8;K|h}? zB+VV2o#2PLa~kTS0xQ)mgYkGd+OrJX3w2(j*f`?*vgNg zkSZnp?lyS=w}i|UtJKI{^CvNF_SL4Ew3WEMbyw@!K|Ok1vxgS0%>n)4pBelEFRjxW9is z6@%(*@EeZdB5Pf@;L3ZtjN0X2<+AWw`+~DV=m3bNw42AUbKN5kG6Kxx7aGg0JcZP` zxUGr}118vdpPNs&p^}D*7p#R+WR9o@js3QrH(+x^B2B}SA2{BQw>=PBwJ#Qf>V}&V zFdaR{U6m7UyODc#8yW=>OUn7k_gj?dug5qgt4T`Kv-k}}_eO1EWZ5+b^1TMzt`mkLFU2IhTh7pQ&bTO@alN z$Cv!^7lxuYMgp?-%@aI(eAI7?d-j&jVy)BEoPAqP7vVFCcBoCX6NJ)7eU?j*m=dLn zlW2Tt+nlVc=usEVnQP+CNI}{J2DKcCdBGa zmg2(d3wOihSVd~(YUpHQZSvA=v4H4Y`xE0VGKNDv9-ae>*hy`<#C%>2b_=0 z5-}32B^F~(Z}Y4|ohU~5nT~V_s3f^t`UMg4<7F$}Oh^Uw`8pd1wajSuH?8!3@@G>N zoIw6Nn69A(63e!Irp`x8Gv!KI1=~btcki9|Z`7@heb!Y;5Efh150)_xZY(0CNL9Y_ zdM{8~=eIJ|@g-XxDc$Yy%DIRf>KQ+K;!=EAEJug?ywXkmWOA=t`D`Mlqi2@0t``>WKi8^LgEje|=Ws^P+dQA~NPkc?TBnkUW55MsN<>d%LEJMhA>+x9y#)LCc+47MR(s>G6db8n$rjt0LWoDm8O z&o&Qm`_-CtTL{4)kds7$on*0?M(T`ItMn|LJ*UQ3H0xXv_^!H5wSVSmw4;3}H{;7i zRC_+3g&jvjM4~R2XYt=%yIwm6aoQm1MDxt1W;2pyiE?d2;=1|KXb0x@=2?|4pE-+= z1E^+m^&D-==KS{kF-A?gga)gWN{J6CGwo5_CzUoJl1!MJ$z9D#$9X8Cf zn4zI(QKuNjhDSM39>D^{|8W?P%at^wynY|UtDaLNz(c9g|HiH+CAD(3lbB`vN;U1k+(kzK^e@ukQ1zR2g1PzXJ` zvVeH*?>X<@9*ate$9nmv((OUtSa?3atUeSwxgcoU^RTd+JD1O9Fjv&qOgYneZ2r2a zd!2eIp|w8T@7%katx>033xfq7>MlPHCET^LYG;eDkXAp%c+4H%qK^Alt@3VqC(`dx zOF5fTOKN9XcjAQ>M_%$u*BB1``7_pJYplnB9W#1(`EUh6o@F5OMND}n|4R~ zDdfV-`+lW*#XRv}6v6#C5?t|!Z*if`lE6_utvvrW=Nf6L&9kqv`lfj4zvZ^lTkZZg zZS1TxS9%o9mY>4B?su>Jy$_OjUay+EaFKIC3M`oE`J&SNGO(kO`^PoGi zlHORO4=e&1DE)JOl$ZE)$Ti-N>}#UZ%RcYqObia+id@W&*CNDZx8ABynIPsYptx18 zG^ZAkcJ?gUzK%B~mYZwd&YH+G$x}v_7K}-|G+^J?bY{E-YJ>; zxrrxSmusb?6R$NCu*8`TfAu6|%M%h|t6C2l+J*^9O;7TFXg3zD!f*nb5WHNHqz(AZ zLzIS&isKb)&Yd2=2a35zF!*JQ5fXX9Q^;H=7ru9puVBWG@L-0(&!be~Wka4&X!a#f znvE1;OJ${#WXJxzZScKjS5l)w=vU%vNo-s!Q7?_?!dIO?JvCrcDi<*;nJg@hKIuQA z2e-JhxMCf-=f%xpi4ndOVx(0=bih<+ujg+@3$Yu;0kOQq4=HYrdZezSa^P-@|KKe@Z$h@k@>ku$N#Aisi-qyKuKTJ8v7-J8hBs`A4IlR;8udsYjyJ@M zGz-_h7S~RR38hFduw%fP#CI?r`)TN%NdH0&r{#>bDk{hN`BYZp4=DcyEsVChS?4x8*k7VCLT z>96zmCawAkttEm4|5f5}XKJ~X3BSL|8-Hm15VuWf+FSeru`a;Lfs39@Du-Xd2egwN zNxqBy#0IU2jA_Fo%c`E$(f$)ss6L`8ZY8h%bT>Zp>}@pj{su-h|9dIzdvP6e5#j-* z(|%)P7Au!+Y8Fu`htQ76KuZ+HGY=hb?;*s-)EYi{DW*LCn=NU6g;ZpWYDJR9&>GI< z-T(|(^$ov7?9safeBf;w|K?HmYL36xZ?UYDoWvzGf{-7{83c$)G5#Lxw(AB(FL4d#jY`D zX^&zee5_o|`C-Sy*?H8?ZX5Cz?qVP|ui_`QhKV1!BHxPZb8h=vysU5E9*fb^+!261Y5!9p-Sn;bVlpau5^roj&w;+Y*b`ZD;(&ZW zc@rCLEx`A+rLS+S7_QQ(g2zdIa_-FomBGBBQex5^`sLibX_d3OMU@m1L(tR=Mb_oG z*G%gCG=tx%`;-{x`RgWUyr0*%86J-6_?eda@Vj-Bx6LXhhH7BS$C1sm?Wm=vD%+Gj z2Ghz7@wvPwef6)83-|qcJ)Baaq3Q4-F6ua5`4%8ob|TByd}r5}p>M7-VPgSKw{P1y z>e=3QWY2C7^KZJCZ70R8$B1!+%lM-YSkUjD_m@&?D1O^x9^O5zr|s+EQ?;+b$;rit zp8nt~VVzzjqXOr#*s>Rl9-Z#(T&e{q5+OcGFfnH3d*lc1lEK-LI_wgrjWsaQLRM9m zFzZB|m>I+p%V(AiisdUgQ&7dE@Z_h?=;t8ys(mp%?EWVeJ2f^U(@PkNT@9q|dYAHM zVz|+1v%0Nb*W1SOb@QN&&{#rY^<-|q&+IsJ6y0yGj zI!G%T91mqo`ppmfIQLEm(o4_%B!r@;=eXozU6x$s@m_3=tvZ@<&Q;U$iylKQb|pn+ zk}dq%p*ZZdBJ2a@Ua9V!S*JXV$9(m8XYcse>#&#kY`3vu=3u)@^dpD%6mCVLt7@R- z3ZDBQtA|996_~(ck?;oJ%G8^vxzfkgFZPPRPEjI;^oG99bf4|Y9kF)uvI?%w6f}-q zNsP={@n2qouUurg*MYw8DC~Wq7_kFw7<_U(!anA~t_H^MgrgY>Qk4Q*>zC89c1j-Y zr1#ed9UKqQbKCI45k591ifWIM@ZZvLC^rug=cBzj!KK%Qd{?>-dZ-ADh;7fEk902Z z`PEfVI@4d+arxZ#lDNY&`(@&!xFma*m9-QXU$vn_Wmf9(DPD10WOMVyDpWG6loYO~ zC=i7Ws2Ll{tcGJGm6q;v68ZQlYdj`Z8y52??+vPWz{U8$1re&KPQZ&<%baSWymY=yn5vjVyTvdKGO|wYss5M z26d-vK_DO0=Q!HCFW8fW!(C-X@%d}VsAht-o1}%|bH#fPl&C?sF$E&@7?YEeFBgAv z`}gh+!)T!q)1aS5uF1KYq^K0s^-*JM_JsS_J%2<={kY*4D5x3;s%u(gt;zHQZKpy*Hy0#P52yw00+mPV6|JkckU`Zt zs|Q9`YocBq1djLNXXr38!g2%_MlA?j?L6oVXv#X!Vx$XOqE!M@M}_Ko{(6DZqiUQz z7p+Q&Qrk2vYDuXldIfHvGdz(wNvl_7*virPkX8YdAOa}JZn8tPmQjEWev7I zUVln;q7+@3B35sNqroN!ZVbxg(vKCpT1KgJqWunOzzXaHZw4s;@+t|X92KC93DHEo zWTxy5gaw1cUK$pgE}3~H3b4q_T%n-6;ovBxmxPmm;-*{fifMs!Yu&DXpbQJ{2>i3~ zD->MVNWm9kBq*76^|)UZbnDi6`?G7+dv2_TYZX}FKt1e_?5j{p;UX(*$L6v$sG{rj z&(!JDj5%wUW2}Nik?qDSWvD+hoxF=(e{nrqx1@YvAJw9cl#BN+JXyb5k}@c`Z9wo?c zv#|sAz2HpBpN=0pHmWG49aM1H3SCc8Z@Rfkvw*qg5!Cf@sQ54FB0KHQ1AZs^&NW@u zxC~Huu~Y?6K)ht=r=Ag_su?GArjg0lbz8(*aVnKosH!#fDCjqRk$uw*q%IuKwn1cF zIR{WU#Cecb-H3>TeWe{JVt)hOB>BOnYyq%+>u%uY6X;+O7@Qb-@6=m%f+iZ8&w}b4 zXMqn$l@VCGh6H+sz%W*5L#DeMx}2i%1{3WvfoG9{_B5LTcOn9M9~iMi<)JQnp3zFg zS5006R5IR4T>iDsG&I5Q0vxIciec!doUK^nD*`BYTE)qJp8Iuc;H-XtYO@U; zfcv=J5I(%|8FUg0@(Bo4p!>q|lHX~3&<@*BNWTi*($N+27*abqrkWtCvaGUo{4jw3 z?WY=W*))x0+6d6}x&+v1jX%sm^E}YAgi%-@)j9L%sg#aJ=P>%^<8+4Eunp?l!Bq|7q*W1EQ+h{>(HOi}ntnxKWK8A)-!- ziiUOFP-Kx14VQ9MBqcPJ^wlDro26!DsEB}ylUs;psFg}qQyHS5;S!-%+Nrkz&UwzWpYxpidyJT{;#PA6f$ClJ3wlIhJji*(vR;kdC8ZF~i05O+ zf~|Nzvp^+BA)Ii#R<2%t_rdl8^hxl7Ttw|)7{`;@9a8ibo|-7dkcg5ApQ2U5a+?_O zyAa#MoC24$68zs9^TY(OMOtg!Z?xBy0A|>B{-YDU!WBFdF(qj@vG_Rtq5^ENozVm08p_jIw+`Wq+cIf89tT5i3h=nKGn-xfD`ut$4Z+>6Z`hP z<(ITk)ok`bvDo)PRNsq<%9~(<9Dy6_&HyKRtakh?I8oY0bJub?abnWu{osTg;lJ2; zqdEd1CGAef(s{17sw$AhVKL;LUIkIVJn`SQ`Y@-$%Jd-d{JhbxkPF74hm|=I2TmmI z>M)X=r#EVBasW6nI$`*%1x?krBKRQWiGQKg=eIk-eTACPvx*MRP|~lGE#O4rV@Y+K zOY%YA7Ag`3K&tQ#&g#dnffH_j%LKj@CV2T2aU!F%w0X&5RTcOnaukGAarfKz8tb1W zf8tUyFn0y9xH#rLy2!7P)5#en)Lrg_Ormy1Eg>!&-a1genMrYF>*dm_E-E2TQt0Y<$?W*>PM(Oi&Zp&ZpWoh=TEBC$sTJ%)IOs4CT9~jg zN(kkG?LVIRX)1+NMulvl)j?jT@Ylt(I zFk{DtgL(d(V&J-Jh%@57MhXX${4c4mm@r|J>i%-M^|;?-MMx2IlReREM01a9-6mE; zj44YsA0W<2WW|MvB`=AG-}qhnCjr%{s$P~&eVJ=TnPnwH>EFvA4aj7amH(dG|Sy_iW5Kr=>O!0Slz)7DUBi>b1B6q1jO z9&5kpu;DQSF)TmsN%?Wh8<8S+2AV4YlcW?@)df9nlk0se6_B-V*q>JCytYZa*QCdf zmB+$VI5HFp)JqL+f+Is6I~sh<_#zN{7i%@rkh(Q{^Oe`}g{svD>`{!g2{NNBPy2WV z42;}=oaZ^Q1bGcj%8;S(cGOgr7bG!`3^nU=TyHg<=iDZZI*hzDt2U>~fUnCDXliUw znuQ4;MTr6R2sD+?LVka=%%{Ms-`gu=@5I}w)w44f4XX}RdCxwO!^kRzUfif3i&w+N zGR21`L{3e5yl@N-9R!UW2B+V1eEtP&G~{`f+eRN%3oRWr?6pOLw_SaI z&Nb`DVlJ^BvYTv3W%Uz(sPMFS=ALU&whnS5qG$TCO?;~x^{jMHWkw?LfP+H)Xi?wz zRi1NDsQNcne5CB4bo40FM8PHN_TN?P+$6o@?^WLyuZ#?6ed5()RmG>v|606Gderd|!SgdhIcNJo^i*(LRH z6NYq?;+!pFzTjMq$-%^_=wE2CWPVsZQ)eTpT`vvgx2BF>0F$6df_P(#s znJ8>@%f=t?@~mNX%q0a~!gN<_%hW&(Unu8T`Ho>Uc-pzqe8KO%{8uyk}5k`hGVO7JudG~=)A!5Dyz?=`WlXK@A6PI zgqgI#QJKH%v(zkp43*ir?2o}m;_}~UlG%6f@h?~P-FQ=|c@m{1D!uH3B2_?(SOXh=8f2?IJ$ zW7uf}CJ3jk?+JsTKLY#nt#1%g)_JdGt3FB)kfC!_%|5};Vt0xR*rH28_&b`q_QpO(Y=KXuF;y^;gMh`7!4sxgXu(TkV& z)U%)Wgu!UzF!1LwvqdVP>j?w?e+K{X%BOYMQr_1S1~P~HUi0AcuSYb{6Q(j3Aou>R z1w*HZU`<9Rdcs(JBO~zobNI4GoF;uw7@U-Ug`WnTrh`-2yC)3#q#z()l~XPzHqa9W zk{u6TEIN8j4Xa3sKYMCsQbhKsC{q*PsKL@^WlpCZ>y`_mM2490!cjzil2<0bxPR}3QFn6bbb(&wgiTha)N*`p%H9M`Ji75 zwzF)E4ODc~RU+`qrM`;=bQk#TBvJLA0<)Df{TDCM2B485p+0+jlOLY_mM{pRUP>Iu zCrPIER?=_rL+9U?SmtpCfRPU+)&qG+p~$n*=WQMpK(|vvLojUe+4zkh=TOs@Ly7yv z5s&cO$SVe*H33eZ*I!H%Yb8?O9_AUMOyrEiisC0^+c+z5j9Nj+RqVeG4QjxBKX@#I z8W{0S#sr}og4c@Ed>SS(xYCxo#4a;Ok*i`}kkns^&`g%I`?w!>cx z`+Q}5xeY0ma`BJC^TrB|KV?p(<{ef11y7(4}^5sq6<6NTy%90 zRs`Ung;&1bM^KK+%Y02T@rZ0=%h$F;R2EPJLRFx0)NP`RdFt{-8q@$^zM^1DKvrbR za6-@(quauhq+!bc-IOW!Z!t?8!bV*|PMmK6-nO`Gm4W+aIY^WZv-S`FiE`)|;B~ zy9umeLg+0pM!p|lHkL#kI6ZVxYoSj<77u7s*qaI8%0r( zkmfb4l$jTX(}-3dEEO5$*Zz|=4~9NTKA!(2-lC1sQERAflS701ljhoXRc^m&Oo+Ha zog)XgXy@ZPy;hLL6xO&?F>=b>St9>zkt#i*Y2pQ#O#Zo2>I4*-=y}@6Ot%w1eXZYH zmy#YeK44sSP!G8oajgy8nA84s%!Gzg4R~#xiT_gm#;QwKl4<`q_Yowa zfi3jiT~C8|HDf$<^ecFR56N(|&V!x>FMTM^{P?6S(@Pi%|DJMP7wRMpd3GX}6Z*tn zvruH+z;6SRc6v^2EX9Sf=I`}0;x8>A*8{E}UiAIT_&|Z}8RDb{6Dv;i>IsDx&l7`k z88}}7Aau_TtZRY2P3H_#@tfdUZ8Uwuj@<4ea>s9tWe16`d5E_k8q#`Yn2a53~mNa zY;^?akHqcIn;9;+{3fKDly~MVTOc6r()p?(KW8^$(u`i%_v!chOqE*f0!0O1PK<6a zdrut%MT_X(M(hjdH#3{&vc@I_d+^i!?@QcVm7t;0?VyGu2T!&x#JvSx%NGJdshg76 z>PrhX7s`I8I7D5kP)HRQkY_Tl%1ed5{HBN4^|POckxWO;2Q+?7Q}bM%_T#ba2RFPl z@TX)=1r&N%sOte@OAjyBWy7}0eiCaM;3oF1EcBPTDctd^6u^qm&+~9Bk3IDu&gk#k z|6)$EHNP`js~0XFI#E!eI5oD|N_M&EC>~*C6NKHV$HTAjSaLlumqTRHQZnIyM^1bM z;8fCf%XN_jaPoq2bP)QrwFT#ps!2+0Ek(YZ_FZ9MbHP~%bXHRv+e(xmGj(3rJW%3P z{^OI*_(~MI`+y7I6$UKBMnl&GmVcGHR>l`we{4{3btOWka~LhjGJ&PUyhP~B^Zt?# zU6q=T0NSXzZ1jybt)O+hj?Y(#c}rm4&NqYKd!Zo{Eh)#t@17r$!w6L71(M|3%75KE zNuq08$=og93bsA2G6dU1aQDTefdjQNzBWiidob`=`lSZ_?iDKNFB%opP8|&BlOZ-% z*e}5y1Lvmq2Tx~%X&ZipyG6v^HO-$6B(TmIMmEYzKvBM5*3W>03F zVepO7P|w;4MOJ4Pio9W z+7lFgTo@s9ioOfBDM-+Yx}YTvL^^*d`rk_TBa=nokS?v}{G|p1_?2)x^lA*VqN#($ z8?VMNXrnYxYz3g=kANc97$$jyd4@g%0FS6KOo*djxoV#L0~0CS2CoYWk!qM^ye=5b zy@~f2`k;R|KLm&(vABas!jXp%YR(wSKh#q_!x}i{?4m^KuO_mIrB+MLpsF;2Q>d0F#l$y)I~e2nz37jG*t>;p-~8OA@eEd~14S)5CEMd)jL zv4PVg6KfN?8A6 zgUdvs<~*{iQ!kw%Eexz-fhfHrR&wE^p*)JFmAmM;gNyw76YZ$3N>CWVVrZ!gT&r0eUe!#onY{91 z|E3w&O>7xNu&0JjFqm;7W;}UtRzFRl6}PJxOd=dr*8jFuiK|9Pad-Kovkg!y=x1HY z<$PwbsI2I2@}i0bawZl}0{U-%i=j{ha;ibj@S%m@?zpLPj(w2xf~?r=U{0mlC!{2G z_ep1do>%);@hF-bq`MY(7-o=lWNcNQNxoy*u8W7nrbfO|pPTAjz4NtLq>Mg!pUGrN zq$=TcyvWSs4Xx6e&KyfCm}p()lvS)B(Bz%2A2bV%qC9uhjn3#LdCJm7`#Y(FG{D;G zSyNVJXuR=$kZ;sz>AJNDUaIcq7}ww0kD^3(NcRqy>^o~;ep6Y39OhrC<32&rukLm6 zzALS`0GlgK3~)6mnz%)m-O(@8v*ka#r10V66RMa%K2DCcSve= z@85QY)r>V4da-n&U(`-hU0LsZx@W8#dGba81KSGd&ac|Xgv?bd^0mU5cHN(=!Fl>! zjn&*+BY84qx7in;tG5?hCpCrO%+P-3Orb9nV?fa^qsPt%?bP- z&0mnIicvYgzTwvh0{)`^5OEmHwd@{# zI6w5+C$D<@`%`R^NK<4v@ZQ`SeIw6 zCCJNByydd|-hbxZ|J>2OuSftK@7dml;9T)Rg5C(+`K~CYvUazrvAv}@m`@GK)ozt| zhDhe1xpJh;MvUfh&u|d5PoVB*Qh59@dSFKmE);PTL`| z0dcQ)C1`4&fl&){vmuWY&2!b}ax84dAhUdQO41X#y4b9!G){pBSmt8XpQ@YaC3&^k z@65o5AW{aW#R}f6w5E&o+Uzzs(b&9dQpN2q!}Wa}4~Dxx>Gy$=SS;yDt6d z46#<4Z*Cow20?B=y_aV^Ou#St>|-fd2*?STyj{p)w*MUM`lf`tg#|~uA>7J+EX&1L zn6=2b>EYO)I%|lrJ2Lq3g{z6*KIB86-^kX60@Y`H=djx*k&sC9pT~-5aMkvEn6^q( zejJ@L=;GC1KJB?>2>V3e6lX!{DR+z}dF7F^$~j$sf4~G|9XI&X0+Y$qi1P3UOznTz WeX6G2ntJ@j6gMbo;P(E`*Zv>ur7w>F literal 0 HcmV?d00001 diff --git a/mac/Resources/menubar.png b/mac/Resources/menubar.png deleted file mode 100644 index c1014ebb34c759d1c7963bba82444a3958215708..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 123 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+1|-AI^@Rf|Cr=m0kcwN$2@-}6Yy|$R3(Y^C zexY8-&@X}E2(L>L^RAYOOaj&q4yZJKuQ-(aw?V?ibbTOifbOrA(-c-tb8Qo3V0e@7 V<0!NBu_w?p22WQ%mvv4FO#ldBCY}HQ diff --git a/mac/Resources/menubar/cixTemplate-18.png b/mac/Resources/menubar/cixTemplate-18.png new file mode 100644 index 0000000000000000000000000000000000000000..2f04998dd4765863ea48e797cf02c0ed40451cc5 GIT binary patch literal 220 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+1|-AI^@RheiJmTwAr*{UCUEmLDDXHR{Cz(% zdUBiLHVFrwQu}W~dr$eFFTB9JI4cwE;K;(Ymiqr$F-Y>YjVvd&(8 zzlgQ8`Q{^y6ZK8&oj1=^*7JV2C_hS|e$|urE7f8u#dS~cv23|`uHA;2SNx*Dlyw?g zZp~!ex>g~YvGrV-L*x9QG6zu?u7x>`aSd_&KsWvWzxr73Hz0!zUV Q0u*8JboFyt=akR{03f_nfdBvi literal 0 HcmV?d00001 diff --git a/mac/Resources/menubar/cixTemplate-36.png b/mac/Resources/menubar/cixTemplate-36.png new file mode 100644 index 0000000000000000000000000000000000000000..0b3deb632263ab14a2f15c925049b543e02761f3 GIT binary patch literal 276 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbBPI|gHhEy=VnQ~jGL4m_%_5c6R z53#EqKJD~NLAke&bM;}lwXY-IbT(aR+@V@B+4zd5(Y+YKQ=J)WHpeZ_&59A6r02(V z#j5rBR;h)7Uz}fOvwH6KaXnVq_i0UV@2sW}>9azvjvXlivm7~Uk7@L0{df{96Zv}o zA(baT3Oy!YtMYpItV!T@!t%@~5&1s5t_U8yC)cj+v~%(knXfVbf8W(r>`wN)RI+@V zLdwF(j94M97zyTO)>~B-g)b<$?07PF(S_Sh7fvtww@qdtI|IZ2|ECYdo&b7^4df|i Y20n2OPub3YWX)7-ZAJg{3)QlR)*s)3s|1NSu;S?I zOyEenwRRe=79v|3jASnPd5gbE*_C!0r%ckV$iy4oV|KlyxC^~A7;kQ z1FL}B-Ec2^1T0%AcV;3_&anOncvxU{01GVEkbB5lSl|!{rsxV_Z8zFOSbsy@D}W&o zG$?^lQ{K2V44QKv#54>Vc88^5w>mI!C&Su7yewlU;0v7Ak-(ajnSGBl{09I4|Nm7J jp7#I%00v1!K~w_(l)W%ngg38!00000NkvXXu0mjfTN;PS literal 0 HcmV?d00001 diff --git a/mac/Resources/menubar/cixTemplate-88.png b/mac/Resources/menubar/cixTemplate-88.png new file mode 100644 index 0000000000000000000000000000000000000000..181e3e5409a9f78f316e61aa09c38fd7f52ecad5 GIT binary patch literal 603 zcmeAS@N?(olHy`uVBq!ia0vp^5g^RL1|$oo8kjIJFbR0NIEGX(zPWkP@2~-n+rxvu z_Zv^?_VW1L75saOj%~)}fLVV$_eY%Kn$KS!z}Ogg@3?b9V zE*JDNy!-RGJUxv0dIbxk&AzTycMhKXbqAOZGZp;tU=9CQD=Zr}bK9G@;n$|keH^l1 z@IkQfo!SdE2fE*O9Qe@2Wn)=Tvg1wel}q3M+@C*_x%j>FTJ1Aio1CZZb8Gyqb6BMj z&Jz0ZUGd7#>I&t}W|IzVzV~_QYaxkeVn2iuDkfPUzrV5RyZWyEcG0hrL?67_K1FQH z#0jAd?(cqH+Q)SG4ik!3fSf&j`IQ2ZJDTE8$5uYgJ1|pyU)cZMjN2cj9%4DCA^M9W z=D=wlQH#YPFO&-y^^HRw^aL&JKft=@OhDuOsYdsw*8j5U=ZRwB3$U8a^6m=Hrdf^6 zl6m)33}P5iZ9075J}_nc|KGAUL>|as1EmdS2B*}-jhv6Q-vdP$JYD@<);T3K0RRar B3j_cF literal 0 HcmV?d00001 diff --git a/mac/Resources/menubar@2x.png b/mac/Resources/menubar@2x.png deleted file mode 100644 index 995310b27a2a10f0a832de91248baa18df5ee8c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 188 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbBnmt_{Ln>~)ogOH7K!L}3VpEH! z16NQ%3(FFPM4<@vg}QfBqwmH3D^~q-X$r?r$;c2h?dY>17rBDA`lPXXo38xSuyE1b zOFSmVYa8D!+8B9PILB$v9qu)nqGC4^|6EFMS$TM8`mN`@2W=%&R&d_DxP0L&_7KPB m)f);_<=^o%*@$pfs*3Y#8|(0UXVe3o!{F)a=d#Wzp$Py}=tS=T diff --git a/mac/scripts/build-app.sh b/mac/scripts/build-app.sh index e3e6f211..e83895a6 100755 --- a/mac/scripts/build-app.sh +++ b/mac/scripts/build-app.sh @@ -106,8 +106,17 @@ cp "$SERVER_BUNDLE/cix-server" "$APP/Contents/MacOS/cix-server" cp -R "$SERVER_BUNDLE/llama" "$APP/Contents/MacOS/llama" cp "$OUT_DIR/stage/cix" "$APP/Contents/MacOS/cix" cp "$OUT_DIR/stage/cix-launcher" "$APP/Contents/MacOS/cix-launcher" -cp mac/Resources/AppIcon.icns mac/Resources/menubar.png mac/Resources/menubar@2x.png \ - "$APP/Contents/Resources/" + +# The app icon is built from the iconset rather than committed as a binary, so +# the PNGs stay the single source of truth. CFBundleIconFile in Info.plist.in +# names this file without its extension, as macOS expects. +iconutil -c icns mac/Resources/cix.iconset -o "$APP/Contents/Resources/cix.icns" + +# Menu-bar glyphs: 1x and 2x of the 18 px status item. These must stay template +# images — pure black plus alpha — because macOS recolours them for dark mode +# and for the pressed state and ignores everything but the alpha channel. +cp mac/Resources/menubar/cixTemplate-18.png "$APP/Contents/Resources/cixTemplate.png" +cp mac/Resources/menubar/cixTemplate-36.png "$APP/Contents/Resources/cixTemplate@2x.png" sed \ -e "s|@SHORT_VERSION@|${MAC_VERSION}|g" \ diff --git a/mac/scripts/make-dmg.sh b/mac/scripts/make-dmg.sh index 5a4ccfcd..80f75a90 100755 --- a/mac/scripts/make-dmg.sh +++ b/mac/scripts/make-dmg.sh @@ -4,16 +4,20 @@ # Usage: mac/scripts/make-dmg.sh [path/to/cix.app] # # Environment: -# MAC_VERSION version string for the volume name and filename (default: dev) +# MAC_VERSION version string for the DMG filename (default: dev) # OUT_DIR output directory (default: mac/dist) +# DMG_LAYOUT auto (default) | require | off — see "Window layout" below # -# No custom window layout. Positioning icons in a DMG means creating a -# read-write image, mounting it, driving Finder over AppleScript to set the -# background and icon coordinates, then converting to compressed read-only. -# That needs a real GUI session; on a CI runner it is flaky at best. A plain -# UDZO image with the app and an /Applications symlink conveys the same -# instruction and always builds. A pre-baked .DS_Store can be added later -# without touching this script. +# The image carries three things beyond the app itself: the installer icon as +# the volume icon, the designed window background, and a Finder layout that +# puts cix.app and the Applications symlink where the background's arrow points. +# +# Two visible items, and deliberately no third. A "READ ME FIRST.txt" with the +# Gatekeeper instructions was tried and removed: the block happens after the +# user has dragged the app to Applications and ejected the image, so the file is +# on screen exactly when it is not needed and gone when it is. Those +# instructions belong where people actually are at that moment — the release +# body next to the download button, and doc/MACOS_APP.md. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -21,80 +25,202 @@ OUT_DIR="${OUT_DIR:-$REPO_ROOT/mac/dist}" APP="${1:-$OUT_DIR/cix.app}" MAC_VERSION="${MAC_VERSION:-dev}" DMG="$OUT_DIR/cix-$MAC_VERSION-arm64.dmg" +DMG_LAYOUT="${DMG_LAYOUT:-auto}" + +# The volume name is deliberately constant, with no version in it. Finder stores +# the window layout in a .DS_Store keyed to this volume, and the AppleScript +# below has to address `disk "cix"` by name — a version-stamped volume would +# make both version-specific for no benefit. The version lives in the filename, +# where people actually look for it. +VOLNAME="cix" + +# Layout from mac/Resources/README.md — these coordinates are where the arrow in +# the background image points. Changing one without the other breaks the design. +WIN_W=640 +WIN_H=420 +ICON_SIZE=104 +APP_X=175 +APP_Y=250 +LINK_X=465 +LINK_Y=250 +# Before adding an item here, two Finder behaviours, both measured rather than +# documented: `position` is the TOP-LEFT of the item cell (icon size plus label, +# ~124 px), not its centre; and a y below roughly 68 is not clamped for that one +# item — Finder translates EVERY item down by the difference, sliding the app +# and the symlink off the arrow they are aligned with. +# +# Finder's window `bounds` are {left, top, right, bottom} on screen, so the +# right/bottom edges are origin+size — not the size itself. Getting this wrong +# yields a window smaller than the background, which Finder then crops. +WIN_LEFT=160 +WIN_TOP=120 +WIN_RIGHT=$((WIN_LEFT + WIN_W)) +WIN_BOTTOM=$((WIN_TOP + WIN_H)) if [[ ! -d "$APP" ]]; then echo "make-dmg: no such bundle: $APP" >&2 exit 1 fi +case "$DMG_LAYOUT" in + auto|require|off) ;; + *) echo "make-dmg: DMG_LAYOUT must be auto, require or off (got '$DMG_LAYOUT')" >&2; exit 2 ;; +esac STAGE="$(mktemp -d -t cix-dmg-XXXXXX)" -trap 'rm -rf "$STAGE"' EXIT - -# ditto, not cp -R: it preserves extended attributes and, critically, the -# signature-relevant metadata. cp -R silently drops some of it, which turns a -# verified bundle into one that fails `codesign --verify --strict` after the -# round trip through the image. +RW_DMG="$(mktemp -u -t cix-dmg-rw-XXXXXX).dmg" +ATTACHED_DEV="" +cleanup() { + [[ -n "$ATTACHED_DEV" ]] && hdiutil detach "$ATTACHED_DEV" -force -quiet 2>/dev/null || true + rm -rf "$STAGE" + rm -f "$RW_DMG" +} +trap cleanup EXIT + +# --------------------------------------------------------------------------- +# Stage the volume contents +# --------------------------------------------------------------------------- + +# ditto, not cp -R: it preserves extended attributes and signature-relevant +# metadata. cp -R silently drops some of it, which turns a verified bundle into +# one that fails `codesign --verify --strict` after the round trip. ditto "$APP" "$STAGE/cix.app" ln -s /Applications "$STAGE/Applications" -# Gatekeeper instructions have to travel with the download. Without a Developer -# ID there is no way to make a first launch quiet, and on macOS 15+ the old -# right-click → Open escape hatch no longer works — the user has to go through -# System Settings. Someone who does not know that concludes the app is broken. -cat > "$STAGE/READ ME FIRST.txt" < Privacy & Security - - Scroll to Security. There will be a message about cix being blocked. - - Click "Open Anyway", then confirm. - - You only have to do this once per installed version. - - (On macOS 15 and later, right-clicking the app and choosing Open no longer - works as a shortcut for this — use System Settings.) - -WHAT IS INSIDE - cix.app/Contents/MacOS/ - cix-launcher the app itself - cix-server the indexing + search server - cix the command-line client - llama/ a Metal-accelerated llama-server for local embeddings +mkdir -p "$STAGE/.background" +cp "$REPO_ROOT/mac/Resources/dmg/dmg-background.png" "$STAGE/.background/dmg-background.png" +cp "$REPO_ROOT/mac/Resources/dmg/dmg-background@2x.png" "$STAGE/.background/dmg-background@2x.png" - The app runs entirely on your machine. Nothing is uploaded anywhere unless - you configure an external embedding provider yourself. +# The volume icon is installed later, after Finder has finished with the volume +# — see the note above the "Volume icon" step below. -DOCS - https://github.com/dvcdsys/code-index/blob/main/doc/MACOS_APP.md -EOF -echo "make-dmg: creating $DMG" -rm -f "$DMG" +# --------------------------------------------------------------------------- +# Build a read-write image, dress it, then compress +# --------------------------------------------------------------------------- +# The layout has to be applied to a mounted, writable volume: Finder stores it +# in the volume's .DS_Store, which cannot be written into a compressed +# read-only image after the fact. +echo "make-dmg: creating writable image" hdiutil create \ - -volname "cix $MAC_VERSION" \ + -volname "$VOLNAME" \ -srcfolder "$STAGE" \ -fs HFS+ \ - -format UDZO \ - -imagekey zlib-level=9 \ + -format UDRW \ -quiet \ - "$DMG" + "$RW_DMG" + +echo "make-dmg: mounting" +ATTACH_OUT="$(hdiutil attach -readwrite -noverify -noautoopen "$RW_DMG")" +ATTACHED_DEV="$(printf '%s\n' "$ATTACH_OUT" | awk '/^\/dev\// { print $1; exit }')" +MOUNT_POINT="$(printf '%s\n' "$ATTACH_OUT" | sed -n 's|.*\(/Volumes/.*\)$|\1|p' | tail -1)" +if [[ -z "$ATTACHED_DEV" || -z "$MOUNT_POINT" ]]; then + echo "make-dmg: could not determine the mount point:" >&2 + printf '%s\n' "$ATTACH_OUT" >&2 + exit 1 +fi +echo "make-dmg: mounted $ATTACHED_DEV at $MOUNT_POINT" + +# --- Window layout ---------------------------------------------------------- +# This is the one step that needs Finder, and Finder needs a real GUI session. +# On a CI runner that may be unavailable, or blocked by an automation-consent +# prompt that nobody is there to answer — which would hang the build rather +# than fail it. So: run it under a watchdog, and treat the outcome according to +# DMG_LAYOUT. +# +# auto try; on failure warn and ship an unstyled but valid DMG (default) +# require try; on failure abort the build +# off skip entirely +layout_applied=0 +if [[ "$DMG_LAYOUT" == "off" ]]; then + echo "make-dmg: DMG_LAYOUT=off — skipping Finder layout" +else + echo "make-dmg: applying Finder layout" + read -r -d '' LAYOUT_SCRIPT </tmp/cix-dmg-layout.log 2>&1 & + osa_pid=$! + ( sleep 90; kill -9 "$osa_pid" 2>/dev/null ) & + watchdog_pid=$! + wait "$osa_pid" + osa_rc=$? + kill "$watchdog_pid" 2>/dev/null + wait "$watchdog_pid" 2>/dev/null + set -e + + if [[ $osa_rc -eq 0 ]]; then + layout_applied=1 + echo "make-dmg: layout applied" + else + echo "make-dmg: WARNING — Finder layout failed (exit $osa_rc). The DMG is valid but unstyled." >&2 + sed 's/^/make-dmg: /' /tmp/cix-dmg-layout.log >&2 || true + # Make the degradation visible in the run summary, not just buried in a + # log nobody opens when the job is green. + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then + echo "::warning title=DMG shipped unstyled::Finder layout failed on the runner; the disk image has no background or icon positions." + fi + if [[ "$DMG_LAYOUT" == "require" ]]; then + echo "make-dmg: DMG_LAYOUT=require — aborting" >&2 + exit 1 + fi + fi +fi + +# --- Volume icon ------------------------------------------------------------ +# The red installer colourway, so the mounted disk and the app inside it are +# never confused for each other in Finder. +# +# This has to happen AFTER the Finder layout, not in the staging directory. +# Staging it up front looks like it works — hdiutil copies the file and SetFile +# sets the flag — but Finder then removes both while laying the window out, and +# the finished image ships with a generic disk icon. Verified by bisecting the +# steps: create/attach/SetFile/detach/convert preserves the icon; adding the +# Finder pass is what loses it. +echo "make-dmg: installing volume icon" +iconutil -c icns "$REPO_ROOT/mac/Resources/cix-installer.iconset" -o "$MOUNT_POINT/.VolumeIcon.icns" +# Without the custom-icon flag, .VolumeIcon.icns is just a hidden file. +SetFile -a C "$MOUNT_POINT" + +sync +echo "make-dmg: unmounting" +hdiutil detach "$ATTACHED_DEV" -quiet +ATTACHED_DEV="" + +echo "make-dmg: compressing" +rm -f "$DMG" +hdiutil convert "$RW_DMG" -format UDZO -imagekey zlib-level=9 -quiet -o "$DMG" echo "make-dmg: verifying image" hdiutil verify "$DMG" -# Ad-hoc sign the image too. It buys no Gatekeeper trust, but it makes -# tampering after publication detectable with codesign rather than only by -# checksum, and it costs one command. +# Ad-hoc sign the image too. It buys no Gatekeeper trust, but it makes tampering +# after publication detectable with codesign rather than only by checksum. codesign --force --sign - "$DMG" -echo "make-dmg: ok — $DMG" +if [[ $layout_applied -eq 1 ]]; then + echo "make-dmg: ok (styled) — $DMG" +else + echo "make-dmg: ok (unstyled) — $DMG" +fi shasum -a 256 "$DMG" diff --git a/mac/scripts/make-placeholder-icons.py b/mac/scripts/make-placeholder-icons.py deleted file mode 100755 index 0df2c8c3..00000000 --- a/mac/scripts/make-placeholder-icons.py +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env python3 -"""Generate the placeholder icon set for cix.app. - -These are placeholders. The three files this writes into mac/Resources/ are -committed, so neither CI nor a normal build ever runs this script — replacing -the artwork means dropping in new files with the same names and sizes, with no -code change anywhere. - - mac/Resources/menubar.png 18x18 template image (black + alpha only) - mac/Resources/menubar@2x.png 36x36 template image - mac/Resources/AppIcon.icns full icns, built via iconutil - -"Template image" is a hard requirement for the menu bar, not a style choice: -macOS recolours template images to match the menu bar (dark mode, tinting, -inactive state) and only looks at the alpha channel. A coloured PNG there -renders as a solid smudge. So the menu-bar glyphs below write pure black -pixels and vary only alpha. - -Pure stdlib (zlib + struct) — deliberately no Pillow, so the script runs on a -clean machine. iconutil ships with macOS. -""" - -from __future__ import annotations - -import pathlib -import shutil -import struct -import subprocess -import sys -import tempfile -import zlib - -REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] -RESOURCES = REPO_ROOT / "mac" / "Resources" - -# Slate background for the app icon; the glyph is white on top of it. -BG = (30, 41, 59) -FG = (241, 245, 249) - -# Sizes iconutil expects in an .iconset directory. Anything missing is simply -# absent from the icns, which macOS then scales badly — so emit the full set. -ICONSET = [ - ("icon_16x16.png", 16), - ("icon_16x16@2x.png", 32), - ("icon_32x32.png", 32), - ("icon_32x32@2x.png", 64), - ("icon_128x128.png", 128), - ("icon_128x128@2x.png", 256), - ("icon_256x256.png", 256), - ("icon_256x256@2x.png", 512), - ("icon_512x512.png", 512), - ("icon_512x512@2x.png", 1024), -] - - -def write_png(path: pathlib.Path, width: int, height: int, pixels: bytearray) -> None: - """Write an 8-bit RGBA PNG. `pixels` is width*height*4 bytes, row-major.""" - - def chunk(tag: bytes, data: bytes) -> bytes: - return ( - struct.pack(">I", len(data)) - + tag - + data - + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) - ) - - # Filter byte 0 (None) per scanline — no filtering, smallest possible code. - raw = b"".join( - b"\x00" + bytes(pixels[y * width * 4 : (y + 1) * width * 4]) - for y in range(height) - ) - ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) - path.write_bytes( - b"\x89PNG\r\n\x1a\n" - + chunk(b"IHDR", ihdr) - + chunk(b"IDAT", zlib.compress(raw, 9)) - + chunk(b"IEND", b"") - ) - - -def blend(pixels: bytearray, width: int, x: int, y: int, rgb, alpha: float) -> None: - """Source-over one pixel. Callers pass fractional alpha for antialiasing.""" - if alpha <= 0: - return - alpha = min(alpha, 1.0) - i = (y * width + x) * 4 - dst_a = pixels[i + 3] / 255.0 - out_a = alpha + dst_a * (1 - alpha) - if out_a <= 0: - return - for c in range(3): - src = rgb[c] / 255.0 - dst = pixels[i + c] / 255.0 - pixels[i + c] = int(round((src * alpha + dst * dst_a * (1 - alpha)) / out_a * 255)) - pixels[i + 3] = int(round(out_a * 255)) - - -def rounded_rect(pixels, width, x0, y0, w, h, radius, rgb, samples=4): - """Draw an antialiased rounded rectangle by supersampling coverage.""" - step = 1.0 / samples - for py in range(int(y0), int(y0 + h) + 1): - if py < 0 or py >= width: - continue - for px in range(int(x0), int(x0 + w) + 1): - if px < 0 or px >= width: - continue - hits = 0 - for sy in range(samples): - for sx in range(samples): - fx = px + (sx + 0.5) * step - fy = py + (sy + 0.5) * step - if inside_rounded(fx, fy, x0, y0, w, h, radius): - hits += 1 - if hits: - blend(pixels, width, px, py, rgb, hits / (samples * samples)) - - -def inside_rounded(fx, fy, x0, y0, w, h, r) -> bool: - if fx < x0 or fx > x0 + w or fy < y0 or fy > y0 + h: - return False - # Clamp the sample into the inner rectangle; the leftover offset is the - # distance to the nearest corner arc centre. - cx = min(max(fx, x0 + r), x0 + w - r) - cy = min(max(fy, y0 + r), y0 + h - r) - dx, dy = fx - cx, fy - cy - return dx * dx + dy * dy <= r * r - - -def draw_glyph(pixels, size, rgb, scale=1.0, offset=(0.0, 0.0)): - """Three left-aligned bars — a stylised index. - - Coordinates are expressed on a 100x100 design grid and mapped onto `size`, - so every raster below is the same drawing rather than three lookalikes. - """ - unit = size / 100.0 * scale - ox = offset[0] * size / 100.0 - oy = offset[1] * size / 100.0 - bar_h = 14.0 - radius = bar_h / 2.0 - for i, (top, bar_w) in enumerate(((16.0, 68.0), (43.0, 44.0), (70.0, 68.0))): - rounded_rect( - pixels, - size, - 16.0 * unit + ox, - top * unit + oy, - bar_w * unit, - bar_h * unit, - radius * unit, - rgb, - ) - - -def make_menubar(size: int) -> bytearray: - pixels = bytearray(size * size * 4) - # Template image: black glyph, transparent elsewhere. macOS reads alpha only. - draw_glyph(pixels, size, (0, 0, 0)) - return pixels - - -def make_app_icon(size: int) -> bytearray: - pixels = bytearray(size * size * 4) - # macOS icon grid: the artwork occupies ~80% of the canvas, with the - # squircle corner radius at ~22.4% of the artwork's edge. - inset = size * 0.10 - art = size - inset * 2 - rounded_rect(pixels, size, inset, inset, art, art, art * 0.2237, BG) - draw_glyph(pixels, size, FG, scale=0.80, offset=(10.0, 10.0)) - return pixels - - -def main() -> int: - if shutil.which("iconutil") is None: - print("make-placeholder-icons: iconutil not found — run this on macOS", file=sys.stderr) - return 1 - - RESOURCES.mkdir(parents=True, exist_ok=True) - - for name, size in (("menubar.png", 18), ("menubar@2x.png", 36)): - write_png(RESOURCES / name, size, size, make_menubar(size)) - print(f"wrote mac/Resources/{name} ({size}x{size})") - - with tempfile.TemporaryDirectory() as tmp: - iconset = pathlib.Path(tmp) / "AppIcon.iconset" - iconset.mkdir() - # Cache by pixel size: the iconset asks for several sizes twice. - rendered: dict[int, bytearray] = {} - for name, size in ICONSET: - if size not in rendered: - rendered[size] = make_app_icon(size) - write_png(iconset / name, size, size, rendered[size]) - subprocess.run( - ["iconutil", "-c", "icns", str(iconset), "-o", str(RESOURCES / "AppIcon.icns")], - check=True, - ) - print("wrote mac/Resources/AppIcon.icns") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From efdc34d4e3b019f1fa8c2e0d070202521384cbe5 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 12:41:14 +0100 Subject: [PATCH 04/12] fix(mac): draw launcher dialogs with the app icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `display alert` is drawn with the icon of the process that ran the script, which is osascript — so every dialog the app showed came up wearing a generic folder icon, immediately after packaging a real icon set. `display dialog` takes an explicit icon, so point it at the bundle's cix.icns. The trade is that the title becomes a window title rather than bold body text, which is worth it. It is also the primitive the later phases need regardless: only `display dialog` supports `default answer`, required for the reset-password prompt. Falls back to `display alert` when running outside a bundle, where there is no icon to point at. Co-Authored-By: Claude Opus 5 --- cli/launcher/dialog_darwin.go | 30 +++++++++++++++++++++++++----- cli/launcher/main_darwin.go | 10 ++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/cli/launcher/dialog_darwin.go b/cli/launcher/dialog_darwin.go index f23ec4b4..d00d3ab1 100644 --- a/cli/launcher/dialog_darwin.go +++ b/cli/launcher/dialog_darwin.go @@ -34,12 +34,32 @@ func quoteAS(s string) string { return strings.Join(parts, " & return & ") } -// alert shows a modal informational alert and blocks until it is dismissed. +// dialogIcon is the POSIX path to the icon dialogs are drawn with. Set once at +// startup from the bundle; empty when the launcher runs outside a .app. +var dialogIcon string + +// alert shows a modal informational dialog and blocks until it is dismissed. +// +// `display dialog` rather than the more obvious `display alert`, for one +// reason: an alert is drawn with the icon of the process that ran the script, +// which here is osascript — so the app's own dialogs came up wearing a generic +// folder icon. `display dialog` takes an explicit icon. The cost is that the +// title is a window title instead of bold body text; the icon is worth more. +// It is also the primitive Phase 2 needs anyway, since only `display dialog` +// supports `default answer` for text input. func alert(title, message string) error { - script := fmt.Sprintf( - `display alert %s message %s as informational buttons {"OK"} default button "OK"`, - quoteAS(title), quoteAS(message), - ) + var script string + if dialogIcon != "" { + script = fmt.Sprintf( + `display dialog %s with title %s with icon POSIX file %s buttons {"OK"} default button "OK"`, + quoteAS(message), quoteAS(title), quoteAS(dialogIcon), + ) + } else { + script = fmt.Sprintf( + `display alert %s message %s as informational buttons {"OK"} default button "OK"`, + quoteAS(title), quoteAS(message), + ) + } return runOsascript(2*time.Minute, script) } diff --git a/cli/launcher/main_darwin.go b/cli/launcher/main_darwin.go index 1955e60f..799cbe42 100644 --- a/cli/launcher/main_darwin.go +++ b/cli/launcher/main_darwin.go @@ -4,9 +4,15 @@ import ( "flag" "fmt" "os" + "path/filepath" "strings" ) +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + // cix-launcher — the executable behind cix.app. // // Scope of this build: the .app packaging pipeline (mac/v* tag → DMG). The @@ -33,6 +39,10 @@ func main() { os.Exit(1) } + if icon := filepath.Join(b.Resources, "cix.icns"); fileExists(icon) { + dialogIcon = icon + } + if isTranslocated(b) { msg := "macOS is running cix from a temporary read-only copy, so it cannot manage a server.\n\n" + "Move cix.app to your Applications folder and open it from there." From 8abef0bd58cc2437391e80a4d974ef17b08d94c9 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 13:03:48 +0100 Subject: [PATCH 05/12] feat(mac): menu bar status, start/stop and first-run setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns cix.app from a package into something you can use: a menu bar item that shows whether the local server is running and what its embedding provider is doing, with start/stop and a dashboard link. No Dock icon, no windows. First-run setup is load-bearing, not polish. cix-server refuses to start against an empty database unless both CIX_BOOTSTRAP_ADMIN_* env vars are set (bootstrap.go, `case count == 0`) — it will not invent an admin silently — so a drag-installed app with no configuration could never start its own server. The wizard asks for an email, generates a password and an API key in the server's own format, writes ~/.cix/server.env at 0600, starts the server and registers it with the CLI. It deliberately writes ~/.cix/server.env rather than the repo .env install-server.sh uses, so a checkout and an installed app can coexist. Server state comes from two sources because neither is sufficient. launchd knows whether the job is loaded and has a pid but not whether it is serving — a cold start spends 30s to several minutes loading an embedding model, in silence. /health knows whether it is serving but cannot tell "stopped" from "starting", which is the distinction that stops people killing a server that was nearly ready. Together they give three honest states. Start is bootstrap-then-kickstart and Stop is `launchctl kill SIGTERM`, which needs KeepAlive=false — a deliberate divergence from install-server.sh. With KeepAlive=true, Stop would have to be a bootout, dragging launchd's drain race onto the hot path of a menu click. The trade is that a crashed server is not resurrected; the menu shows it stopped within five seconds. install-server.sh owns the same launchd label and points it at a repo checkout. Rather than clobber it, the app detects a wrapper without its own provenance marker and goes observe-only: status, provider and dashboard still work over HTTP, Start/Stop are disabled and labelled "managed externally". Two display decisions that are correctness, not taste: - The provider kind "ollama" is rendered as "llama.cpp (bundled)". In this codebase that kind IS the bundled llama-server supervisor — provider/ollama spawns llama-server and hardcodes ManagesProcess: true — and no Ollama is involved or possible. Printing the raw kind tells the user they are running software they never installed. The dashboard keeps showing the raw kind; it is a diagnostic surface and this is not. - model_loaded is debounced over two consecutive polls. The server computes it under a 500 ms deadline it can lose under load, so one false is not evidence, and for HTTP providers (openai, voyage) it is skipped entirely — hence EmbeddingsHealthy, which never derives a red state from a provider that owns no local process. Also extends Client.Status, which was dead code decoding 4 of 10 always-present fields, and pins the whole response with twin golden fixtures on both sides of the module boundary — the repo's convention for a contract two modules cannot share a type for. Verified end to end on a clean machine state: wizard writes the env at 0600 and bootstraps the agent, server answers /health, menu renders all three rows, Stop leaves the job loaded with no pid, Start brings it back, Open Dashboard reaches the login page, and the CLI's existing default_server is left untouched. Co-Authored-By: Claude Opus 5 --- cli/go.mod | 2 + cli/go.sum | 4 + cli/internal/client/client.go | 59 +++- cli/internal/client/status_test.go | 103 ++++++ cli/launcher/dialog_darwin.go | 70 ++++ cli/launcher/env_darwin.go | 151 +++++++++ cli/launcher/firstrun_darwin.go | 191 +++++++++++ cli/launcher/launchd_darwin.go | 304 ++++++++++++++++++ cli/launcher/main_darwin.go | 97 +++--- cli/launcher/menu_darwin.go | 165 ++++++++++ cli/launcher/status_darwin.go | 277 ++++++++++++++++ cli/launcher/status_darwin_test.go | 130 ++++++++ doc/MACOS_APP.md | 98 ++++-- .../internal/httpapi/status_contract_test.go | 83 +++++ 14 files changed, 1669 insertions(+), 65 deletions(-) create mode 100644 cli/internal/client/status_test.go create mode 100644 cli/launcher/env_darwin.go create mode 100644 cli/launcher/firstrun_darwin.go create mode 100644 cli/launcher/launchd_darwin.go create mode 100644 cli/launcher/menu_darwin.go create mode 100644 cli/launcher/status_darwin.go create mode 100644 cli/launcher/status_darwin_test.go create mode 100644 server/internal/httpapi/status_contract_test.go diff --git a/cli/go.mod b/cli/go.mod index effec5c1..0af244a5 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -3,6 +3,7 @@ module github.com/dvcdsys/code-index/cli go 1.25.12 require ( + fyne.io/systray v1.12.2 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 @@ -33,6 +34,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect diff --git a/cli/go.sum b/cli/go.sum index 1916907e..33a492b3 100644 --- a/cli/go.sum +++ b/cli/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/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -41,6 +43,8 @@ github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJ github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= diff --git a/cli/internal/client/client.go b/cli/internal/client/client.go index b60beb4d..b7d0085d 100644 --- a/cli/internal/client/client.go +++ b/cli/internal/client/client.go @@ -254,9 +254,60 @@ func (c *Client) Status() (*StatusResponse, error) { return &status, nil } +// StatusResponse mirrors GET /api/v1/status. +// +// The first block is always present. The version-check block is present only +// when the server was started with the version-check service wired +// (CIX_VERSION_CHECK_ENABLED); when it is absent the four fields stay at their +// zero values, and `UpdateAvailable == false` is then "unknown", not "current". +// Branch on `VersionCheck != nil` before trusting it. +// +// LatestVersion and ReleaseURL are pointers because the server emits JSON null +// for them until a check has actually succeeded — an empty string would be +// indistinguishable from "checked, and there is no newer release". +// +// The exact wire shape is pinned by a twin golden fixture: status_test.go here +// and server/internal/httpapi/status_contract_test.go there. The two modules +// cannot import each other, so those fixtures are the contract — change one and +// the other fails, forcing both sides to move in the same PR. type StatusResponse struct { - Status string `json:"status"` - ModelLoaded bool `json:"model_loaded"` - Projects int `json:"projects"` - ActiveIndexingJobs int `json:"active_indexing_jobs"` + Status string `json:"status"` + Backend string `json:"backend"` + ServerVersion string `json:"server_version"` + APIVersion string `json:"api_version"` + ModelLoaded bool `json:"model_loaded"` + EmbeddingModel string `json:"embedding_model"` + EmbeddingProvider string `json:"embedding_provider"` + EmbeddingProviderManagesProcess bool `json:"embedding_provider_manages_process"` + Projects int `json:"projects"` + ActiveIndexingJobs int `json:"active_indexing_jobs"` + + UpdateAvailable bool `json:"update_available"` + LatestVersion *string `json:"latest_version"` + ReleaseURL *string `json:"release_url"` + VersionCheck *VersionCheckStatus `json:"version_check"` +} + +// VersionCheckStatus is the nested `version_check` object. +type VersionCheckStatus struct { + Enabled bool `json:"enabled"` + Error *string `json:"error"` + // CheckedAt is RFC3339, or nil when no check has completed yet. + CheckedAt *string `json:"checked_at"` +} + +// EmbeddingsHealthy reports whether the embedding provider should be shown as +// working. +// +// ModelLoaded alone is not that answer. The server computes it under a 500 ms +// deadline, and for HTTP providers (openai, voyage) there is no local process +// whose liveness it could reflect — EmbeddingProviderManagesProcess is false +// there, and treating a slow or skipped probe as "down" would show a permanent +// red dot for a provider that is fine. Only a managed local process +// (llama-server) can meaningfully be reported as not loaded. +func (s *StatusResponse) EmbeddingsHealthy() bool { + if !s.EmbeddingProviderManagesProcess { + return true + } + return s.ModelLoaded } diff --git a/cli/internal/client/status_test.go b/cli/internal/client/status_test.go new file mode 100644 index 00000000..3d90f1a2 --- /dev/null +++ b/cli/internal/client/status_test.go @@ -0,0 +1,103 @@ +package client + +import ( + "encoding/json" + "testing" +) + +// Twin of server/internal/httpapi/status_contract_test.go. The two modules +// cannot import each other, so these byte-identical goldens are the contract +// for GET /api/v1/status: change a field name or type on either side and one of +// the two test files fails, forcing both to move in the same PR. +// +// Key order is alphabetical because the server builds the response as a +// map[string]any and encoding/json sorts map keys. Order does not matter for +// decoding — it is kept identical to make the twin obvious on inspection. +const ( + wantStatusWire = `{"active_indexing_jobs":0,"api_version":"v1","backend":"go","embedding_model":"awhiteside/CodeRankEmbed-Q8_0-GGUF","embedding_provider":"","embedding_provider_manages_process":false,"model_loaded":false,"projects":0,"server_version":"1.2.3","status":"ok"}` + + wantStatusWithVersionCheckWire = `{"active_indexing_jobs":0,"api_version":"v1","backend":"go","embedding_model":"awhiteside/CodeRankEmbed-Q8_0-GGUF","embedding_provider":"","embedding_provider_manages_process":false,"latest_version":null,"model_loaded":false,"projects":0,"release_url":null,"server_version":"1.2.3","status":"ok","update_available":false,"version_check":{"checked_at":null,"enabled":true,"error":null}}` +) + +func TestStatusResponse_DecodesServerContract(t *testing.T) { + var got StatusResponse + if err := json.Unmarshal([]byte(wantStatusWire), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + want := StatusResponse{ + Status: "ok", + Backend: "go", + ServerVersion: "1.2.3", + APIVersion: "v1", + EmbeddingModel: "awhiteside/CodeRankEmbed-Q8_0-GGUF", + } + if got != want { + t.Errorf("decoded status drifted from the server contract:\n got: %+v\nwant: %+v\n"+ + "→ update server/internal/httpapi/server.go and its twin fixture in the same PR.", got, want) + } + + // A server with the version check switched off sends none of these. The + // nil pointers are what tells a caller "unknown" apart from "up to date". + if got.VersionCheck != nil { + t.Errorf("VersionCheck = %+v, want nil when the server omits the block", got.VersionCheck) + } +} + +func TestStatusResponse_DecodesVersionCheckBlock(t *testing.T) { + var got StatusResponse + if err := json.Unmarshal([]byte(wantStatusWithVersionCheckWire), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.VersionCheck == nil { + t.Fatal("VersionCheck = nil, want the decoded block") + } + if !got.VersionCheck.Enabled { + t.Error("VersionCheck.Enabled = false, want true") + } + // Before the first successful poll the server sends nulls, not empty + // strings — that distinction is the whole reason these are pointers. + if got.VersionCheck.CheckedAt != nil { + t.Errorf("VersionCheck.CheckedAt = %q, want nil before the first check", *got.VersionCheck.CheckedAt) + } + if got.VersionCheck.Error != nil { + t.Errorf("VersionCheck.Error = %q, want nil", *got.VersionCheck.Error) + } + if got.LatestVersion != nil || got.ReleaseURL != nil { + t.Errorf("LatestVersion/ReleaseURL = %v/%v, want nil before the first check", got.LatestVersion, got.ReleaseURL) + } + if got.UpdateAvailable { + t.Error("UpdateAvailable = true, want false") + } +} + +func TestEmbeddingsHealthy(t *testing.T) { + tests := []struct { + name string + managesProcess bool + modelLoaded bool + want bool + }{ + // llama-server: the only case where "not loaded" is real information. + {"managed process, loaded", true, true, true}, + {"managed process, not loaded", true, false, false}, + // openai / voyage: there is no local process to be down, and the + // server's Ready() probe runs under a 500 ms deadline it can lose. + // Reporting red here would mean a permanently red dot on a provider + // that works — which is exactly what the dashboard avoids doing. + {"http provider, loaded", false, true, true}, + {"http provider, not loaded", false, false, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := &StatusResponse{ + EmbeddingProviderManagesProcess: tc.managesProcess, + ModelLoaded: tc.modelLoaded, + } + if got := s.EmbeddingsHealthy(); got != tc.want { + t.Errorf("EmbeddingsHealthy() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/cli/launcher/dialog_darwin.go b/cli/launcher/dialog_darwin.go index d00d3ab1..f07fc100 100644 --- a/cli/launcher/dialog_darwin.go +++ b/cli/launcher/dialog_darwin.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "context" + "errors" "fmt" "os/exec" "strings" @@ -63,6 +65,74 @@ func alert(title, message string) error { return runOsascript(2*time.Minute, script) } +// errCancelled is returned when the user dismissed a dialog instead of +// answering it. osascript reports this as exit status 1 with "User canceled" on +// stderr, which is indistinguishable from a real failure unless matched. +var errCancelled = errors.New("cancelled by user") + +// prompt asks for one line of text. Returns errCancelled if the user cancels. +func prompt(title, message, defaultAnswer string) (string, error) { + script := fmt.Sprintf( + `display dialog %s with title %s default answer %s %s buttons {"Cancel", "OK"} default button "OK" cancel button "Cancel"`, + quoteAS(message), quoteAS(title), quoteAS(defaultAnswer), iconClause(), + ) + out, err := outputOsascript(5*time.Minute, script) + if err != nil { + return "", err + } + // osascript prints `button returned:OK, text returned:`. The text is + // last, and a value containing ", text returned:" is not reachable because + // the field is single-line — so cutting on the marker is safe. + _, answer, ok := strings.Cut(out, "text returned:") + if !ok { + return "", fmt.Errorf("unexpected osascript output: %q", out) + } + return strings.TrimSpace(answer), nil +} + +// confirm shows a two-button question. Returns false when the user declines. +func confirm(title, message, okLabel string) (bool, error) { + script := fmt.Sprintf( + `display dialog %s with title %s %s buttons {"Cancel", %s} default button %s cancel button "Cancel"`, + quoteAS(message), quoteAS(title), iconClause(), quoteAS(okLabel), quoteAS(okLabel), + ) + if _, err := outputOsascript(5*time.Minute, script); err != nil { + if errors.Is(err, errCancelled) { + return false, nil + } + return false, err + } + return true, nil +} + +func iconClause() string { + if dialogIcon == "" { + return "" + } + return "with icon POSIX file " + quoteAS(dialogIcon) +} + +func outputOsascript(timeout time.Duration, script string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, "osascript", "-e", script) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if ctx.Err() != nil { + return "", fmt.Errorf("osascript timed out after %s", timeout) + } + if strings.Contains(stderr.String(), "User canceled") { + return "", errCancelled + } + return "", fmt.Errorf("osascript: %w: %s", err, strings.TrimSpace(stderr.String())) + } + return stdout.String(), nil +} + func runOsascript(timeout time.Duration, script string) error { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() diff --git a/cli/launcher/env_darwin.go b/cli/launcher/env_darwin.go new file mode 100644 index 00000000..e781c90d --- /dev/null +++ b/cli/launcher/env_darwin.go @@ -0,0 +1,151 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// The launcher keeps server configuration in ~/.cix/server.env, deliberately +// NOT in the repo-root .env that install-server.sh uses. A developer checkout +// and an installed .app can then coexist on one machine without fighting over +// one file — which matters because they also share a launchd label. +// +// The file is the single source of truth for the server process: the launchd +// plist carries no configuration, the wrapper script just sources this file. +// Editing it and restarting is enough, with no plist regeneration. + +const ( + defaultServerPort = 21847 + envFileMode = 0o600 +) + +func cixHome() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".cix"), nil +} + +func serverEnvPath() (string, error) { + dir, err := cixHome() + if err != nil { + return "", err + } + return filepath.Join(dir, "server.env"), nil +} + +// readServerEnv parses ~/.cix/server.env into a map. +// +// The parser is deliberately small: this file is written by us and consumed by +// our own wrapper (`set -a; source`), so it never has to cope with the full +// range of shell syntax. It does strip matching quotes, because the writer adds +// them. +func readServerEnv() (map[string]string, error) { + path, err := serverEnvPath() + if err != nil { + return nil, err + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + vars := map[string]string{} + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(strings.TrimPrefix(key, "export ")) + value = strings.TrimSpace(value) + if len(value) >= 2 { + if (value[0] == '\'' && value[len(value)-1] == '\'') || + (value[0] == '"' && value[len(value)-1] == '"') { + value = value[1 : len(value)-1] + } + } + // Undo the writer's escaping of an embedded single quote. + vars[key] = strings.ReplaceAll(value, `'\''`, `'`) + } + if err := sc.Err(); err != nil { + return nil, err + } + return vars, nil +} + +// writeServerEnv writes the file at 0600 — it holds an API key and a bootstrap +// password. Written via a temp file + rename so a crash mid-write cannot leave +// the server with a half-parsed configuration. +func writeServerEnv(vars map[string]string) error { + path, err := serverEnvPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + + keys := make([]string, 0, len(vars)) + for k := range vars { + keys = append(keys, k) + } + sort.Strings(keys) + + var sb strings.Builder + sb.WriteString("# Generated by cix.app. Sourced by ~/.cix/launchd/run-cix-server.sh.\n") + sb.WriteString("# Contains an API key and the bootstrap password: keep at mode 0600.\n") + for _, k := range keys { + fmt.Fprintf(&sb, "%s='%s'\n", k, strings.ReplaceAll(vars[k], `'`, `'\''`)) + } + + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(sb.String()), envFileMode); err != nil { + return err + } + // WriteFile honours the mode only when it creates the file; an existing + // temp file from a previous crash would keep its old mode. + if err := os.Chmod(tmp, envFileMode); err != nil { + os.Remove(tmp) + return err + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return err + } + return nil +} + +// serverPort returns the port the server is configured to listen on. +func serverPort(vars map[string]string) int { + if raw, ok := vars["CIX_PORT"]; ok { + if n, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil && n > 0 && n < 65536 { + return n + } + } + return defaultServerPort +} + +// dashboardURL is built from the server.env port, never from the CLI config. +// The CLI's default server may legitimately point at a remote cix — opening +// that in a browser when the user clicked "Open Dashboard" on a local menu bar +// item would be wrong, and confusing in exactly the case where the local server +// is down. +func dashboardURL(vars map[string]string) string { + return fmt.Sprintf("http://localhost:%d/dashboard", serverPort(vars)) +} + +func localBaseURL(vars map[string]string) string { + return fmt.Sprintf("http://localhost:%d", serverPort(vars)) +} diff --git a/cli/launcher/firstrun_darwin.go b/cli/launcher/firstrun_darwin.go new file mode 100644 index 00000000..14cd4e30 --- /dev/null +++ b/cli/launcher/firstrun_darwin.go @@ -0,0 +1,191 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net/mail" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/dvcdsys/code-index/cli/internal/client" + "github.com/dvcdsys/code-index/cli/internal/config" +) + +// First-run setup. +// +// This exists because of a hard server-side constraint, not for polish: +// cix-server refuses to start against an empty database unless both +// CIX_BOOTSTRAP_ADMIN_EMAIL and CIX_BOOTSTRAP_ADMIN_PASSWORD are set +// (server/cmd/cix-server/bootstrap.go, `case count == 0`). It will not invent +// an admin account silently. A drag-installed app with no configuration can +// therefore never start its own server, so the wizard is load-bearing. + +const bootstrapServerName = "local" + +// needsFirstRun reports whether ~/.cix/server.env is missing. +func needsFirstRun() bool { + path, err := serverEnvPath() + if err != nil { + return false + } + _, err = os.Stat(path) + return errors.Is(err, os.ErrNotExist) +} + +// runFirstRun walks the user through creating the admin account, writes +// server.env, and registers the server with the CLI. +// +// Returns errCancelled if the user backs out, which is not an error condition — +// the app stays running with Start disabled until they complete setup. +func runFirstRun(b bundle) error { + intro := "cix needs an administrator account before it can start.\n\n" + + "Enter the email address to sign in with. A password will be generated for you, " + + "and you will be asked to change it the first time you log in." + + email, err := prompt("Set up cix", intro, "") + if err != nil { + return err + } + email = strings.TrimSpace(email) + for { + if _, err := mail.ParseAddress(email); err == nil && email != "" { + break + } + email, err = prompt("Set up cix", "That does not look like an email address. Try again:", email) + if err != nil { + return err + } + email = strings.TrimSpace(email) + } + + password, err := generatePassword() + if err != nil { + return fmt.Errorf("generate password: %w", err) + } + apiKey, err := generateAPIKey() + if err != nil { + return fmt.Errorf("generate api key: %w", err) + } + + home, err := os.UserHomeDir() + if err != nil { + return err + } + dataDir := filepath.Join(home, ".cix", "data") + if err := os.MkdirAll(dataDir, 0o700); err != nil { + return err + } + + vars := map[string]string{ + "CIX_BOOTSTRAP_ADMIN_EMAIL": email, + "CIX_BOOTSTRAP_ADMIN_PASSWORD": password, + // Imported as an "env-bootstrap" legacy key when the admin is created + // on a fresh DB (bootstrap.go), so the CLI works from the first boot + // without anyone visiting the dashboard to mint one. + "CIX_API_KEY": apiKey, + "CIX_PORT": strconv.Itoa(defaultServerPort), + "CIX_DATA_DIR": dataDir, + "CIX_SQLITE_PATH": filepath.Join(dataDir, "cix.db"), + // The .app owns updating itself. Leaving the server's own check on + // would mean two different components offering the user two different + // "update available" prompts for two different tag streams. + "CIX_VERSION_CHECK_ENABLED": "false", + } + if err := writeServerEnv(vars); err != nil { + return fmt.Errorf("write server.env: %w", err) + } + + if err := writeLaunchdFiles(b, false); err != nil { + return fmt.Errorf("write launchd files: %w", err) + } + if err := startServer(); err != nil { + return fmt.Errorf("start server: %w", err) + } + + // Register with the CLI so `cix` works out of the box. Failure here is not + // fatal — the server is up and the dashboard works; only the terminal + // client is left unconfigured, and the message says so. + cliNote := "" + if err := registerWithCLI(localBaseURL(vars), apiKey); err != nil { + cliNote = fmt.Sprintf("\n\nThe cix command line client could not be configured automatically (%v). "+ + "The server itself is unaffected.", err) + } + + waitNote := "" + if err := waitForHealth(localBaseURL(vars), 90*time.Second); err != nil { + // Not an error: a cold start loads an embedding model and can take + // minutes, in silence. Saying so beats a spinner that gives up. + waitNote = "\n\nThe server is still starting. First boot downloads and loads the embedding " + + "model, which can take a few minutes; the menu bar will show it as running when it is ready." + } + + return alert("cix is set up", fmt.Sprintf( + "Sign in at %s\n\nEmail:\n%s\n\nTemporary password:\n%s\n\n"+ + "You will be asked to change this password on first login.%s%s", + dashboardURL(vars), email, password, waitNote, cliNote)) +} + +// registerWithCLI adds (or updates) the local server in ~/.cix/config.yaml. +// +// It does not touch default_server when one is already set: a user whose CLI +// points at a remote cix should not have it silently repointed at localhost by +// installing an app. +func registerWithCLI(baseURL, apiKey string) error { + if err := config.SetServerURL(bootstrapServerName, baseURL); err != nil { + return err + } + if err := config.SetServerKey(bootstrapServerName, apiKey); err != nil { + return err + } + cfg, err := config.Load() + if err != nil { + return err + } + if cfg.DefaultServer == "" { + return config.SetDefaultServer(bootstrapServerName) + } + return nil +} + +func waitForHealth(baseURL string, timeout time.Duration) error { + c := client.New(baseURL, "") + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if err := c.Health(); err == nil { + return nil + } + time.Sleep(time.Second) + } + return fmt.Errorf("server did not answer /health within %s", timeout) +} + +// generateAPIKey mints a key in the server's own format: "cix_" plus 43 +// base64url characters (32 random bytes). The format is not cosmetic — the +// server's ImportLegacy path stores this verbatim, and apikeys.PrefixDisplayLen +// assumes the prefix shape when rendering keys in the dashboard. +func generateAPIKey() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return "cix_" + base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +// generatePassword returns a 24-character base64url password. +// +// It is shown once, in a dialog, and the server forces a change at first login, +// so readability matters more than memorability. base64url avoids the quoting +// problem entirely: the value is written into a shell-sourced env file, and a +// generated password containing a quote would break the wrapper script. +func generatePassword() (string, error) { + var b [18]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} diff --git a/cli/launcher/launchd_darwin.go b/cli/launcher/launchd_darwin.go new file mode 100644 index 00000000..640e0c21 --- /dev/null +++ b/cli/launcher/launchd_darwin.go @@ -0,0 +1,304 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// launchd control for the cix-server agent. +// +// The label, paths and helper semantics deliberately match install-server.sh +// (:43-47, :211-270) so a machine never ends up with two competing definitions +// of "the cix server agent". The one divergence is KeepAlive — see writePlist. +// +// The hard-won rule from the installer, which applies verbatim here: no +// launchctl exit code is worth trusting. `bootout` returns while the job is +// still draining out of the domain, and the legacy `load` exits 0 even when it +// failed. Every step asks launchd whether the label is really there. + +const ( + launchdLabel = "com.cix.server" + + // Provenance marker written into the wrapper script. Its absence is how we + // recognise a wrapper generated by install-server.sh — same label, but + // pointing at a repo checkout rather than at this bundle. + managedByMarker = "# managed-by: cix.app" +) + +func launchdDomain() string { return fmt.Sprintf("gui/%d", os.Getuid()) } + +func launchdTarget() string { return launchdDomain() + "/" + launchdLabel } + +func plistPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "LaunchAgents", launchdLabel+".plist"), nil +} + +func wrapperPath() (string, error) { + dir, err := cixHome() + if err != nil { + return "", err + } + return filepath.Join(dir, "launchd", "run-cix-server.sh"), nil +} + +func logDir() (string, error) { + dir, err := cixHome() + if err != nil { + return "", err + } + return filepath.Join(dir, "logs"), nil +} + +// runLaunchctl runs one launchctl subcommand with a deadline. launchctl is +// normally instantaneous, so the timeout is a guard against a wedged domain +// rather than an expected wait. +func runLaunchctl(args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "launchctl", args...).CombinedOutput() + return string(out), err +} + +// launchdLoaded reports whether the agent is present in the user domain. +// `launchctl print` is the only trustworthy check available. +func launchdLoaded() bool { + _, err := runLaunchctl("print", launchdTarget()) + return err == nil +} + +// launchdPID returns the pid of the running job, or 0 when the job is +// registered but has no process — never started, crashed, or on its way out. +func launchdPID() int { + out, err := runLaunchctl("print", launchdTarget()) + if err != nil { + return 0 + } + for line := range strings.SplitSeq(out, "\n") { + line = strings.TrimSpace(line) + if rest, ok := strings.CutPrefix(line, "pid = "); ok { + if pid, err := strconv.Atoi(strings.TrimSpace(rest)); err == nil { + return pid + } + } + } + return 0 +} + +// launchdBootout removes the job and waits for it to actually leave the domain. +// Bootstrapping the same label inside the drain window fails with an +// "Input/output error" that reads like a permissions problem. +func launchdBootout() error { + _, _ = runLaunchctl("bootout", launchdTarget()) + for range 50 { + if !launchdLoaded() { + return nil + } + time.Sleep(200 * time.Millisecond) + } + return fmt.Errorf("launchd job %s did not leave the domain", launchdLabel) +} + +// launchdBootstrap loads the agent, draining any previous instance first. The +// whole cycle is retried: a domain that was busy on the first attempt is +// usually ready on the second. +func launchdBootstrap() error { + path, err := plistPath() + if err != nil { + return err + } + var lastOut string + for range 3 { + if err := launchdBootout(); err != nil { + return err + } + out, err := runLaunchctl("bootstrap", launchdDomain(), path) + lastOut = out + if err == nil && launchdLoaded() { + return nil + } + time.Sleep(time.Second) + } + return fmt.Errorf("could not bootstrap %s: %s", launchdLabel, strings.TrimSpace(lastOut)) +} + +// startServer brings the server up. +// +// With KeepAlive=false the job may be loaded but idle, so "start" is two steps: +// make sure the label is in the domain, then kickstart the process. kickstart +// on an already-running job is a no-op restart, which is the right behaviour +// for a menu item that means "make it run". +func startServer() error { + if !launchdLoaded() { + if err := launchdBootstrap(); err != nil { + return err + } + } + if out, err := runLaunchctl("kickstart", launchdTarget()); err != nil { + return fmt.Errorf("kickstart: %s", strings.TrimSpace(out)) + } + return nil +} + +// stopServer sends SIGTERM to the running process and leaves the job loaded. +// +// Not `bootout`: the server handles SIGTERM and drains, and booting the label +// out would put every subsequent Start through the drain race for no gain. The +// job staying loaded-but-idle is exactly what KeepAlive=false is for. +func stopServer() error { + if !launchdLoaded() { + return nil + } + out, err := runLaunchctl("kill", "SIGTERM", launchdTarget()) + if err != nil { + // "No such process" is success from the user's point of view: the job + // is loaded, nothing is running, which is what they asked for. + if strings.Contains(out, "No such process") || launchdPID() == 0 { + return nil + } + return fmt.Errorf("kill: %s", strings.TrimSpace(out)) + } + return nil +} + +// writeLaunchdFiles regenerates the wrapper and the plist for this bundle. +// +// Called on every launch, so an app that was moved, or replaced by an update, +// re-points the agent at itself without the user doing anything. +func writeLaunchdFiles(b bundle, runAtLoad bool) error { + wrapper, err := wrapperPath() + if err != nil { + return err + } + plist, err := plistPath() + if err != nil { + return err + } + logs, err := logDir() + if err != nil { + return err + } + envPath, err := serverEnvPath() + if err != nil { + return err + } + + for _, dir := range []string{filepath.Dir(wrapper), filepath.Dir(plist), logs} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + + // The wrapper holds all the configuration indirection so the plist stays + // static: launchd only re-reads a plist at bootstrap, but the wrapper is + // read afresh on every start, so editing server.env and restarting is + // enough. CIX_LLAMA_BIN_DIR is not set — cix-server resolves llama/ next to + // its own executable, and inside the bundle that is already correct. + script := fmt.Sprintf(`#!/usr/bin/env bash +%s +# Generated by cix.app — launchd entry point for cix-server. +# Regenerated on every app launch; edit ~/.cix/server.env instead. +set -euo pipefail +set -a +source %q +set +a +exec %q +`, managedByMarker, envPath, b.Server) + + if err := os.WriteFile(wrapper, []byte(script), 0o755); err != nil { + return err + } + if err := os.Chmod(wrapper, 0o755); err != nil { + return err + } + + runAtLoadXML := "" + if runAtLoad { + runAtLoadXML = "" + } + + // KeepAlive=false, a deliberate divergence from install-server.sh. + // With KeepAlive=true, Stop has to be a bootout (launchd would restart + // anything it merely killed), which drags the drain race onto the hot path + // of a menu click. The trade is that a crashed server is not resurrected — + // acceptable when a menu bar item shows it as stopped within five seconds. + doc := fmt.Sprintf(` + + + Label%s + ProgramArguments + %s + RunAtLoad%s + KeepAlive + StandardOutPath%s + StandardErrorPath%s + +`, launchdLabel, wrapper, runAtLoadXML, + filepath.Join(logs, "cix-server.log"), + filepath.Join(logs, "cix-server.err")) + + return os.WriteFile(plist, []byte(doc), 0o644) +} + +// autostartEnabled reads RunAtLoad back out of the installed plist. +// +// The plist is regenerated on every app launch and on every Start, so this is +// what stops that regeneration from quietly resetting the user's autostart +// choice. Absent plist means absent choice, which is off. (The menu toggle that +// sets it is Phase 3; this reader is what makes adding it safe.) +func autostartEnabled() bool { + path, err := plistPath() + if err != nil { + return false + } + data, err := os.ReadFile(path) + if err != nil { + return false + } + // Deliberately not a plist parser: we generate this file ourselves, on one + // line, in a known shape. A foreign plist is handled by foreignAgent, which + // takes the app out of the business of writing it at all. + body := string(data) + idx := strings.Index(body, "RunAtLoad") + if idx < 0 { + return false + } + rest := body[idx+len("RunAtLoad"):] + return strings.HasPrefix(strings.TrimSpace(rest), "") +} + +// foreignAgent reports whether a launchd agent under our label exists but was +// set up by something else — in practice install-server.sh, which uses the same +// label (:44) but points it at a repo checkout (:351). +// +// Clobbering it would silently repoint a developer's working install at the +// bundle. The app goes observe-only instead: status, provider and dashboard +// still work over HTTP, but Start/Stop are disabled and labelled. +func foreignAgent() bool { + plist, err := plistPath() + if err != nil { + return false + } + if _, err := os.Stat(plist); err != nil { + return false // nothing there at all; ours to create + } + wrapper, err := wrapperPath() + if err != nil { + return false + } + data, err := os.ReadFile(wrapper) + if err != nil { + // A plist exists but its wrapper does not, or is unreadable: not ours. + return true + } + return !strings.Contains(string(data), managedByMarker) +} diff --git a/cli/launcher/main_darwin.go b/cli/launcher/main_darwin.go index 799cbe42..6f738ad4 100644 --- a/cli/launcher/main_darwin.go +++ b/cli/launcher/main_darwin.go @@ -1,28 +1,23 @@ package main import ( + "errors" "flag" "fmt" "os" + "os/exec" "path/filepath" "strings" ) -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - // cix-launcher — the executable behind cix.app. // -// Scope of this build: the .app packaging pipeline (mac/v* tag → DMG). The -// menu-bar interface, launchd control and self-updater land in later releases. -// What ships today is a correctly signed bundle carrying cix-server, the cix -// CLI and a Metal-enabled llama-server, plus enough of a front end to tell the -// user what they have and prove the bundle is intact. +// A menu bar item showing whether the local cix server is running and what its +// embedding provider is doing, with start/stop and a dashboard link. It is an +// LSUIElement app: no Dock icon, no windows, no application menu. func main() { showVersion := flag.Bool("v", false, "print version and exit") - report := flag.Bool("report", false, "print the bundle report to stdout instead of showing a dialog") + report := flag.Bool("report", false, "print a bundle report to stdout and exit, instead of showing the menu") flag.Parse() if *showVersion { @@ -33,8 +28,8 @@ func main() { b, err := locateBundle() if err != nil { // Running outside a .app is a developer scenario (`go run ./launcher`), - // not a user-facing error — there is no bundle to report on, so say so - // on the terminal that is definitely attached and stop. + // not a user-facing error — there is no bundle to manage, so say so on + // the terminal that is definitely attached and stop. fmt.Fprintf(os.Stderr, "cix-launcher %s: %v\n", version, err) os.Exit(1) } @@ -43,31 +38,64 @@ func main() { dialogIcon = icon } + // -report is the scriptable path: CI verifies a freshly built bundle with + // it, and it never opens a window. + if *report { + fmt.Println(bundleReport(b)) + return + } + if isTranslocated(b) { - msg := "macOS is running cix from a temporary read-only copy, so it cannot manage a server.\n\n" + - "Move cix.app to your Applications folder and open it from there." - if *report { - fmt.Fprintln(os.Stderr, msg) - os.Exit(1) - } - _ = alert("Move cix.app to Applications", msg) + // Gatekeeper is running the app from a randomised read-only copy, which + // it does to any quarantined app opened from outside /Applications. + // Everything appears to work until the copy is reaped, taking the + // launchd job's target with it — so refuse rather than half-work. + _ = alert("Move cix to Applications", + "macOS is running cix from a temporary copy, so it cannot manage a server.\n\n"+ + "Move cix.app to your Applications folder and open it from there.") os.Exit(1) } - body := bundleReport(b) + stripQuarantine(b) - // A .app has no terminal attached: writing to stdout on a double-click is - // indistinguishable from crashing. -report exists so the same information - // is scriptable for CI and for the DMG verification steps. - if *report { - fmt.Println(body) - return + if needsFirstRun() { + if err := runFirstRun(b); err != nil { + if errors.Is(err, errCancelled) { + // Setup is resumable: the app stays in the menu bar with Start + // disabled, and the next launch offers the wizard again. + _ = alert("Setup cancelled", + "cix has not been set up yet, so the server cannot start.\n\n"+ + "Quit and reopen cix when you want to finish setting it up.") + } else { + _ = alert("Setup failed", fmt.Sprintf("cix could not complete first-time setup.\n\n%v", err)) + } + } + } else if !foreignAgent() { + // Re-point the launchd agent at this bundle. An app that was moved, or + // replaced by an update, would otherwise keep a job aimed at a path + // that no longer holds the binary. + if err := writeLaunchdFiles(b, autostartEnabled()); err != nil { + fmt.Fprintf(os.Stderr, "cix-launcher: could not refresh launchd files: %v\n", err) + } } - if err := alert("cix "+displayVersion(), body); err != nil { - fmt.Fprintf(os.Stderr, "cix-launcher: %v\n", err) - os.Exit(1) - } + runMenu(b) +} + +// stripQuarantine clears com.apple.quarantine from the whole bundle, once. +// +// Not cosmetic: the nested llama-server inherits the quarantine flag from the +// disk image, and macOS then SIGKILLs it on exec with EMPTY STDERR. From the +// supervisor's side that is indistinguishable from a crash, which is precisely +// the failure server/Makefile documents for stale signatures. Best-effort — on +// a read-only volume there is nothing to do and nothing to report. +func stripQuarantine(b bundle) { + _ = exec.Command("xattr", "-dr", "com.apple.quarantine", b.Root).Run() +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil } // bundleReport asks each bundled binary for its own version rather than @@ -82,15 +110,10 @@ func bundleReport(b bundle) string { fmt.Fprintf(&sb, "CLI: %s\n", binaryVersion(b.CLI, "--version")) if _, err := os.Stat(b.LlamaDir); err == nil { - fmt.Fprintf(&sb, "Embeddings: bundled llama-server (Metal)\n") + sb.WriteString("Embeddings: bundled llama-server (Metal)\n") } else { fmt.Fprintf(&sb, "Embeddings: MISSING — %s not found\n", b.LlamaDir) } - sb.WriteString("\nThe menu-bar interface is not part of this release. ") - sb.WriteString("To run the server now:\n\n") - fmt.Fprintf(&sb, " %s\n", b.Server) - sb.WriteString("\nSee doc/MACOS_APP.md for the required environment variables.") - return sb.String() } diff --git a/cli/launcher/menu_darwin.go b/cli/launcher/menu_darwin.go new file mode 100644 index 00000000..386215c0 --- /dev/null +++ b/cli/launcher/menu_darwin.go @@ -0,0 +1,165 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "fyne.io/systray" +) + +// The menu bar UI. +// +// systray.Run takes over the calling goroutine and must own the main one — on +// macOS the status item lives on the AppKit main thread. Everything else here +// runs in goroutines and only touches systray through its setters, which are +// safe to call from anywhere. + +type menu struct { + bundle bundle + poll *poller + stop chan struct{} + + statusItem *systray.MenuItem + embeddingsItem *systray.MenuItem + modelItem *systray.MenuItem + startStopItem *systray.MenuItem + dashboardItem *systray.MenuItem +} + +func runMenu(b bundle) { + m := &menu{bundle: b, poll: newPoller(), stop: make(chan struct{})} + systray.Run(m.onReady, m.onExit) +} + +func (m *menu) onReady() { + if icon, err := os.ReadFile(filepath.Join(m.bundle.Resources, "cixTemplate@2x.png")); err == nil { + // SetTemplateIcon, not SetIcon: macOS recolours a template image for + // dark mode, for a tinted menu bar and for the pressed state, using + // only its alpha channel. A coloured icon here is a smudge at 18 px and + // unreadable in dark mode. + systray.SetTemplateIcon(icon, icon) + } else { + systray.SetTitle("cix") + } + systray.SetTooltip("cix — semantic code search") + + m.statusItem = systray.AddMenuItem("cix-server: …", "") + m.statusItem.Disable() + m.embeddingsItem = systray.AddMenuItem("Embeddings: …", "") + m.embeddingsItem.Disable() + m.modelItem = systray.AddMenuItem("", "") + m.modelItem.Disable() + m.modelItem.Hide() + + systray.AddSeparator() + m.startStopItem = systray.AddMenuItem("Start Server", "") + m.dashboardItem = systray.AddMenuItem("Open Dashboard", "Open the cix dashboard in your browser") + + systray.AddSeparator() + quitItem := systray.AddMenuItem("Quit cix", "Quit the menu bar app; the server keeps running") + + go m.poll.run(m.stop) + go m.watch() + + go func() { + for { + select { + case <-m.startStopItem.ClickedCh: + go m.toggleServer() + case <-m.dashboardItem.ClickedCh: + go m.openDashboard() + case <-quitItem.ClickedCh: + systray.Quit() + return + } + } + }() +} + +func (m *menu) onExit() { + close(m.stop) +} + +// watch redraws the menu whenever the poller reports a change. +func (m *menu) watch() { + for { + select { + case <-m.stop: + return + case <-m.poll.changed: + m.render(m.poll.snapshotNow()) + } + } +} + +func (m *menu) render(s snapshot) { + m.statusItem.SetTitle(s.ServerLine()) + m.embeddingsItem.SetTitle(s.EmbeddingsLine()) + + if line := s.ModelLine(); line != "" { + m.modelItem.SetTitle(line) + m.modelItem.Show() + } else { + m.modelItem.Hide() + } + + switch { + case !s.Managed: + // Another installation owns the launchd label. Showing an enabled + // Start button that would fight it — or worse, silently repoint it — is + // the wrong behaviour; observing is useful, interfering is not. + m.startStopItem.SetTitle("Start Server (managed externally)") + m.startStopItem.Disable() + case s.State == stateRunning: + m.startStopItem.SetTitle("Stop Server") + m.startStopItem.Enable() + case s.State == stateStarting: + m.startStopItem.SetTitle("Starting…") + m.startStopItem.Disable() + default: + m.startStopItem.SetTitle("Start Server") + m.startStopItem.Enable() + } + + if s.State == stateRunning { + m.dashboardItem.Enable() + } else { + m.dashboardItem.Disable() + } +} + +func (m *menu) toggleServer() { + s := m.poll.snapshotNow() + if !s.Managed { + return + } + + var err error + if s.State == stateRunning { + err = stopServer() + } else { + // Re-point the agent at this bundle before starting. The app may have + // been moved or replaced by an update since the files were written. + if err = writeLaunchdFiles(m.bundle, autostartEnabled()); err == nil { + err = startServer() + } + } + if err != nil { + _ = alert("cix", fmt.Sprintf("Could not %s the server.\n\n%v", + map[bool]string{true: "stop", false: "start"}[s.State == stateRunning], err)) + } + m.poll.refresh() +} + +func (m *menu) openDashboard() { + vars, err := readServerEnv() + if err != nil { + _ = alert("cix", "cix is not set up yet.") + return + } + if err := exec.Command("open", dashboardURL(vars)).Run(); err != nil { + _ = alert("cix", fmt.Sprintf("Could not open the dashboard.\n\n%v", err)) + } +} diff --git a/cli/launcher/status_darwin.go b/cli/launcher/status_darwin.go new file mode 100644 index 00000000..982efc4a --- /dev/null +++ b/cli/launcher/status_darwin.go @@ -0,0 +1,277 @@ +package main + +import ( + "fmt" + "strings" + "sync" + "time" + + "github.com/dvcdsys/code-index/cli/internal/client" +) + +// The launcher reads the server's state from two independent sources, because +// neither alone is sufficient: +// +// - launchd knows whether the job is loaded and whether it has a pid. It does +// NOT know whether the process is serving: cix-server can spend minutes +// loading an embedding model before it answers anything. +// - /health and /api/v1/status know whether it is serving, and what the +// embedding provider is doing. They cannot distinguish "stopped" from +// "starting", and cannot tell us anything at all when the job was never +// loaded. +// +// Together they separate the three states a user actually cares about: +// stopped, starting, running. + +type runState int + +const ( + stateStopped runState = iota + stateStarting + stateRunning +) + +func (s runState) String() string { + switch s { + case stateRunning: + return "Running" + case stateStarting: + return "Starting…" + default: + return "Stopped" + } +} + +type snapshot struct { + State runState + PID int + Port int + Status *client.StatusResponse + + // EmbeddingsOK is debounced — see poller.pollStatus. + EmbeddingsOK bool + + // Managed is false when the launchd agent belongs to install-server.sh + // rather than to this app; Start/Stop are then disabled. + Managed bool +} + +// providerLabel turns the wire provider kind into something a person can act +// on. +// +// The only translation is "ollama", and it is not cosmetic. In this codebase +// that kind IS the bundled llama-server supervisor — see +// server/internal/embeddings/provider/ollama/provider.go, which spawns +// llama-server and hardcodes ManagesProcess: true. No Ollama is involved and +// none can be: the HTTP providers (openai, voyage) are a different kind +// entirely. The name is historical. Printing it verbatim in a menu bar tells +// the user they are running software they have not installed, and sends them +// looking for an Ollama that is not there. +// +// The dashboard shows the raw kind, and that is fine — it is a diagnostic +// surface for someone who knows the provider model. This is not. +func providerLabel(kind string) string { + switch kind { + case "ollama": + return "llama.cpp (bundled)" + case "": + return "unknown" + default: + return kind + } +} + +// EmbeddingsLine renders the provider row of the menu. +func (s snapshot) EmbeddingsLine() string { + if s.State != stateRunning || s.Status == nil { + return "Embeddings: unknown" + } + label := providerLabel(s.Status.EmbeddingProvider) + if s.EmbeddingsOK { + return "Embeddings: " + label + " — ready" + } + return "Embeddings: " + label + " — not ready" +} + +// ServerLine renders the top row of the menu. +func (s snapshot) ServerLine() string { + switch s.State { + case stateRunning: + return fmt.Sprintf("cix-server: Running (:%d)", s.Port) + case stateStarting: + return "cix-server: Starting…" + default: + if !s.Managed { + return "cix-server: Stopped (managed externally)" + } + return "cix-server: Stopped" + } +} + +// ModelLine renders the model row, or an empty string when there is nothing to +// say — the menu hides the item rather than showing "Model: unknown". +func (s snapshot) ModelLine() string { + if s.State != stateRunning || s.Status == nil || s.Status.EmbeddingModel == "" { + return "" + } + // The server reports the provider's fingerprint ID, which is prefixed with + // the provider kind ("ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF"). The row + // above already names the provider, and the prefix is the same misleading + // name providerLabel exists to avoid — so show the model alone. + model := s.Status.EmbeddingModel + if _, rest, ok := strings.Cut(model, ":"); ok && rest != "" { + model = rest + } + return "Model: " + model +} + +type poller struct { + mu sync.RWMutex + snap snapshot + + // notReadyStrikes counts consecutive /status polls reporting the embedding + // provider as not ready. The server computes model_loaded under a 500 ms + // deadline it can legitimately lose under load, so a single false is not + // evidence of anything; flipping the menu on it produces a row that + // flickers between ready and not-ready while nothing is wrong. + notReadyStrikes int + + changed chan struct{} +} + +const notReadyStrikesRequired = 2 + +func newPoller() *poller { + return &poller{changed: make(chan struct{}, 1)} +} + +func (p *poller) snapshotNow() snapshot { + p.mu.RLock() + defer p.mu.RUnlock() + return p.snap +} + +// notify wakes the menu without ever blocking the poll loop; a pending +// notification already means "re-read the snapshot". +func (p *poller) notify() { + select { + case p.changed <- struct{}{}: + default: + } +} + +// run polls until stop is closed. Health is cheap and unauthenticated, so it +// runs often; /status needs the API key and returns far more than the menu +// changes, so it runs at a third of the rate. +func (p *poller) run(stop <-chan struct{}) { + const ( + healthEvery = 5 * time.Second + statusEvery = 15 * time.Second + ) + + p.pollHealth() + p.pollStatus() + p.notify() + + healthTick := time.NewTicker(healthEvery) + statusTick := time.NewTicker(statusEvery) + defer healthTick.Stop() + defer statusTick.Stop() + + for { + select { + case <-stop: + return + case <-healthTick.C: + p.pollHealth() + p.notify() + case <-statusTick.C: + p.pollStatus() + p.notify() + } + } +} + +// refresh forces an immediate poll — used right after Start or Stop, so the +// menu reflects the click instead of waiting out the tick. +func (p *poller) refresh() { + p.pollHealth() + p.pollStatus() + p.notify() +} + +func (p *poller) pollHealth() { + vars, _ := readServerEnv() // nil map on first run; serverPort falls back + port := serverPort(vars) + managed := !foreignAgent() + + loaded := launchdLoaded() + pid := 0 + if loaded { + pid = launchdPID() + } + + healthy := client.New(localBaseURL(vars), "").Health() == nil + + state := stateStopped + switch { + case healthy: + state = stateRunning + case pid != 0: + // A live process that is not answering yet: cold start, loading the + // model. Reporting "Stopped" here is what makes people restart a server + // that was two minutes from being ready. + state = stateStarting + } + + p.mu.Lock() + p.snap.State = state + p.snap.PID = pid + p.snap.Port = port + p.snap.Managed = managed + if state != stateRunning { + // Provider information from a server that is no longer answering is + // stale by definition. + p.snap.Status = nil + p.snap.EmbeddingsOK = false + p.notReadyStrikes = 0 + } + p.mu.Unlock() +} + +func (p *poller) pollStatus() { + vars, err := readServerEnv() + if err != nil { + return + } + key := vars["CIX_API_KEY"] + if key == "" { + return + } + + st, err := client.New(localBaseURL(vars), key).Status() + if err != nil { + return + } + p.applyStatus(st) +} + +// applyStatus folds one /status response into the snapshot, applying the +// not-ready debounce. Split out from the fetch so the transition rules are +// testable without a server. +func (p *poller) applyStatus(st *client.StatusResponse) { + healthy := st.EmbeddingsHealthy() + + p.mu.Lock() + defer p.mu.Unlock() + p.snap.Status = st + if healthy { + p.notReadyStrikes = 0 + p.snap.EmbeddingsOK = true + return + } + p.notReadyStrikes++ + if p.notReadyStrikes >= notReadyStrikesRequired { + p.snap.EmbeddingsOK = false + } +} diff --git a/cli/launcher/status_darwin_test.go b/cli/launcher/status_darwin_test.go new file mode 100644 index 00000000..0b902862 --- /dev/null +++ b/cli/launcher/status_darwin_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "testing" + + "github.com/dvcdsys/code-index/cli/internal/client" +) + +func TestProviderLabel(t *testing.T) { + tests := map[string]string{ + // The bundled llama-server supervisor. Showing the raw kind here would + // tell the user they are running Ollama, which is not installed and + // never was — see the comment on providerLabel. + "ollama": "llama.cpp (bundled)", + "openai": "openai", + "voyage": "voyage", + "": "unknown", + } + for kind, want := range tests { + if got := providerLabel(kind); got != want { + t.Errorf("providerLabel(%q) = %q, want %q", kind, got, want) + } + } +} + +func TestSnapshotLines(t *testing.T) { + running := snapshot{ + State: stateRunning, + Port: 21847, + Managed: true, + Status: &client.StatusResponse{ + EmbeddingProvider: "ollama", + EmbeddingModel: "ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF", + }, + EmbeddingsOK: true, + } + + if got, want := running.ServerLine(), "cix-server: Running (:21847)"; got != want { + t.Errorf("ServerLine() = %q, want %q", got, want) + } + if got, want := running.EmbeddingsLine(), "Embeddings: llama.cpp (bundled) — ready"; got != want { + t.Errorf("EmbeddingsLine() = %q, want %q", got, want) + } + // The provider prefix is stripped: the row above already names the + // provider, and repeating the misleading kind defeats providerLabel. + if got, want := running.ModelLine(), "Model: awhiteside/CodeRankEmbed-Q8_0-GGUF"; got != want { + t.Errorf("ModelLine() = %q, want %q", got, want) + } +} + +func TestSnapshotLines_NotRunning(t *testing.T) { + // A cold start loads the embedding model in silence for up to a few + // minutes. Calling that "Stopped" is what makes people kill and restart a + // server that was nearly ready, so it gets its own state. + starting := snapshot{State: stateStarting, Managed: true} + if got, want := starting.ServerLine(), "cix-server: Starting…"; got != want { + t.Errorf("ServerLine() = %q, want %q", got, want) + } + // Provider details from a server that is not answering are stale by + // definition, so no row claims otherwise. + if got, want := starting.ModelLine(), ""; got != want { + t.Errorf("ModelLine() = %q, want %q (hidden)", got, want) + } + if got, want := starting.EmbeddingsLine(), "Embeddings: unknown"; got != want { + t.Errorf("EmbeddingsLine() = %q, want %q", got, want) + } + + // An agent installed by install-server.sh owns the same launchd label. The + // app observes it but must not offer to drive it. + external := snapshot{State: stateStopped, Managed: false} + if got, want := external.ServerLine(), "cix-server: Stopped (managed externally)"; got != want { + t.Errorf("ServerLine() = %q, want %q", got, want) + } +} + +func TestPollerDebouncesNotReady(t *testing.T) { + // model_loaded is computed server-side under a 500 ms deadline it can + // legitimately lose under load. One false is not evidence; flipping the + // menu row on it produces a flicker while nothing is wrong. + p := newPoller() + p.snap.EmbeddingsOK = true + + notReady := &client.StatusResponse{ + EmbeddingProvider: "ollama", + EmbeddingProviderManagesProcess: true, + ModelLoaded: false, + } + + p.applyStatus(notReady) + if !p.snapshotNow().EmbeddingsOK { + t.Error("EmbeddingsOK flipped after a single not-ready poll; want it held until the second") + } + + p.applyStatus(notReady) + if p.snapshotNow().EmbeddingsOK { + t.Error("EmbeddingsOK still true after two consecutive not-ready polls") + } + + // One good poll clears the count, so a later single miss cannot flip it. + p.applyStatus(&client.StatusResponse{ + EmbeddingProvider: "ollama", + EmbeddingProviderManagesProcess: true, + ModelLoaded: true, + }) + if !p.snapshotNow().EmbeddingsOK { + t.Fatal("EmbeddingsOK false after a ready poll") + } + p.applyStatus(notReady) + if !p.snapshotNow().EmbeddingsOK { + t.Error("a single not-ready poll after a ready one flipped the row") + } +} + +func TestPollerTrustsHTTPProviders(t *testing.T) { + // openai / voyage have no local process to be down, and the server skips + // the liveness probe for them. Debouncing to "not ready" there would show a + // permanently red row for a provider that works. + p := newPoller() + http := &client.StatusResponse{ + EmbeddingProvider: "voyage", + EmbeddingProviderManagesProcess: false, + ModelLoaded: false, + } + p.applyStatus(http) + p.applyStatus(http) + p.applyStatus(http) + if !p.snapshotNow().EmbeddingsOK { + t.Error("EmbeddingsOK = false for an HTTP provider; want true regardless of model_loaded") + } +} diff --git a/doc/MACOS_APP.md b/doc/MACOS_APP.md index 36c2457e..d8059dcb 100644 --- a/doc/MACOS_APP.md +++ b/doc/MACOS_APP.md @@ -3,11 +3,13 @@ `cix.app` packages the cix server, the `cix` CLI and a Metal-accelerated `llama-server` into a single drag-to-install application for Apple Silicon. -> **Scope of the current release.** The packaging pipeline ships first: the app -> bundles and signs correctly, and every component reports its own version. The -> menu-bar interface — start/stop, provider status, dashboard link, password -> reset, autostart and self-update — lands in subsequent `mac/v*` releases. -> Until then the bundled binaries are run directly, as described below. +The app sits in the menu bar and shows whether the server is running and what +its embedding provider is doing, with start/stop and a link to the dashboard. +There is no Dock icon and no window. + +> **Not in this release yet:** the autostart toggle, password reset from the +> menu, and self-update. Autostart can be set up with `install-server.sh`, and +> password reset is a command, described below. ## Requirements @@ -94,31 +96,72 @@ also reports its own: /Applications/cix.app/Contents/MacOS/cix-launcher -report ``` -## Running the server from the current release +## First run -The server refuses to start against an empty database unless it is told which -admin account to create — it will not invent one silently. On first run: +The very first launch asks for an email address, then generates a password and +an API key, starts the server, and shows you the credentials. You will be asked +to change the password when you first sign in. -```bash -export CIX_DATA_DIR="$HOME/.cix/data" -export CIX_SQLITE_PATH="$HOME/.cix/data/cix.db" -export CIX_PORT=21847 -export CIX_BOOTSTRAP_ADMIN_EMAIL="you@example.com" -export CIX_BOOTSTRAP_ADMIN_PASSWORD="choose-a-strong-one" +This step is not decoration. `cix-server` refuses to start against an empty +database unless it is told which admin account to create — it will not invent +one silently — so an app with no configuration could never start its own server. + +What it writes: + +| Path | Contents | +|---|---| +| `~/.cix/server.env` | port, data paths, API key, bootstrap credentials (mode 0600) | +| `~/.cix/data/` | SQLite database and the index | +| `~/.cix/launchd/run-cix-server.sh` | launchd entry point; sources `server.env` | +| `~/Library/LaunchAgents/com.cix.server.plist` | the launchd agent | +| `~/.cix/config.yaml` | a `local` server entry, so the `cix` CLI works | + +`server.env` is the single source of truth for the server process — the plist +carries no configuration. To change a setting, edit that file and use +**Stop Server** then **Start Server**. -/Applications/cix.app/Contents/MacOS/cix-server +The CLI's default server is left alone if you already had one. Installing an app +should not silently repoint a `cix` command that was talking to a remote server. + +macOS will show a notification saying `run-cix-server.sh` can run in the +background, with a link to Login Items & Extensions. That is expected: it is how +macOS announces any newly registered background agent. + +## The menu + +``` +cix-server: Running (:21847) +Embeddings: llama.cpp (bundled) — ready +Model: awhiteside/CodeRankEmbed-Q8_0-GGUF +───────────── +Stop Server +Open Dashboard +───────────── +Quit cix ``` -The dashboard is then at , and you will be -required to change that bootstrap password at first login. On later runs the two -`CIX_BOOTSTRAP_ADMIN_*` variables are no longer needed. +**Starting…** is its own state, distinct from Running and Stopped. A cold start +loads the embedding model and can take anywhere from 30 seconds to several +minutes, in silence — a server showing "Starting…" is working, not stuck. -Cold starts are slow and quiet: loading the embedding model takes 30–60 seconds -warm and can take several minutes the first time, and the ready banner only -appears at the end. A server that has not answered yet is usually still starting. +**Quit cix** quits the menu bar app only. The server is a launchd agent and +keeps running; use **Stop Server** first if you want it down. -To use the CLI against it, put it on your `PATH` as a **symlink**, so it keeps -pointing at the current bundle after an update: +**Open Dashboard** always targets the port in `server.env`, never the CLI's +configured server — that one may legitimately point at a remote cix. + +### If you already use install-server.sh + +Both use the same launchd label, `com.cix.server`. When the app finds an agent +it did not create, it does not touch it: status, the provider row and the +dashboard link keep working over HTTP, and Start/Stop are disabled and labelled +*managed externally*. The app is then useful alongside a development checkout +instead of fighting it. + +## Using the CLI + +Put it on your `PATH` as a **symlink**, so it keeps pointing at the current +bundle after an update: ```bash ln -sf /Applications/cix.app/Contents/MacOS/cix /usr/local/bin/cix @@ -133,7 +176,12 @@ ln -sf /Applications/cix.app/Contents/MacOS/cix /usr/local/bin/cix ``` It prints a generated temporary password. Point it at the same `CIX_DATA_DIR` / -`CIX_SQLITE_PATH` the server uses, or it will not find the database. +`CIX_SQLITE_PATH` the server uses, or it will not find the database: + +```bash +set -a; source ~/.cix/server.env; set +a +/Applications/cix.app/Contents/MacOS/cix-server -reset-password you@example.com +``` ## Building it yourself @@ -148,6 +196,8 @@ order and why each step is the way it is. ## Uninstalling ```bash +launchctl bootout "gui/$(id -u)/com.cix.server" +rm -f ~/Library/LaunchAgents/com.cix.server.plist ~/.cix/launchd/run-cix-server.sh rm -rf /Applications/cix.app rm -f /usr/local/bin/cix # if you created the symlink rm -rf ~/.cix # config, database and index data diff --git a/server/internal/httpapi/status_contract_test.go b/server/internal/httpapi/status_contract_test.go new file mode 100644 index 00000000..bade7b87 --- /dev/null +++ b/server/internal/httpapi/status_contract_test.go @@ -0,0 +1,83 @@ +package httpapi + +import ( + "net/http/httptest" + "testing" + + "github.com/dvcdsys/code-index/server/internal/versioncheck" +) + +// The goldens below pin the exact wire JSON of GET /api/v1/status. The CLI and +// the macOS launcher live in a SEPARATE module (github.com/dvcdsys/code-index/ +// cli) that cannot import this one, so the contract is held by twin fixtures: +// identical goldens live in cli/internal/client/status_test.go. Rename, retype +// or drop a field on either side and one of these tests fails, forcing the +// other module to be updated in the same PR. +// +// Key order here is alphabetical because GetStatus builds a map[string]any and +// encoding/json sorts map keys. That is stable, so the bytes can be pinned. +// +// Two goldens, because the response has two shapes. The version-check fields +// are folded in only when Deps.VersionCheck is wired; a consumer that assumes +// they are always present will read `update_available: false` as "you are up to +// date" on a server where the check is simply switched off. +const ( + wantStatusWire = `{"active_indexing_jobs":0,"api_version":"v1","backend":"go","embedding_model":"awhiteside/CodeRankEmbed-Q8_0-GGUF","embedding_provider":"","embedding_provider_manages_process":false,"model_loaded":false,"projects":0,"server_version":"1.2.3","status":"ok"}` + + wantStatusWithVersionCheckWire = `{"active_indexing_jobs":0,"api_version":"v1","backend":"go","embedding_model":"awhiteside/CodeRankEmbed-Q8_0-GGUF","embedding_provider":"","embedding_provider_manages_process":false,"latest_version":null,"model_loaded":false,"projects":0,"release_url":null,"server_version":"1.2.3","status":"ok","update_available":false,"version_check":{"checked_at":null,"enabled":true,"error":null}}` +) + +func statusServer(vc *versioncheck.Service) *Server { + // Deliberately minimal Deps: a nil DB makes the two counts 0 and a nil + // EmbeddingSvc makes the provider fields their zero values, so the body is + // deterministic without standing up a database or a llama sidecar. + return &Server{Deps: Deps{ + ServerVersion: "1.2.3", + APIVersion: "v1", + Backend: "go", + EmbeddingModel: "awhiteside/CodeRankEmbed-Q8_0-GGUF", + VersionCheck: vc, + }} +} + +func TestGetStatus_WireContract(t *testing.T) { + rec := httptest.NewRecorder() + statusServer(nil).GetStatus(rec, httptest.NewRequest("GET", "/api/v1/status", nil)) + + if rec.Code != 200 { + t.Fatalf("status = %d, want 200", rec.Code) + } + got := trimTrailingNewline(rec.Body.String()) + if got != wantStatusWire { + t.Errorf("/status wire shape drifted from the CLI contract:\n got: %s\nwant: %s\n"+ + "→ update cli/internal/client/client.go and its twin fixture in the same PR.", got, wantStatusWire) + } +} + +func TestGetStatus_WireContract_WithVersionCheck(t *testing.T) { + // Never Run(), so the snapshot is the constructor's initial state: enabled, + // nothing checked yet. That is exactly the shape a client sees during the + // first minute of every server's life (the poller has a 60s initial delay), + // so it is worth pinning rather than only the post-check shape. + vc := versioncheck.New(versioncheck.Config{ + Enabled: true, + Repo: "dvcdsys/code-index", + CurrentVersion: "1.2.3", + }, nil) + + rec := httptest.NewRecorder() + statusServer(vc).GetStatus(rec, httptest.NewRequest("GET", "/api/v1/status", nil)) + + got := trimTrailingNewline(rec.Body.String()) + if got != wantStatusWithVersionCheckWire { + t.Errorf("/status version-check shape drifted from the CLI contract:\n got: %s\nwant: %s\n"+ + "→ update cli/internal/client/client.go and its twin fixture in the same PR.", got, wantStatusWithVersionCheckWire) + } +} + +func trimTrailingNewline(s string) string { + for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { + s = s[:len(s)-1] + } + return s +} From 41a066e19be7e85d0ecbee74655b506fc9fbd2e9 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 13:32:40 +0100 Subject: [PATCH 06/12] feat(mac): fixed-width menu, status dots, and a local/network toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to the menu bar, plus the server config the last one needs. Width. An NSMenu is exactly as wide as its widest row, so an untruncated model id — `ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF`, 41 characters — stretched the whole menu to fit one line nobody reads in full. Every row is now capped at the same width, so the menu is a predictable size rather than a function of whichever model happens to be configured. Truncation is from the middle, since these are qualified names where both ends carry information and tail truncation would render every Hugging Face model as its owner. Status. Coloured dots — green running, amber starting, red stopped, grey unknown — instead of a "— ready" suffix, which is how macOS status apps say this and which costs no width. Amber for starting is deliberate: a cold start loading a model is working, and red is what makes people kill it. The dots are generated rather than shipped as files, and are NOT template images: a template is recoloured from its alpha channel alone, which would make all four the same colour and erase the only thing they say. The model row gets a transparent dot of the same size, because AppKit indents a title by its image width and without one that row starts left of its neighbours. Details submenu, replacing per-item tooltips. Native NSMenuItem tooltips have two behaviours that make them unusable here, neither with an API: once any tooltip in the app has appeared, every subsequent one shows with no delay at all, and they are positioned against the item rather than the pointer. Fixing either means giving every row a custom NSView with its own tracking area — that is, writing the menu in Objective-C instead of using systray. A submenu gets native timing and placement for free, and is where the full model id, the pid, the port, the exposure and the reason Start/Stop is disabled now live. Local/network toggle. This needed a server change: the HTTP server hardcoded `fmt.Sprintf(":%d", cfg.Port)`, so it always bound every interface with no way to say otherwise. Adds CIX_BIND_ADDR, defaulting to empty — every interface, which is what containers need and what every existing deployment already has, so nothing changes for them. Config.ListenAddr composes it with net.JoinHostPort so an IPv6 literal is bracketed rather than concatenated into a different address, and validation rejects a URL or a host:port pair, both of which bind as a hostname on some systems and then fail to resolve — a server that is silently unreachable instead of one that refused to start. The app writes 127.0.0.1 at first run, deliberately unlike the server default: exposing a code index to the local network is a choice to make on purpose. The menu toggle asks for confirmation when widening access and not when narrowing it, and restarts the server, because the bind address is read once at process start and saying "saved" without restarting would be a lie discovered later. Existing installs keep whatever they had; the app does not silently narrow a server someone already relies on. Verified at the socket, not the config file: toggling produced `TCP 127.0.0.1:21847` and a LAN request refused, then `TCP *:21847` and the same request served, with localhost working throughout and a new pid each time. Cancelling the confirmation left both the file and the binding untouched. Co-Authored-By: Claude Opus 5 --- .env.example | 5 + cli/launcher/dots_darwin.go | 107 ++++++++++++++++ cli/launcher/env_darwin.go | 29 +++++ cli/launcher/firstrun_darwin.go | 5 + cli/launcher/menu_darwin.go | 150 +++++++++++++++++++++- cli/launcher/status_darwin.go | 142 ++++++++++++++++++++- cli/launcher/status_darwin_test.go | 176 +++++++++++++++++++++++++- doc/CONFIG_REFERENCE.md | 1 + doc/MACOS_APP.md | 33 ++++- server/cmd/cix-server/main.go | 2 +- server/internal/config/config.go | 62 ++++++++- server/internal/config/config_test.go | 64 ++++++++++ 12 files changed, 754 insertions(+), 22 deletions(-) create mode 100644 cli/launcher/dots_darwin.go diff --git a/.env.example b/.env.example index 464f2ac1..4ed6acb1 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,11 @@ CIX_BOOTSTRAP_ADMIN_PASSWORD=change-me-on-first-login # ── Networking + storage ────────────────────────────────────────────────── CIX_PORT=21847 +# Interface to listen on. Unset (the default) means every interface, which is +# what a container needs. Set to 127.0.0.1 to make the server reachable only +# from this machine — the right choice for a desktop install, where exposing a +# code index to the whole LAN is rarely intended. Bare address, no port. +# CIX_BIND_ADDR=127.0.0.1 CIX_CHROMA_PERSIST_DIR=~/.cix/data/chroma CIX_SQLITE_PATH=~/.cix/data/sqlite/projects.db CIX_GGUF_CACHE_DIR=~/.cix/data/models diff --git a/cli/launcher/dots_darwin.go b/cli/launcher/dots_darwin.go new file mode 100644 index 00000000..99181259 --- /dev/null +++ b/cli/launcher/dots_darwin.go @@ -0,0 +1,107 @@ +package main + +import ( + "bytes" + "image" + "image/color" + "image/png" + "math" + "sync" +) + +// Status dots for the menu rows. +// +// A coloured dot beside a row is how macOS status apps show liveness, and it +// carries the state without spending menu width on words like "— ready". That +// matters here: an NSMenu is as wide as its widest row, so every character in a +// status string is width the model name does not get. +// +// These are NOT template images. A template image is recoloured by macOS from +// its alpha channel alone, which would turn every dot the same colour and erase +// the only thing they say. The menu-bar glyph is a template image; these are +// not, deliberately. +// +// Generated rather than shipped as files: they are three flat circles, and a +// generator is smaller than the PNGs plus the build-script lines to copy them. + +// Colours picked to stay legible on both light and dark menu backgrounds, and +// to survive the most common form of colour blindness by differing in lightness +// as well as hue — a green/red pair alone would not. +var ( + dotGreen = color.NRGBA{R: 0x30, G: 0xB0, B: 0x50, A: 0xFF} + dotAmber = color.NRGBA{R: 0xE0, G: 0x94, B: 0x1B, A: 0xFF} + dotRed = color.NRGBA{R: 0xD7, G: 0x3E, B: 0x2C, A: 0xFF} // the CIX brand red + dotGrey = color.NRGBA{R: 0x8E, G: 0x8E, B: 0x93, A: 0xFF} + + // dotBlank is a fully transparent dot used purely as a spacer. AppKit + // indents a menu item's title by its image width, so a row without an image + // starts further left than its neighbours and the group reads as ragged. + // An invisible image of the same size keeps the titles on one edge. + dotBlank = color.NRGBA{} +) + +// dotSize is the rendered pixel size. 24 px at @2x renders as a 12 pt dot, +// which is the size AppKit gives a menu item image next to body text. +const dotSize = 24 + +var ( + dotOnce sync.Once + dotCache map[color.NRGBA][]byte +) + +// dotPNG returns a PNG of a filled circle in c, encoded once and reused. +func dotPNG(c color.NRGBA) []byte { + dotOnce.Do(func() { + dotCache = map[color.NRGBA][]byte{} + for _, col := range []color.NRGBA{dotGreen, dotAmber, dotRed, dotGrey, dotBlank} { + dotCache[col] = renderDot(col) + } + }) + if b, ok := dotCache[c]; ok { + return b + } + return renderDot(c) +} + +func renderDot(c color.NRGBA) []byte { + img := image.NewNRGBA(image.Rect(0, 0, dotSize, dotSize)) + centre := float64(dotSize-1) / 2 + // Leave a pixel of margin so the antialiased edge is not clipped by the + // image bounds, which reads as a flat-sided circle at this size. + radius := centre - 1 + + for y := range dotSize { + for x := range dotSize { + dx := float64(x) - centre + dy := float64(y) - centre + d := math.Hypot(dx, dy) + // Coverage over a one-pixel band at the edge: cheap antialiasing, + // and at 24 px the difference between this and a hard edge is the + // difference between a circle and a cog. + var alpha float64 + switch { + case d <= radius-0.5: + alpha = 1 + case d >= radius+0.5: + alpha = 0 + default: + alpha = radius + 0.5 - d + } + if alpha <= 0 { + continue + } + img.SetNRGBA(x, y, color.NRGBA{ + R: c.R, G: c.G, B: c.B, + A: uint8(math.Round(alpha * float64(c.A))), + }) + } + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + // Encoding a fixed-size in-memory NRGBA image cannot fail; a nil icon + // simply leaves the menu row without one. + return nil + } + return buf.Bytes() +} diff --git a/cli/launcher/env_darwin.go b/cli/launcher/env_darwin.go index e781c90d..eaaec014 100644 --- a/cli/launcher/env_darwin.go +++ b/cli/launcher/env_darwin.go @@ -3,6 +3,7 @@ package main import ( "bufio" "fmt" + "net" "os" "path/filepath" "sort" @@ -137,6 +138,34 @@ func serverPort(vars map[string]string) int { return defaultServerPort } +// Bind addresses the network toggle switches between. +// +// The server's own default is an empty CIX_BIND_ADDR, meaning every interface — +// that is what containers need and every existing deployment already has, so it +// stays. A desktop install is a different situation: the app writes loopback at +// first run, because "everyone on the café Wi-Fi can query my code index" is +// rarely what someone installing a menu bar app had in mind. +const ( + bindLocalOnly = "127.0.0.1" + bindAllInterfaces = "0.0.0.0" +) + +// isLocalOnly reports whether the configured bind address is loopback. +// +// An absent CIX_BIND_ADDR is NOT local-only: it is the server's all-interfaces +// default. Reading it the other way would show a reassuring "local only" on +// exactly the installs that are exposed. +func isLocalOnly(vars map[string]string) bool { + addr := strings.TrimSpace(vars["CIX_BIND_ADDR"]) + if addr == "" { + return false + } + if ip := net.ParseIP(addr); ip != nil { + return ip.IsLoopback() + } + return strings.EqualFold(addr, "localhost") +} + // dashboardURL is built from the server.env port, never from the CLI config. // The CLI's default server may legitimately point at a remote cix — opening // that in a browser when the user clicked "Open Dashboard" on a local menu bar diff --git a/cli/launcher/firstrun_darwin.go b/cli/launcher/firstrun_darwin.go index 14cd4e30..b8dee140 100644 --- a/cli/launcher/firstrun_darwin.go +++ b/cli/launcher/firstrun_darwin.go @@ -91,6 +91,11 @@ func runFirstRun(b bundle) error { "CIX_PORT": strconv.Itoa(defaultServerPort), "CIX_DATA_DIR": dataDir, "CIX_SQLITE_PATH": filepath.Join(dataDir, "cix.db"), + // Loopback by default, unlike the server's own all-interfaces default. + // A container has to be reachable from outside itself; a desktop app + // does not, and exposing a code index to the local network is a choice + // someone should make on purpose. The menu has a toggle for it. + "CIX_BIND_ADDR": bindLocalOnly, // The .app owns updating itself. Leaving the server's own check on // would mean two different components offering the user two different // "update available" prompts for two different tag streams. diff --git a/cli/launcher/menu_darwin.go b/cli/launcher/menu_darwin.go index 386215c0..94f588bc 100644 --- a/cli/launcher/menu_darwin.go +++ b/cli/launcher/menu_darwin.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "time" "fyne.io/systray" ) @@ -15,6 +16,11 @@ import ( // macOS the status item lives on the AppKit main thread. Everything else here // runs in goroutines and only touches systray through its setters, which are // safe to call from anywhere. +// +// Layout follows the platform rather than inventing one: disabled rows carrying +// state with a coloured indicator, then actions, then a checkbox for the one +// setting, then Quit. Every row is width-capped (see maxRowRunes) because an +// NSMenu is as wide as its widest row. type menu struct { bundle bundle @@ -26,8 +32,18 @@ type menu struct { modelItem *systray.MenuItem startStopItem *systray.MenuItem dashboardItem *systray.MenuItem + networkItem *systray.MenuItem + + // detail holds the submenu rows under the server row. They are created + // once and retitled on every render: systray has no way to remove an item, + // so rebuilding the submenu per update would grow it without bound. + detail [detailRows]*systray.MenuItem } +// detailRows is the fixed number of submenu slots. Rows with nothing to say are +// hidden rather than left blank. +const detailRows = 6 + func runMenu(b bundle) { m := &menu{bundle: b, poll: newPoller(), stop: make(chan struct{})} systray.Run(m.onReady, m.onExit) @@ -45,8 +61,16 @@ func (m *menu) onReady() { } systray.SetTooltip("cix — semantic code search") + // The server row is the one enabled row in the status group, because it + // carries the details submenu — a parent has to be enabled for macOS to + // open its submenu. The disclosure arrow reads as "there is more here" + // rather than "this does something", which is what it is. m.statusItem = systray.AddMenuItem("cix-server: …", "") - m.statusItem.Disable() + for i := range m.detail { + m.detail[i] = m.statusItem.AddSubMenuItem("", "") + m.detail[i].Disable() + } + m.embeddingsItem = systray.AddMenuItem("Embeddings: …", "") m.embeddingsItem.Disable() m.modelItem = systray.AddMenuItem("", "") @@ -57,6 +81,9 @@ func (m *menu) onReady() { m.startStopItem = systray.AddMenuItem("Start Server", "") m.dashboardItem = systray.AddMenuItem("Open Dashboard", "Open the cix dashboard in your browser") + systray.AddSeparator() + m.networkItem = systray.AddMenuItemCheckbox("Allow Network Access", "", false) + systray.AddSeparator() quitItem := systray.AddMenuItem("Quit cix", "Quit the menu bar app; the server keeps running") @@ -70,6 +97,8 @@ func (m *menu) onReady() { go m.toggleServer() case <-m.dashboardItem.ClickedCh: go m.openDashboard() + case <-m.networkItem.ClickedCh: + go m.toggleNetworkAccess() case <-quitItem.ClickedCh: systray.Quit() return @@ -96,10 +125,18 @@ func (m *menu) watch() { func (m *menu) render(s snapshot) { m.statusItem.SetTitle(s.ServerLine()) + m.statusItem.SetIcon(dotPNG(s.ServerDot())) + m.renderDetail(s) + m.embeddingsItem.SetTitle(s.EmbeddingsLine()) + m.embeddingsItem.SetIcon(dotPNG(s.EmbeddingsDot())) if line := s.ModelLine(); line != "" { m.modelItem.SetTitle(line) + // A transparent spacer, not a missing icon: AppKit indents a title by + // its image width, so without one this row would start left of the two + // above it and the group would read as ragged. + m.modelItem.SetIcon(dotPNG(dotBlank)) m.modelItem.Show() } else { m.modelItem.Hide() @@ -110,7 +147,7 @@ func (m *menu) render(s snapshot) { // Another installation owns the launchd label. Showing an enabled // Start button that would fight it — or worse, silently repoint it — is // the wrong behaviour; observing is useful, interfering is not. - m.startStopItem.SetTitle("Start Server (managed externally)") + m.startStopItem.SetTitle("Start Server") m.startStopItem.Disable() case s.State == stateRunning: m.startStopItem.SetTitle("Stop Server") @@ -128,6 +165,39 @@ func (m *menu) render(s snapshot) { } else { m.dashboardItem.Disable() } + + if s.LocalOnly { + m.networkItem.Uncheck() + } else { + m.networkItem.Check() + } + if s.Managed { + m.networkItem.Enable() + } else { + // The setting lives in a config file this app does not own. + m.networkItem.Disable() + } +} + +// renderDetail fills the submenu under the server row. Slots with nothing to +// say are hidden, so the submenu never shows an empty line. +func (m *menu) renderDetail(s snapshot) { + lines := [detailRows]string{ + s.DetailProcess(), + s.DetailPort(), + s.DetailNetwork(), + s.DetailModel(), + s.DetailVersion(), + s.DetailManaged(), + } + for i, line := range lines { + if line == "" { + m.detail[i].Hide() + continue + } + m.detail[i].SetTitle(line) + m.detail[i].Show() + } } func (m *menu) toggleServer() { @@ -147,12 +217,84 @@ func (m *menu) toggleServer() { } } if err != nil { - _ = alert("cix", fmt.Sprintf("Could not %s the server.\n\n%v", - map[bool]string{true: "stop", false: "start"}[s.State == stateRunning], err)) + verb := "start" + if s.State == stateRunning { + verb = "stop" + } + _ = alert("cix", fmt.Sprintf("Could not %s the server.\n\n%v", verb, err)) } m.poll.refresh() } +// toggleNetworkAccess switches CIX_BIND_ADDR between loopback and all +// interfaces, then restarts the server so the change takes effect. +// +// Widening access asks first. Nothing else in this menu changes what the +// machine exposes to the network, and a checkbox is an easy thing to hit by +// accident; narrowing access needs no confirmation because it can only be safe. +func (m *menu) toggleNetworkAccess() { + vars, err := readServerEnv() + if err != nil { + _ = alert("cix", "cix is not set up yet.") + return + } + + wasRunning := m.poll.snapshotNow().State == stateRunning + local := isLocalOnly(vars) + + if local { + ok, err := confirm("Allow access from your network?", + fmt.Sprintf("cix will accept connections from any machine that can reach this Mac on port %d, "+ + "instead of only from this Mac.\n\n"+ + "Accounts and API keys still apply — this does not disable authentication — but the login page "+ + "and the API become reachable from your network.\n\n"+ + "The server will restart.", serverPort(vars)), + "Allow") + if err != nil || !ok { + // Put the checkbox back: the click already toggled it visually. + m.render(m.poll.snapshotNow()) + return + } + vars["CIX_BIND_ADDR"] = bindAllInterfaces + } else { + vars["CIX_BIND_ADDR"] = bindLocalOnly + } + + if err := writeServerEnv(vars); err != nil { + _ = alert("cix", fmt.Sprintf("Could not save the setting.\n\n%v", err)) + m.render(m.poll.snapshotNow()) + return + } + + // The bind address is read once, when the process starts, so the setting + // means nothing until the server is restarted. Saying "saved" without doing + // that would be a lie the user only discovers when it does not work. + if wasRunning { + if err := restartServer(); err != nil { + _ = alert("cix", fmt.Sprintf("The setting was saved, but the server could not be restarted.\n\n%v", err)) + } + } + m.poll.refresh() +} + +// restartServer stops the server, waits for the process to actually go away, +// and starts it again. The wait matters: kickstarting while the old process +// still holds the listening socket produces a server that exits immediately +// with "address already in use". +func restartServer() error { + if err := stopServer(); err != nil { + return err + } + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) { + if launchdPID() == 0 { + break + } + time.Sleep(250 * time.Millisecond) + } + return startServer() +} + func (m *menu) openDashboard() { vars, err := readServerEnv() if err != nil { diff --git a/cli/launcher/status_darwin.go b/cli/launcher/status_darwin.go index 982efc4a..66ef1bee 100644 --- a/cli/launcher/status_darwin.go +++ b/cli/launcher/status_darwin.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "image/color" "strings" "sync" "time" @@ -54,6 +55,10 @@ type snapshot struct { // Managed is false when the launchd agent belongs to install-server.sh // rather than to this app; Start/Stop are then disabled. Managed bool + + // LocalOnly reflects CIX_BIND_ADDR: true when the server is bound to + // loopback and therefore unreachable from other machines. + LocalOnly bool } // providerLabel turns the wire provider kind into something a person can act @@ -81,16 +86,71 @@ func providerLabel(kind string) string { } } +// maxRowRunes caps every menu row. +// +// An NSMenu is exactly as wide as its widest row, so an untruncated model id +// (`ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF`, 41 characters) stretched the +// whole menu to fit one line nobody needs to read in full. Capping every row at +// the same width makes the menu a predictable size instead of a function of +// whichever model happens to be configured; the full value goes in the tooltip. +const maxRowRunes = 34 + +// ellipsize shortens s to at most maxRunes, cutting from the middle. +// +// Middle rather than tail because these values are qualified names — +// "awhiteside/CodeRankEmbed-Q8_0-GGUF" — where both ends carry information and +// the middle is the least missed. Tail truncation would leave every Hugging +// Face model rendered as its owner. +func ellipsize(s string, maxRunes int) string { + r := []rune(s) + if len(r) <= maxRunes || maxRunes < 3 { + return s + } + keep := maxRunes - 1 // one rune for the ellipsis + head := (keep + 1) / 2 + tail := keep - head + return string(r[:head]) + "…" + string(r[len(r)-tail:]) +} + +// row renders "label + value", ellipsizing the value so the whole row fits. +func row(label, value string) string { + return label + ellipsize(value, maxRowRunes-len([]rune(label))) +} + // EmbeddingsLine renders the provider row of the menu. +// +// Readiness is carried by the row's dot, not by a "— ready" suffix: the words +// cost menu width that the model name needs, and a coloured indicator is how +// macOS status apps say this. func (s snapshot) EmbeddingsLine() string { if s.State != stateRunning || s.Status == nil { return "Embeddings: unknown" } - label := providerLabel(s.Status.EmbeddingProvider) - if s.EmbeddingsOK { - return "Embeddings: " + label + " — ready" + return row("Embeddings: ", providerLabel(s.Status.EmbeddingProvider)) +} + +// EmbeddingsDot is the indicator colour for the provider row. +func (s snapshot) EmbeddingsDot() color.NRGBA { + switch { + case s.State != stateRunning || s.Status == nil: + return dotGrey + case s.EmbeddingsOK: + return dotGreen + default: + return dotRed + } +} + +// ServerDot is the indicator colour for the server row. +func (s snapshot) ServerDot() color.NRGBA { + switch s.State { + case stateRunning: + return dotGreen + case stateStarting: + return dotAmber + default: + return dotRed } - return "Embeddings: " + label + " — not ready" } // ServerLine renders the top row of the menu. @@ -102,12 +162,69 @@ func (s snapshot) ServerLine() string { return "cix-server: Starting…" default: if !s.Managed { - return "cix-server: Stopped (managed externally)" + // "(managed externally)" spelled out is 40 characters and would set + // the width of the whole menu on its own. The tooltip explains. + return "cix-server: Stopped (external)" } return "cix-server: Stopped" } } +// Detail rows — the information the truncated rows cannot carry, shown in a +// submenu on the server row rather than in tooltips. +// +// Native menu-item tooltips were tried and removed. Two AppKit behaviours make +// them unusable here and neither has an API: once any tooltip in the app has +// appeared, every subsequent one shows with no delay at all; and they are +// positioned against the menu item rather than the pointer. Fixing either means +// giving every row a custom NSView with its own tracking area — that is, +// writing the menu in Objective-C instead of using systray. A submenu gets +// native timing and placement for free. + +// DetailProcess reports the running process, or that there is none. +func (s snapshot) DetailProcess() string { + if s.PID == 0 { + return "Process: not running" + } + return fmt.Sprintf("Process: %d", s.PID) +} + +func (s snapshot) DetailPort() string { + return fmt.Sprintf("Port: %d", s.Port) +} + +// DetailNetwork states the exposure in plain terms. "127.0.0.1" is precise and +// means nothing to most people; "this Mac only" is the fact they care about. +func (s snapshot) DetailNetwork() string { + if s.LocalOnly { + return "Network: this Mac only" + } + return "Network: reachable from your network" +} + +// DetailModel is the full, untruncated model id — the value the row had to cut. +func (s snapshot) DetailModel() string { + if name := s.ModelName(); name != "" { + return "Model: " + name + } + return "" +} + +func (s snapshot) DetailVersion() string { + if s.Status == nil || s.Status.ServerVersion == "" { + return "" + } + return "Server " + s.Status.ServerVersion +} + +// DetailManaged explains a disabled Start/Stop, which is otherwise inexplicable. +func (s snapshot) DetailManaged() string { + if s.Managed { + return "" + } + return "Managed by install-server.sh" +} + // ModelLine renders the model row, or an empty string when there is nothing to // say — the menu hides the item rather than showing "Model: unknown". func (s snapshot) ModelLine() string { @@ -118,11 +235,23 @@ func (s snapshot) ModelLine() string { // the provider kind ("ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF"). The row // above already names the provider, and the prefix is the same misleading // name providerLabel exists to avoid — so show the model alone. + return row("Model: ", s.ModelName()) +} + +// ModelName is the untruncated model, for the tooltip. +func (s snapshot) ModelName() string { + if s.Status == nil { + return "" + } + // The server reports the provider's fingerprint ID, which is prefixed with + // the provider kind ("ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF"). The row + // above already names the provider, and the prefix is the same misleading + // name providerLabel exists to avoid — so show the model alone. model := s.Status.EmbeddingModel if _, rest, ok := strings.Cut(model, ":"); ok && rest != "" { model = rest } - return "Model: " + model + return model } type poller struct { @@ -229,6 +358,7 @@ func (p *poller) pollHealth() { p.snap.PID = pid p.snap.Port = port p.snap.Managed = managed + p.snap.LocalOnly = isLocalOnly(vars) if state != stateRunning { // Provider information from a server that is no longer answering is // stale by definition. diff --git a/cli/launcher/status_darwin_test.go b/cli/launcher/status_darwin_test.go index 0b902862..94fe8fae 100644 --- a/cli/launcher/status_darwin_test.go +++ b/cli/launcher/status_darwin_test.go @@ -1,7 +1,9 @@ package main import ( + "strings" "testing" + "unicode/utf8" "github.com/dvcdsys/code-index/cli/internal/client" ) @@ -38,14 +40,20 @@ func TestSnapshotLines(t *testing.T) { if got, want := running.ServerLine(), "cix-server: Running (:21847)"; got != want { t.Errorf("ServerLine() = %q, want %q", got, want) } - if got, want := running.EmbeddingsLine(), "Embeddings: llama.cpp (bundled) — ready"; got != want { + // Readiness is on the dot, not in the text: the words "— ready" cost menu + // width, and an NSMenu is as wide as its widest row. + if got, want := running.EmbeddingsLine(), "Embeddings: llama.cpp (bundled)"; got != want { t.Errorf("EmbeddingsLine() = %q, want %q", got, want) } - // The provider prefix is stripped: the row above already names the - // provider, and repeating the misleading kind defeats providerLabel. - if got, want := running.ModelLine(), "Model: awhiteside/CodeRankEmbed-Q8_0-GGUF"; got != want { + // The provider prefix is stripped — the row above already names the + // provider, and repeating the misleading kind defeats providerLabel — and + // the remainder is middle-truncated to the width cap. + if got, want := running.ModelLine(), "Model: awhiteside/Co…bed-Q8_0-GGUF"; got != want { t.Errorf("ModelLine() = %q, want %q", got, want) } + if got, want := running.ModelName(), "awhiteside/CodeRankEmbed-Q8_0-GGUF"; got != want { + t.Errorf("ModelName() = %q, want %q (the tooltip keeps the full id)", got, want) + } } func TestSnapshotLines_NotRunning(t *testing.T) { @@ -66,11 +74,66 @@ func TestSnapshotLines_NotRunning(t *testing.T) { } // An agent installed by install-server.sh owns the same launchd label. The - // app observes it but must not offer to drive it. + // app observes it but must not offer to drive it. Abbreviated in the row + // because spelled out it is 40 characters and would set the menu's width on + // its own; the tooltip carries the explanation. external := snapshot{State: stateStopped, Managed: false} - if got, want := external.ServerLine(), "cix-server: Stopped (managed externally)"; got != want { + if got, want := external.ServerLine(), "cix-server: Stopped (external)"; got != want { t.Errorf("ServerLine() = %q, want %q", got, want) } + // A disabled Start/Stop is otherwise inexplicable, so the reason is in the + // details submenu. + if !strings.Contains(external.DetailManaged(), "install-server.sh") { + t.Errorf("DetailManaged() should name the external installer, got %q", external.DetailManaged()) + } +} + +func TestDetailRows(t *testing.T) { + // The details submenu replaced menu-item tooltips: once any AppKit tooltip + // has shown, every later one appears with no delay, and they are positioned + // against the item rather than the pointer. Neither has an API. + s := snapshot{ + State: stateRunning, PID: 4242, Port: 21847, Managed: true, LocalOnly: true, + Status: &client.StatusResponse{ + ServerVersion: "0.12.4", + EmbeddingModel: "ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF", + }, + } + if got, want := s.DetailProcess(), "Process: 4242"; got != want { + t.Errorf("DetailProcess() = %q, want %q", got, want) + } + if got, want := s.DetailPort(), "Port: 21847"; got != want { + t.Errorf("DetailPort() = %q, want %q", got, want) + } + // "127.0.0.1" is precise and means nothing to most people; the exposure is + // what they need to know. + if got, want := s.DetailNetwork(), "Network: this Mac only"; got != want { + t.Errorf("DetailNetwork() = %q, want %q", got, want) + } + // The full id, which the truncated row could not carry — the whole reason + // this submenu exists. + if got, want := s.DetailModel(), "Model: awhiteside/CodeRankEmbed-Q8_0-GGUF"; got != want { + t.Errorf("DetailModel() = %q, want %q", got, want) + } + if got, want := s.DetailVersion(), "Server 0.12.4"; got != want { + t.Errorf("DetailVersion() = %q, want %q", got, want) + } + if got := s.DetailManaged(); got != "" { + t.Errorf("DetailManaged() = %q, want empty for an app-managed agent", got) + } + + // Rows with nothing to say are hidden, never blank. + stopped := snapshot{State: stateStopped, Port: 21847, Managed: true} + if got, want := stopped.DetailProcess(), "Process: not running"; got != want { + t.Errorf("DetailProcess() = %q, want %q", got, want) + } + if stopped.DetailModel() != "" || stopped.DetailVersion() != "" { + t.Error("model and version rows should be empty when the server is not answering") + } + exposed := snapshot{State: stateRunning, Port: 21847, Managed: true, LocalOnly: false} + if got, want := exposed.DetailNetwork(), "Network: reachable from your network"; got != want { + t.Errorf("DetailNetwork() = %q, want %q", got, want) + } } func TestPollerDebouncesNotReady(t *testing.T) { @@ -128,3 +191,104 @@ func TestPollerTrustsHTTPProviders(t *testing.T) { t.Error("EmbeddingsOK = false for an HTTP provider; want true regardless of model_loaded") } } + +func TestEllipsize(t *testing.T) { + tests := []struct { + in string + max int + want string + }{ + {"short", 10, "short"}, + {"exactly-10", 10, "exactly-10"}, + // Middle, not tail: both ends of a qualified name carry information, + // and tail truncation renders every Hugging Face model as its owner. + {"awhiteside/CodeRankEmbed-Q8_0-GGUF", 27, "awhiteside/Co…d-Q8_0-GGUF"}, + {"abcdefghij", 5, "abc…j"}, + // Multi-byte input must be cut on rune boundaries, not bytes. + {"привіт-світе-довгий-рядок", 10, "привіт…ядок"}, + } + for _, tc := range tests { + got := ellipsize(tc.in, tc.max) + if len([]rune(got)) > tc.max && len([]rune(tc.in)) > tc.max { + t.Errorf("ellipsize(%q, %d) = %q, %d runes — over the cap", tc.in, tc.max, got, len([]rune(got))) + } + if !utf8.ValidString(got) { + t.Errorf("ellipsize(%q, %d) produced invalid UTF-8: %q", tc.in, tc.max, got) + } + } +} + +func TestRowsFitTheWidthCap(t *testing.T) { + // An NSMenu is exactly as wide as its widest row, so a long model id used + // to stretch the whole menu. Every row must stay inside the cap. + s := snapshot{ + State: stateRunning, + Port: 21847, + Managed: true, + Status: &client.StatusResponse{ + EmbeddingProvider: "ollama", + EmbeddingModel: "ollama:some-extremely-long-organisation/an-even-longer-model-name-v2", + }, + EmbeddingsOK: true, + } + for name, line := range map[string]string{ + "ServerLine": s.ServerLine(), + "EmbeddingsLine": s.EmbeddingsLine(), + "ModelLine": s.ModelLine(), + } { + if n := len([]rune(line)); n > maxRowRunes { + t.Errorf("%s = %q is %d runes, over the %d cap", name, line, n, maxRowRunes) + } + } + // The untruncated value is still available for the tooltip. + if got, want := s.ModelName(), "some-extremely-long-organisation/an-even-longer-model-name-v2"; got != want { + t.Errorf("ModelName() = %q, want %q", got, want) + } +} + +func TestDots(t *testing.T) { + running := snapshot{State: stateRunning, Status: &client.StatusResponse{}, EmbeddingsOK: true} + if running.ServerDot() != dotGreen || running.EmbeddingsDot() != dotGreen { + t.Error("a running server with a ready provider should be green on both rows") + } + // Starting is amber, not red: a cold start loading a model is working, and + // red is what makes people kill it. + if (snapshot{State: stateStarting}).ServerDot() != dotAmber { + t.Error("starting should be amber") + } + if (snapshot{State: stateStopped}).ServerDot() != dotRed { + t.Error("stopped should be red") + } + // Nothing is known about the provider when the server is down; grey says + // "unknown", red would claim it is broken. + if (snapshot{State: stateStopped}).EmbeddingsDot() != dotGrey { + t.Error("provider state should be grey when the server is not answering") + } + if len(dotPNG(dotGreen)) == 0 { + t.Error("dotPNG returned no bytes") + } +} + +func TestIsLocalOnly(t *testing.T) { + // An ABSENT CIX_BIND_ADDR is the server's all-interfaces default, not + // loopback. Reading it the other way would show a reassuring "local only" + // on exactly the installs that are exposed. + tests := []struct { + vars map[string]string + want bool + }{ + {map[string]string{}, false}, + {map[string]string{"CIX_BIND_ADDR": ""}, false}, + {map[string]string{"CIX_BIND_ADDR": "0.0.0.0"}, false}, + {map[string]string{"CIX_BIND_ADDR": "192.168.1.5"}, false}, + {map[string]string{"CIX_BIND_ADDR": "127.0.0.1"}, true}, + {map[string]string{"CIX_BIND_ADDR": " 127.0.0.1 "}, true}, + {map[string]string{"CIX_BIND_ADDR": "::1"}, true}, + {map[string]string{"CIX_BIND_ADDR": "localhost"}, true}, + } + for _, tc := range tests { + if got := isLocalOnly(tc.vars); got != tc.want { + t.Errorf("isLocalOnly(%v) = %v, want %v", tc.vars, got, tc.want) + } + } +} diff --git a/doc/CONFIG_REFERENCE.md b/doc/CONFIG_REFERENCE.md index 3494b00c..4a4eae58 100644 --- a/doc/CONFIG_REFERENCE.md +++ b/doc/CONFIG_REFERENCE.md @@ -31,6 +31,7 @@ the DB. | Variable | Default | Description | |---|---|---| | `CIX_PORT` | `21847` | Listen port (both Docker images bake this in). | +| `CIX_BIND_ADDR` | — | Interface to listen on, as a bare address with no port. Empty means every interface, which is what a container needs; set `127.0.0.1` to make the server reachable only from the machine it runs on. The macOS app writes `127.0.0.1` at first run and exposes a menu toggle. | | `CIX_SQLITE_PATH` | `/data/sqlite/projects.db` | SQLite path. Suffixed with the model-safe name on open. | | `CIX_CHROMA_PERSIST_DIR` | `/data/chroma` | Vector store directory. | | `CIX_GGUF_CACHE_DIR` | `/data/models` | Where downloaded GGUF files live. | diff --git a/doc/MACOS_APP.md b/doc/MACOS_APP.md index d8059dcb..ebf08819 100644 --- a/doc/MACOS_APP.md +++ b/doc/MACOS_APP.md @@ -130,20 +130,47 @@ macOS announces any newly registered background agent. ## The menu ``` -cix-server: Running (:21847) -Embeddings: llama.cpp (bundled) — ready -Model: awhiteside/CodeRankEmbed-Q8_0-GGUF +● cix-server: Running (:21847) +● Embeddings: llama.cpp (bundled) + Model: awhiteside/Co…bed-Q8_0-GGUF ───────────── Stop Server Open Dashboard ───────────── +Allow Network Access ✓ +───────────── Quit cix ``` +The dot carries the state: green running, amber starting, red stopped, grey +unknown. Rows are truncated so the menu stays a predictable width instead of +being as wide as whichever model happens to be configured — hover a row for the +full value and for details that do not fit, such as the pid and whether the +server is reachable from your network. + **Starting…** is its own state, distinct from Running and Stopped. A cold start loads the embedding model and can take anywhere from 30 seconds to several minutes, in silence — a server showing "Starting…" is working, not stuck. +### Allow Network Access + +Off, the server binds to `127.0.0.1` and only this Mac can reach it. On, it +binds to every interface and any machine that can reach this Mac on its port can +too — useful for querying your index from a laptop or a phone on the same +network, and not something to leave on by accident. Turning it **on** asks for +confirmation; turning it off does not. + +Accounts and API keys apply either way; this does not disable authentication. It +decides whether the login page and the API are reachable at all. + +The setting is `CIX_BIND_ADDR` in `server.env`, and it is read once at process +start, so changing it restarts the server. + +> The server's own default (and every container's) is to bind all interfaces. +> The app deliberately differs: a fresh desktop install starts loopback-only. +> An install that predates this setting keeps whatever it had — the app does not +> silently narrow a server you already rely on. + **Quit cix** quits the menu bar app only. The server is a launchd agent and keeps running; use **Stop Server** first if you want it down. diff --git a/server/cmd/cix-server/main.go b/server/cmd/cix-server/main.go index c015a75c..9f6dbae9 100644 --- a/server/cmd/cix-server/main.go +++ b/server/cmd/cix-server/main.go @@ -618,7 +618,7 @@ func run() error { }) srv := &http.Server{ - Addr: fmt.Sprintf(":%d", cfg.Port), + Addr: cfg.ListenAddr(), Handler: handler, ReadTimeout: 30 * time.Second, WriteTimeout: 60 * time.Second, diff --git a/server/internal/config/config.go b/server/internal/config/config.go index b496b951..7e08c152 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -5,6 +5,7 @@ package config import ( "fmt" + "net" "os" "path/filepath" "runtime" @@ -25,8 +26,16 @@ type Config struct { // CIX_AUTH_DISABLED=true (and also requires CIX_API_KEY to be empty). // Replaces the older "empty API key = no auth" implicit bypass; the new // behaviour fails loud when CIX_API_KEY is missing without the flag. - AuthDisabled bool - Port int + AuthDisabled bool + Port int + // BindAddr is the interface the HTTP server listens on. Empty (the + // default) means every interface, which is what a container needs and what + // every existing deployment already gets — so the default must not change. + // + // Set it to 127.0.0.1 to make the server reachable only from the machine it + // runs on. That is the useful setting for a desktop install, where "the + // whole LAN can reach my code index" is rarely what was intended. + BindAddr string EmbeddingModel string ChromaPersistDir string SQLitePath string @@ -254,6 +263,7 @@ func Load() (*Config, error) { return nil, err } c.Port = port + c.BindAddr = strings.TrimSpace(getenv("CIX_BIND_ADDR", "")) maxFileSize, err := getenvInt("CIX_MAX_FILE_SIZE", 524288) if err != nil { @@ -490,9 +500,57 @@ func (c *Config) Validate() error { if c.LlamaStartupSec <= 0 { return fmt.Errorf("CIX_LLAMA_STARTUP_TIMEOUT=%d, must be positive", c.LlamaStartupSec) } + if err := validateBindAddr(c.BindAddr); err != nil { + return err + } + return nil +} + +// validateBindAddr rejects the two shapes people actually get wrong: a URL, and +// a host:port pair. Both bind successfully as a *hostname* on some systems and +// then fail to resolve, producing a server that is silently unreachable rather +// than one that refused to start. +// +// Hostnames are otherwise allowed through — "localhost" is a legitimate and +// probably the most common value. +func validateBindAddr(addr string) error { + if addr == "" { + return nil + } + if strings.Contains(addr, "://") { + return fmt.Errorf("CIX_BIND_ADDR=%q is a URL; use a bare address such as 127.0.0.1 or 0.0.0.0", addr) + } + // A colon is legal in an IPv6 literal and nowhere else here. + if strings.Contains(addr, ":") && net.ParseIP(addr) == nil { + return fmt.Errorf("CIX_BIND_ADDR=%q includes a port or is not a valid IPv6 address; set the port with CIX_PORT", addr) + } return nil } +// ListenAddr is the address the HTTP server binds to. +// +// An empty BindAddr yields ":port" — every interface. That is the historical +// behaviour and what a container requires, so it stays the default; narrowing +// it is opt-in. +func (c *Config) ListenAddr() string { + if c.BindAddr == "" { + return ":" + strconv.Itoa(c.Port) + } + return net.JoinHostPort(c.BindAddr, strconv.Itoa(c.Port)) +} + +// LocalOnly reports whether the configured bind address is loopback — i.e. the +// server is reachable only from this machine. +func (c *Config) LocalOnly() bool { + if c.BindAddr == "" { + return false + } + if ip := net.ParseIP(c.BindAddr); ip != nil { + return ip.IsLoopback() + } + return strings.EqualFold(c.BindAddr, "localhost") +} + // defaultDataDir returns the platform-specific root for runtime data // (SQLite + chromem-go). Used to build defaults for CIX_SQLITE_PATH and // CIX_CHROMA_PERSIST_DIR when neither env var is set. diff --git a/server/internal/config/config_test.go b/server/internal/config/config_test.go index 58415a84..1b28f10c 100644 --- a/server/internal/config/config_test.go +++ b/server/internal/config/config_test.go @@ -284,3 +284,67 @@ func unsetAll(t *testing.T) { osUnsetenv(k) } } + +func TestListenAddr(t *testing.T) { + tests := []struct { + name string + bind string + port int + want string + }{ + // The empty default must keep producing ":port". Every existing + // deployment — every container in particular — depends on binding all + // interfaces, so narrowing this is opt-in and never implicit. + {"default is all interfaces", "", 21847, ":21847"}, + {"loopback", "127.0.0.1", 21847, "127.0.0.1:21847"}, + {"explicit all interfaces", "0.0.0.0", 8080, "0.0.0.0:8080"}, + {"hostname", "localhost", 21847, "localhost:21847"}, + // JoinHostPort brackets IPv6; a naive host+":"+port would produce + // "::1:21847", which net.Listen reads as a different address entirely. + {"ipv6 loopback is bracketed", "::1", 21847, "[::1]:21847"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := &Config{BindAddr: tc.bind, Port: tc.port} + if got := c.ListenAddr(); got != tc.want { + t.Errorf("ListenAddr() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestLocalOnly(t *testing.T) { + tests := map[string]bool{ + "": false, // all interfaces + "0.0.0.0": false, + "192.168.1.5": false, + "127.0.0.1": true, + "127.0.0.2": true, // the whole 127/8 block is loopback + "::1": true, + "localhost": true, + "LOCALHOST": true, + } + for bind, want := range tests { + if got := (&Config{BindAddr: bind}).LocalOnly(); got != want { + t.Errorf("LocalOnly() for %q = %v, want %v", bind, got, want) + } + } +} + +func TestValidateBindAddr(t *testing.T) { + valid := []string{"", "127.0.0.1", "0.0.0.0", "localhost", "::1", "192.168.1.5"} + for _, addr := range valid { + if err := validateBindAddr(addr); err != nil { + t.Errorf("validateBindAddr(%q) = %v, want nil", addr, err) + } + } + // The two mistakes worth catching: both bind as a hostname on some systems + // and then fail to resolve, giving a server that is silently unreachable + // instead of one that refused to start. + invalid := []string{"http://127.0.0.1", "https://cix.local", "127.0.0.1:21847", "localhost:8080"} + for _, addr := range invalid { + if err := validateBindAddr(addr); err == nil { + t.Errorf("validateBindAddr(%q) = nil, want an error", addr) + } + } +} From dfcc9903f174915ddd5902f7a9fed783255a8c84 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 13:38:11 +0100 Subject: [PATCH 07/12] fix(mac): remove every tooltip from the menu bar app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the last ones — the status-item tooltip and the two still passed as the second argument to AddMenuItem. The app now shows none at all. AppKit's tooltips have two behaviours here that no API can change: once any one of them has appeared, every subsequent one shows with no delay whatsoever, and they are positioned against the element rather than the pointer. Changing either means giving every row a custom NSView with its own tracking area — writing the menu in Objective-C instead of using systray, which is not a trade worth making for hover text. Everything they carried already moved to the details submenu, where the timing and placement are the system's own. One tooltip was load-bearing and could not simply be deleted: Quit closes the menu bar app and leaves the launchd agent running, the opposite of what Quit means in most menu bar apps. That now reads "Quit (server keeps running)" — a surprise is worth 22 characters of title. Co-Authored-By: Claude Opus 5 --- cli/launcher/menu_darwin.go | 18 +++++++++++++++--- cli/launcher/status_darwin.go | 5 +++-- cli/launcher/status_darwin_test.go | 6 +++--- doc/MACOS_APP.md | 24 +++++++++++++++--------- 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/cli/launcher/menu_darwin.go b/cli/launcher/menu_darwin.go index 94f588bc..1a363edc 100644 --- a/cli/launcher/menu_darwin.go +++ b/cli/launcher/menu_darwin.go @@ -59,7 +59,16 @@ func (m *menu) onReady() { } else { systray.SetTitle("cix") } - systray.SetTooltip("cix — semantic code search") + // No tooltips anywhere, including on the status item itself. AppKit's are + // not usable here: once any one of them has appeared, every subsequent one + // shows with no delay at all, and they are positioned against the element + // rather than the pointer. Neither has an API — changing either means + // giving every row a custom NSView with its own tracking area, i.e. writing + // the menu in Objective-C instead of using systray. Everything a tooltip + // would have said is in the details submenu, where the timing and placement + // are the system's own and correct. + // + // The empty second argument to AddMenuItem is that tooltip. Leave it empty. // The server row is the one enabled row in the status group, because it // carries the details submenu — a parent has to be enabled for macOS to @@ -79,13 +88,16 @@ func (m *menu) onReady() { systray.AddSeparator() m.startStopItem = systray.AddMenuItem("Start Server", "") - m.dashboardItem = systray.AddMenuItem("Open Dashboard", "Open the cix dashboard in your browser") + m.dashboardItem = systray.AddMenuItem("Open Dashboard", "") systray.AddSeparator() m.networkItem = systray.AddMenuItemCheckbox("Allow Network Access", "", false) systray.AddSeparator() - quitItem := systray.AddMenuItem("Quit cix", "Quit the menu bar app; the server keeps running") + // Spelled out rather than left to a tooltip. Quit here closes the menu bar + // app and leaves the launchd agent running, which is the opposite of what + // Quit means in most menu bar apps — a surprise worth 22 characters. + quitItem := systray.AddMenuItem("Quit (server keeps running)", "") go m.poll.run(m.stop) go m.watch() diff --git a/cli/launcher/status_darwin.go b/cli/launcher/status_darwin.go index 66ef1bee..20cbe3e9 100644 --- a/cli/launcher/status_darwin.go +++ b/cli/launcher/status_darwin.go @@ -92,7 +92,8 @@ func providerLabel(kind string) string { // (`ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF`, 41 characters) stretched the // whole menu to fit one line nobody needs to read in full. Capping every row at // the same width makes the menu a predictable size instead of a function of -// whichever model happens to be configured; the full value goes in the tooltip. +// whichever model happens to be configured; the full value goes in the details +// submenu. const maxRowRunes = 34 // ellipsize shortens s to at most maxRunes, cutting from the middle. @@ -163,7 +164,7 @@ func (s snapshot) ServerLine() string { default: if !s.Managed { // "(managed externally)" spelled out is 40 characters and would set - // the width of the whole menu on its own. The tooltip explains. + // the width of the whole menu on its own. The details submenu explains. return "cix-server: Stopped (external)" } return "cix-server: Stopped" diff --git a/cli/launcher/status_darwin_test.go b/cli/launcher/status_darwin_test.go index 94fe8fae..de89fca4 100644 --- a/cli/launcher/status_darwin_test.go +++ b/cli/launcher/status_darwin_test.go @@ -52,7 +52,7 @@ func TestSnapshotLines(t *testing.T) { t.Errorf("ModelLine() = %q, want %q", got, want) } if got, want := running.ModelName(), "awhiteside/CodeRankEmbed-Q8_0-GGUF"; got != want { - t.Errorf("ModelName() = %q, want %q (the tooltip keeps the full id)", got, want) + t.Errorf("ModelName() = %q, want %q (the details submenu keeps the full id)", got, want) } } @@ -76,7 +76,7 @@ func TestSnapshotLines_NotRunning(t *testing.T) { // An agent installed by install-server.sh owns the same launchd label. The // app observes it but must not offer to drive it. Abbreviated in the row // because spelled out it is 40 characters and would set the menu's width on - // its own; the tooltip carries the explanation. + // its own; the details submenu carries the explanation. external := snapshot{State: stateStopped, Managed: false} if got, want := external.ServerLine(), "cix-server: Stopped (external)"; got != want { t.Errorf("ServerLine() = %q, want %q", got, want) @@ -240,7 +240,7 @@ func TestRowsFitTheWidthCap(t *testing.T) { t.Errorf("%s = %q is %d runes, over the %d cap", name, line, n, maxRowRunes) } } - // The untruncated value is still available for the tooltip. + // The untruncated value is still available for the details submenu. if got, want := s.ModelName(), "some-extremely-long-organisation/an-even-longer-model-name-v2"; got != want { t.Errorf("ModelName() = %q, want %q", got, want) } diff --git a/doc/MACOS_APP.md b/doc/MACOS_APP.md index ebf08819..6dd56bd8 100644 --- a/doc/MACOS_APP.md +++ b/doc/MACOS_APP.md @@ -130,23 +130,29 @@ macOS announces any newly registered background agent. ## The menu ``` -● cix-server: Running (:21847) -● Embeddings: llama.cpp (bundled) - Model: awhiteside/Co…bed-Q8_0-GGUF -───────────── -Stop Server +● cix-server: Running (:21847) ▸ Process: 78903 +● Embeddings: llama.cpp (bundled) Port: 21847 + Model: awhiteside/Co…bed-Q8_0-GGUF Network: this Mac only +───────────── Model: awhiteside/CodeRankEmbed-Q8_0-GGUF +Stop Server Server 0.12.4 Open Dashboard ───────────── Allow Network Access ✓ ───────────── -Quit cix +Quit (server keeps running) ``` The dot carries the state: green running, amber starting, red stopped, grey unknown. Rows are truncated so the menu stays a predictable width instead of -being as wide as whichever model happens to be configured — hover a row for the -full value and for details that do not fit, such as the pid and whether the -server is reachable from your network. +being as wide as whichever model happens to be configured; the submenu on the +server row holds the full values and the things that do not fit — the process +id, the port, the network exposure and the untruncated model name. + +There are no tooltips anywhere in this app, deliberately. AppKit's have two +behaviours that cannot be changed through any API: once one of them has +appeared, every subsequent one shows with no delay at all, and they are placed +against the element rather than the pointer. A submenu gets native timing and +placement for free. **Starting…** is its own state, distinct from Running and Stopped. A cold start loads the embedding model and can take anywhere from 30 seconds to several From 93abf3320b000ba7565219fdd546f3ded7e6971a Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 15:35:33 +0100 Subject: [PATCH 08/12] feat(mac): autostart toggle, password reset, and install-server.sh takeover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the menu bar app. Start at Login writes RunAtLoad and reloads the agent. The reload is not avoidable: launchd reads a plist exactly once, when the job is bootstrapped, so rewriting it with the job loaded changes nothing until the next login — which is the setting being changed, so the user would find out months later. Reloading stops a running server, so the toggle puts it back rather than pretending the cost is zero. The checkbox reads RunAtLoad back out of the file instead of remembering it, so it cannot drift from what launchd will actually do. Reset Password drives `cix-server -reset-password`, which opens the database directly and does not need the server stopped. Its stdin is /dev/null, the branch that makes it generate a password rather than read one, and the "Temporary password: " line is matched by prefix — the surrounding lines are conditional. That command answers an unknown address by listing every account in the database. Reasonable for an operator at a terminal who already holds the DB file; an account enumeration anyone behind the user can read once it is in a GUI alert. The dialog gets one sentence, the full output goes to ~/.cix/logs/launcher.log at mode 0600 — which is also why the launcher now has a log at all, being an LSUIElement app with no terminal to write to. Takeover. install-server.sh owns the same launchd label and points it at a repo checkout, so on a machine already running cix from a clone this app finds an agent it did not create, holding the port it wants. It now asks once, shows the paths it found so the installation is recognisable, and remembers the answer. Leaving it alone is observe-only, as before. Taking it over migrates the port, API key and database paths out of the .env the old wrapper sourced — and only those four, since the rest of that file is the other installation's business — backs up the plist and wrapper first, and refuses the whole operation if the backup fails, that being the one step that overwrites something unrecoverable. The first-run wizard is now behind that check, not beside it: with a foreign agent present it would have set up a second server that cannot bind the port, against a second, empty database. Fixes a real bug found while testing the takeover prompt. Dialog dismissal was detected by matching osascript's "User canceled" — but the message is localised, and this project's own development Mac runs a British locale where it says "cancelled" with two Ls. So no cancel anywhere in the app was recognised; the network toggle only appeared to work because both its branches do the same thing. Now matched on OSStatus -128, which is a number in every language. Verified end to end against a simulated install-server.sh agent: the prompt named both its .env and its binary, Leave It Alone recorded the choice and disabled Start/Stop while leaving reset password working, a relaunch did not ask again, and Take Over migrated the API key back into a server.env it had been removed from, backed up both files, and left the config byte-identical to the snapshot taken before the simulation. Co-Authored-By: Claude Opus 5 --- cli/launcher/dialog_darwin.go | 34 ++++- cli/launcher/env_darwin.go | 7 + cli/launcher/launchd_darwin.go | 17 +++ cli/launcher/logging_darwin.go | 60 ++++++++ cli/launcher/main_darwin.go | 26 +++- cli/launcher/menu_darwin.go | 51 ++++++- cli/launcher/prefs_darwin.go | 73 +++++++++ cli/launcher/resetpw_darwin.go | 134 +++++++++++++++++ cli/launcher/status_darwin.go | 6 + cli/launcher/status_darwin_test.go | 118 +++++++++++++++ cli/launcher/takeover_darwin.go | 230 +++++++++++++++++++++++++++++ doc/MACOS_APP.md | 66 +++++++-- 12 files changed, 800 insertions(+), 22 deletions(-) create mode 100644 cli/launcher/logging_darwin.go create mode 100644 cli/launcher/prefs_darwin.go create mode 100644 cli/launcher/resetpw_darwin.go create mode 100644 cli/launcher/takeover_darwin.go diff --git a/cli/launcher/dialog_darwin.go b/cli/launcher/dialog_darwin.go index f07fc100..0509d685 100644 --- a/cli/launcher/dialog_darwin.go +++ b/cli/launcher/dialog_darwin.go @@ -66,10 +66,21 @@ func alert(title, message string) error { } // errCancelled is returned when the user dismissed a dialog instead of -// answering it. osascript reports this as exit status 1 with "User canceled" on -// stderr, which is indistinguishable from a real failure unless matched. +// answering it. osascript reports this as exit status 1 — the same status as a +// real failure — so it has to be told apart from the error text. var errCancelled = errors.New("cancelled by user") +// isUserCancelled recognises AppleScript's user-cancelled error. +// +// Match the OSStatus, not the sentence. The message is localised: on this +// project's own development Mac, running a British locale, osascript says "User +// cancelled" with two Ls, while the American spelling has one — so a string +// match on either is wrong somewhere, and was. -128 (userCanceledErr) is a +// number and says the same thing in every language. +func isUserCancelled(stderr string) bool { + return strings.Contains(stderr, "(-128)") +} + // prompt asks for one line of text. Returns errCancelled if the user cancels. func prompt(title, message, defaultAnswer string) (string, error) { script := fmt.Sprintf( @@ -92,9 +103,22 @@ func prompt(title, message, defaultAnswer string) (string, error) { // confirm shows a two-button question. Returns false when the user declines. func confirm(title, message, okLabel string) (bool, error) { + return ask(title, message, okLabel, "Cancel") +} + +// ask shows a two-button question with both labels spelled out. +// +// Separate from confirm because not every choice has a "cancel" side. "Take +// Over" versus "Leave It Alone" are two real options, and labelling the second +// one Cancel would imply it does nothing — when in fact it decides how the app +// behaves from then on. +// +// yesLabel is the default button. Dismissing the dialog counts as no. +func ask(title, message, yesLabel, noLabel string) (bool, error) { script := fmt.Sprintf( - `display dialog %s with title %s %s buttons {"Cancel", %s} default button %s cancel button "Cancel"`, - quoteAS(message), quoteAS(title), iconClause(), quoteAS(okLabel), quoteAS(okLabel), + `display dialog %s with title %s %s buttons {%s, %s} default button %s cancel button %s`, + quoteAS(message), quoteAS(title), iconClause(), + quoteAS(noLabel), quoteAS(yesLabel), quoteAS(yesLabel), quoteAS(noLabel), ) if _, err := outputOsascript(5*time.Minute, script); err != nil { if errors.Is(err, errCancelled) { @@ -125,7 +149,7 @@ func outputOsascript(timeout time.Duration, script string) (string, error) { if ctx.Err() != nil { return "", fmt.Errorf("osascript timed out after %s", timeout) } - if strings.Contains(stderr.String(), "User canceled") { + if isUserCancelled(stderr.String()) { return "", errCancelled } return "", fmt.Errorf("osascript: %w: %s", err, strings.TrimSpace(stderr.String())) diff --git a/cli/launcher/env_darwin.go b/cli/launcher/env_darwin.go index eaaec014..84cf63e6 100644 --- a/cli/launcher/env_darwin.go +++ b/cli/launcher/env_darwin.go @@ -52,6 +52,13 @@ func readServerEnv() (map[string]string, error) { if err != nil { return nil, err } + return readEnvFile(path) +} + +// readEnvFile parses any KEY=value file in the same dialect. Used for our own +// server.env and, during a takeover, for the .env an install-server.sh wrapper +// sources — which is written by a different tool and may quote differently. +func readEnvFile(path string) (map[string]string, error) { f, err := os.Open(path) if err != nil { return nil, err diff --git a/cli/launcher/launchd_darwin.go b/cli/launcher/launchd_darwin.go index 640e0c21..1d2e72a6 100644 --- a/cli/launcher/launchd_darwin.go +++ b/cli/launcher/launchd_darwin.go @@ -276,6 +276,23 @@ func autostartEnabled() bool { return strings.HasPrefix(strings.TrimSpace(rest), "") } +// setAutostart flips RunAtLoad and reloads the agent. +// +// The reload is not optional and cannot be skipped by kickstarting instead: +// launchd reads a plist exactly once, when the job is bootstrapped into the +// domain. A rewritten plist with the job still loaded changes nothing at all +// until the next login — which is precisely the setting being changed, so the +// user would find out months later. +// +// Rebootstrapping restarts a running server. That is why the caller restores +// the process afterwards rather than this function pretending the cost is zero. +func setAutostart(b bundle, enabled bool) error { + if err := writeLaunchdFiles(b, enabled); err != nil { + return err + } + return launchdBootstrap() +} + // foreignAgent reports whether a launchd agent under our label exists but was // set up by something else — in practice install-server.sh, which uses the same // label (:44) but points it at a repo checkout (:351). diff --git a/cli/launcher/logging_darwin.go b/cli/launcher/logging_darwin.go new file mode 100644 index 00000000..578d94a7 --- /dev/null +++ b/cli/launcher/logging_darwin.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// A file log, next to the server's own logs. +// +// The launcher has no terminal — it is an LSUIElement app — so anything it +// writes to stderr is lost. That is fine for chatter and not fine for the +// detail behind a failure, which is exactly what someone needs when the menu +// says "could not start the server" and nothing else. +// +// It is also the sink for output that must NOT reach a dialog. `cix-server +// -reset-password` answers an unknown address by listing every account in the +// database (resetpassword.go: "existing users: …"). That is a reasonable thing +// to print for an operator who already holds the DB file; putting it in a GUI +// alert would turn a typo into an account enumeration anyone standing behind +// the user can read. + +var ( + logMu sync.Mutex + logFile *os.File +) + +func launcherLogPath() (string, error) { + dir, err := logDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "launcher.log"), nil +} + +// logf appends a timestamped line. Best effort: a launcher that cannot write +// its log still has a job to do, so failures here are silent by design. +func logf(format string, args ...any) { + logMu.Lock() + defer logMu.Unlock() + + if logFile == nil { + path, err := launcherLogPath() + if err != nil { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return + } + logFile = f + } + + fmt.Fprintf(logFile, "%s %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...)) +} diff --git a/cli/launcher/main_darwin.go b/cli/launcher/main_darwin.go index 6f738ad4..e4717f1a 100644 --- a/cli/launcher/main_darwin.go +++ b/cli/launcher/main_darwin.go @@ -58,7 +58,18 @@ func main() { stripQuarantine(b) - if needsFirstRun() { + // Order matters here. A machine that already runs cix from a checkout has a + // launchd agent under our label and a server holding our port, so the + // first-run wizard must never get a look at it — it would set up a second + // server that cannot bind, against a second, empty database. + switch { + case foreignAgent(): + // Asks once, remembers the answer, and defaults to leaving it alone. + // When the user declines, the app stays in observe-only mode: status + // and the dashboard work, Start/Stop do not. + handleForeignAgent(b) + + case needsFirstRun(): if err := runFirstRun(b); err != nil { if errors.Is(err, errCancelled) { // Setup is resumable: the app stays in the menu bar with Start @@ -67,15 +78,18 @@ func main() { "cix has not been set up yet, so the server cannot start.\n\n"+ "Quit and reopen cix when you want to finish setting it up.") } else { + logf("first-run setup failed: %v", err) _ = alert("Setup failed", fmt.Sprintf("cix could not complete first-time setup.\n\n%v", err)) } } - } else if !foreignAgent() { - // Re-point the launchd agent at this bundle. An app that was moved, or - // replaced by an update, would otherwise keep a job aimed at a path - // that no longer holds the binary. + + default: + // Re-point the launchd agent at this bundle, preserving the user's + // autostart choice. An app that was moved, or replaced by an update, + // would otherwise keep a job aimed at a path that no longer holds the + // binary. if err := writeLaunchdFiles(b, autostartEnabled()); err != nil { - fmt.Fprintf(os.Stderr, "cix-launcher: could not refresh launchd files: %v\n", err) + logf("could not refresh launchd files: %v", err) } } diff --git a/cli/launcher/menu_darwin.go b/cli/launcher/menu_darwin.go index 1a363edc..669590a8 100644 --- a/cli/launcher/menu_darwin.go +++ b/cli/launcher/menu_darwin.go @@ -32,7 +32,9 @@ type menu struct { modelItem *systray.MenuItem startStopItem *systray.MenuItem dashboardItem *systray.MenuItem + autostartItem *systray.MenuItem networkItem *systray.MenuItem + resetPWItem *systray.MenuItem // detail holds the submenu rows under the server row. They are created // once and retitled on every render: systray has no way to remove an item, @@ -91,7 +93,9 @@ func (m *menu) onReady() { m.dashboardItem = systray.AddMenuItem("Open Dashboard", "") systray.AddSeparator() + m.autostartItem = systray.AddMenuItemCheckbox("Start at Login", "", false) m.networkItem = systray.AddMenuItemCheckbox("Allow Network Access", "", false) + m.resetPWItem = systray.AddMenuItem("Reset Password…", "") systray.AddSeparator() // Spelled out rather than left to a tooltip. Quit here closes the menu bar @@ -109,8 +113,12 @@ func (m *menu) onReady() { go m.toggleServer() case <-m.dashboardItem.ClickedCh: go m.openDashboard() + case <-m.autostartItem.ClickedCh: + go m.toggleAutostart() case <-m.networkItem.ClickedCh: go m.toggleNetworkAccess() + case <-m.resetPWItem.ClickedCh: + go m.resetPasswordFlow() case <-quitItem.ClickedCh: systray.Quit() return @@ -183,12 +191,53 @@ func (m *menu) render(s snapshot) { } else { m.networkItem.Check() } + if s.Autostart { + m.autostartItem.Check() + } else { + m.autostartItem.Uncheck() + } + if s.Managed { m.networkItem.Enable() + m.autostartItem.Enable() + m.resetPWItem.Enable() } else { - // The setting lives in a config file this app does not own. + // These live in files this app does not own — except the password + // reset, which only needs the database and is therefore still useful + // against an install-server.sh deployment. m.networkItem.Disable() + m.autostartItem.Disable() + m.resetPWItem.Enable() + } +} + +// toggleAutostart flips RunAtLoad on the launchd agent. +// +// launchd reads a plist only when the job is bootstrapped, so the change needs +// a full unload/load cycle — which stops a running server. Rather than leave it +// down, or pretend the cost is zero, this restores whatever state the server +// was in. +func (m *menu) toggleAutostart() { + s := m.poll.snapshotNow() + if !s.Managed { + return } + enable := !s.Autostart + + if err := setAutostart(m.bundle, enable); err != nil { + _ = alert("cix", fmt.Sprintf("Could not change the start-at-login setting.\n\n%v", err)) + m.render(m.poll.snapshotNow()) + return + } + + // bootstrap does not start the job unless RunAtLoad fired, so a server that + // was running before the reload has to be put back. + if s.State == stateRunning { + if err := startServer(); err != nil { + _ = alert("cix", fmt.Sprintf("The setting was changed, but the server could not be restarted.\n\n%v", err)) + } + } + m.poll.refresh() } // renderDetail fills the submenu under the server row. Slots with nothing to diff --git a/cli/launcher/prefs_darwin.go b/cli/launcher/prefs_darwin.go new file mode 100644 index 00000000..40ad41dc --- /dev/null +++ b/cli/launcher/prefs_darwin.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// Launcher preferences: ~/.cix/launcher.json. +// +// Only decisions the user should not be asked twice about. Everything that +// configures the server lives in server.env, and everything about the launchd +// job lives in the plist; this file exists so the app can remember an answer, +// not so it can grow a second configuration system. + +type prefs struct { + // Takeover records what the user chose when an agent installed by + // install-server.sh was found under the same launchd label: "takeover" or + // "keep". Empty means the question has not been asked yet. + Takeover string `json:"takeover,omitempty"` +} + +const ( + takeoverAdopt = "takeover" + takeoverKeep = "keep" +) + +func prefsPath() (string, error) { + dir, err := cixHome() + if err != nil { + return "", err + } + return filepath.Join(dir, "launcher.json"), nil +} + +// loadPrefs returns the stored preferences, or zero values when the file is +// absent or unreadable. A corrupt prefs file must not stop the app starting: +// the worst case is being asked a question again. +func loadPrefs() prefs { + path, err := prefsPath() + if err != nil { + return prefs{} + } + data, err := os.ReadFile(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + logf("could not read %s: %v", path, err) + } + return prefs{} + } + var p prefs + if err := json.Unmarshal(data, &p); err != nil { + logf("could not parse %s: %v — treating as empty", path, err) + return prefs{} + } + return p +} + +func savePrefs(p prefs) error { + path, err := prefsPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o600) +} diff --git a/cli/launcher/resetpw_darwin.go b/cli/launcher/resetpw_darwin.go new file mode 100644 index 00000000..b28c1b90 --- /dev/null +++ b/cli/launcher/resetpw_darwin.go @@ -0,0 +1,134 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// Password recovery from the menu. +// +// This drives `cix-server -reset-password `, which opens the SQLite +// database directly — no HTTP, no auth — so possession of the database file is +// the authorization boundary. The server does NOT need to be stopped for it. + +// resetPasswordFlow prompts for an address, runs the reset, and shows the +// generated password. +func (m *menu) resetPasswordFlow() { + vars, err := readServerEnv() + if err != nil { + _ = alert("cix", "cix is not set up yet.") + return + } + + email, err := prompt("Reset password", + "Enter the email address of the account to reset.\n\n"+ + "A new temporary password will be generated. Any active sessions for the "+ + "account are signed out, and the next login forces a password change.", + vars["CIX_BOOTSTRAP_ADMIN_EMAIL"]) + if err != nil { + return // cancelled, or the dialog failed and already reported + } + email = strings.TrimSpace(email) + if email == "" { + return + } + + password, err := runResetPassword(m.bundle, vars, email) + if err != nil { + _ = alert("Could not reset the password", err.Error()) + return + } + + _ = alert("Password reset", fmt.Sprintf( + "Account:\n%s\n\nTemporary password:\n%s\n\n"+ + "You will be asked to change it at the next sign-in. Other sessions for this "+ + "account have been signed out.", email, password)) +} + +// runResetPassword executes the reset and returns the generated password. +// +// The returned error is safe to display. The command's own error output is not: +// on an unknown address it lists every account in the database +// (resetpassword.go, "existing users: …"). That is defensible for an operator +// at a terminal who already holds the DB file, and indefensible in a GUI alert, +// where a typo would enumerate accounts to whoever is looking at the screen. So +// the full output goes to the log and the dialog gets one sentence. +func runResetPassword(b bundle, vars map[string]string, email string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, b.Server, "-reset-password", email) + + // The subprocess reads config from the environment, so it must see the same + // database the server uses. Without these it would fall back to the default + // path and refuse — or worse, on an older build, act on the wrong database. + cmd.Env = append(os.Environ(), + "CIX_SQLITE_PATH="+vars["CIX_SQLITE_PATH"], + "CIX_DATA_DIR="+vars["CIX_DATA_DIR"], + ) + + // Empty stdin, not the terminal's: the command reads a password from a pipe + // when one is attached and generates one otherwise. /dev/null reads as an + // empty first line, which it treats as "generate" — the branch we want, and + // the only one that gives us a password to show. + devNull, err := os.Open(os.DevNull) + if err != nil { + return "", fmt.Errorf("open %s: %w", os.DevNull, err) + } + defer devNull.Close() + cmd.Stdin = devNull + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + logf("reset-password for %q failed: %v; stderr: %s", email, err, strings.TrimSpace(stderr.String())) + if ctx.Err() != nil { + return "", fmt.Errorf("the reset command did not finish in time") + } + // Matched against the server's wording, and deliberately answered with + // less than it said. + if strings.Contains(stderr.String(), "no user with email") { + return "", fmt.Errorf("No account with that email address.") + } + if strings.Contains(stderr.String(), "database not found") { + return "", fmt.Errorf("The database could not be found. Check CIX_SQLITE_PATH in ~/.cix/server.env.") + } + return "", fmt.Errorf("The password could not be reset. See ~/.cix/logs/launcher.log for details.") + } + + password, ok := parseTemporaryPassword(stdout.String()) + if !ok { + // The reset itself succeeded — the command exited zero — so saying it + // failed would be wrong and would invite a second, pointless attempt. + logf("reset-password for %q succeeded but no password line was found in: %s", email, stdout.String()) + return "", fmt.Errorf("The password was reset, but cix could not read the new password from the output. " + + "See ~/.cix/logs/launcher.log.") + } + logf("reset-password for %q succeeded", email) + return password, nil +} + +// parseTemporaryPassword pulls the password out of the command's stdout. +// +// The contract is one line, "Temporary password: ", printed only when +// the password was generated rather than piped in (resetpassword.go). Matching +// the prefix rather than a line index keeps this working when the surrounding +// lines change — the DISABLED-account warning, for one, is conditional. +func parseTemporaryPassword(stdout string) (string, bool) { + const marker = "Temporary password: " + for line := range strings.SplitSeq(stdout, "\n") { + if rest, ok := strings.CutPrefix(strings.TrimSpace(line), marker); ok { + if pw := strings.TrimSpace(rest); pw != "" { + return pw, true + } + } + } + return "", false +} diff --git a/cli/launcher/status_darwin.go b/cli/launcher/status_darwin.go index 20cbe3e9..846f05d3 100644 --- a/cli/launcher/status_darwin.go +++ b/cli/launcher/status_darwin.go @@ -59,6 +59,11 @@ type snapshot struct { // LocalOnly reflects CIX_BIND_ADDR: true when the server is bound to // loopback and therefore unreachable from other machines. LocalOnly bool + + // Autostart reflects RunAtLoad in the installed plist — read back from the + // file rather than remembered, so the menu cannot drift from what launchd + // will actually do at the next login. + Autostart bool } // providerLabel turns the wire provider kind into something a person can act @@ -360,6 +365,7 @@ func (p *poller) pollHealth() { p.snap.Port = port p.snap.Managed = managed p.snap.LocalOnly = isLocalOnly(vars) + p.snap.Autostart = autostartEnabled() if state != stateRunning { // Provider information from a server that is no longer answering is // stale by definition. diff --git a/cli/launcher/status_darwin_test.go b/cli/launcher/status_darwin_test.go index de89fca4..75b43e6a 100644 --- a/cli/launcher/status_darwin_test.go +++ b/cli/launcher/status_darwin_test.go @@ -1,6 +1,8 @@ package main import ( + "os" + "path/filepath" "strings" "testing" "unicode/utf8" @@ -292,3 +294,119 @@ func TestIsLocalOnly(t *testing.T) { } } } + +func TestParseTemporaryPassword(t *testing.T) { + // The real success output. The password line is conditional — it is only + // printed when the password was generated rather than piped in — and the + // DISABLED warning is conditional too, so matching the prefix rather than a + // line index is what keeps this working when the surrounding lines change. + out := "Password reset for admin@example.com (admin).\n" + + "Temporary password: mKq7RtVbn3XpZs4Ldy8W\n" + + "All sessions for this account were revoked; the next login forces a password change.\n" + pw, ok := parseTemporaryPassword(out) + if !ok || pw != "mKq7RtVbn3XpZs4Ldy8W" { + t.Errorf("parseTemporaryPassword() = %q, %v; want the generated password", pw, ok) + } + + // With a DISABLED account the command appends a warning after the password. + withWarning := out + "WARNING: this account is DISABLED — it still cannot log in.\n" + if pw, ok := parseTemporaryPassword(withWarning); !ok || pw != "mKq7RtVbn3XpZs4Ldy8W" { + t.Errorf("parseTemporaryPassword() with trailing warning = %q, %v", pw, ok) + } + + // A piped-in password produces no line at all. Reporting some other line as + // the password would be worse than reporting nothing. + piped := "Password reset for admin@example.com (admin).\n" + + "All sessions for this account were revoked; the next login forces a password change.\n" + if pw, ok := parseTemporaryPassword(piped); ok { + t.Errorf("parseTemporaryPassword() = %q, true; want no match when none was generated", pw) + } + if _, ok := parseTemporaryPassword(""); ok { + t.Error("parseTemporaryPassword(\"\") reported a match") + } +} + +func TestPrefsRoundTrip(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + // Nothing recorded yet: the question has not been asked. + if got := loadPrefs(); got.Takeover != "" { + t.Errorf("loadPrefs() on a fresh home = %+v, want zero", got) + } + if err := savePrefs(prefs{Takeover: takeoverKeep}); err != nil { + t.Fatalf("savePrefs: %v", err) + } + if got := loadPrefs(); got.Takeover != takeoverKeep { + t.Errorf("loadPrefs() = %+v, want Takeover=%q", got, takeoverKeep) + } + + // A corrupt file must not stop the app starting; the worst case is being + // asked the question again. + path, err := prefsPath() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + if got := loadPrefs(); got.Takeover != "" { + t.Errorf("loadPrefs() on corrupt file = %+v, want zero", got) + } +} + +func TestReadEnvFileDialects(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + // install-server.sh writes a file this parser also has to read during a + // takeover, and it does not use our quoting. + body := "# comment\n\nCIX_PORT=21847\nCIX_API_KEY=\"cix_quoted\"\nexport CIX_DATA_DIR='/tmp/data'\nBAD_LINE\n" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + got, err := readEnvFile(path) + if err != nil { + t.Fatalf("readEnvFile: %v", err) + } + want := map[string]string{ + "CIX_PORT": "21847", + "CIX_API_KEY": "cix_quoted", + "CIX_DATA_DIR": "/tmp/data", + } + for k, v := range want { + if got[k] != v { + t.Errorf("readEnvFile()[%q] = %q, want %q", k, got[k], v) + } + } + if _, ok := got["BAD_LINE"]; ok { + t.Error("a line without = should be skipped, not stored") + } +} + +func TestIsUserCancelled(t *testing.T) { + // The message is localised and the spelling differs between locales — this + // project's own development Mac runs en-GB and emits "cancelled", while the + // American spelling has one L. Matching either sentence is wrong somewhere, + // and was: every dialog dismissal in the app was being reported as a + // failure. The OSStatus is the same number in every language. + cancelled := []string{ + `0:778: execution error: User cancelled. (-128)`, + `0:112: execution error: User canceled. (-128)`, + `0:50: execution error: User canceled the operation. (-128)`, + } + for _, s := range cancelled { + if !isUserCancelled(s) { + t.Errorf("isUserCancelled(%q) = false, want true", s) + } + } + + notCancelled := []string{ + "", + `0:10: syntax error: Expected end of line but found identifier. (-2741)`, + `execution error: Finder got an error: Can't get disk "cix". (-1728)`, + } + for _, s := range notCancelled { + if isUserCancelled(s) { + t.Errorf("isUserCancelled(%q) = true, want false", s) + } + } +} diff --git a/cli/launcher/takeover_darwin.go b/cli/launcher/takeover_darwin.go new file mode 100644 index 00000000..0f363f01 --- /dev/null +++ b/cli/launcher/takeover_darwin.go @@ -0,0 +1,230 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// Coexisting with install-server.sh. +// +// Both use the launchd label com.cix.server — install-server.sh:44 — and it +// points at a repo checkout (:351). So on a machine where someone already runs +// cix from a clone, this app finds an agent it did not create, pointing at a +// server it does not own, holding the port it would use. +// +// Silently rewriting that agent would repoint a working development install at +// the bundle without telling anyone, and re-running install-server.sh would +// silently take it back. Both directions are surprising. So the app asks once, +// remembers the answer, and defaults to leaving it alone. + +// handleForeignAgent asks (once) what to do about an agent this app did not +// create, and acts on the answer. Returns true when the app now owns the agent. +func handleForeignAgent(b bundle) bool { + p := loadPrefs() + + if p.Takeover == "" { + existing := describeForeignAgent() + adopt, err := ask("Another cix installation was found", + "A cix-server background agent is already installed on this Mac, set up by "+ + "install-server.sh rather than by this app.\n\n"+ + existing+"\n\n"+ + "cix.app can take it over and manage it from the menu bar, or leave it "+ + "exactly as it is and only show its status.\n\n"+ + "If you take it over, re-running install-server.sh will claim it back.", + "Take Over", "Leave It Alone") + if err != nil { + // Undecided is not a decision: ask again next launch rather than + // recording a preference the user never expressed. + logf("takeover prompt failed or was dismissed: %v", err) + return false + } + p.Takeover = takeoverKeep + if adopt { + p.Takeover = takeoverAdopt + } + if err := savePrefs(p); err != nil { + logf("could not save the takeover decision: %v", err) + } + } + + if p.Takeover != takeoverAdopt { + logf("leaving the existing launchd agent alone (observe-only)") + return false + } + + if err := adoptForeignAgent(b); err != nil { + logf("takeover failed: %v", err) + _ = alert("Could not take over the existing installation", + fmt.Sprintf("%v\n\nThe existing installation was left untouched. "+ + "cix will show its status but will not manage it.", err)) + // Forget the decision so a fixed environment can be retried, rather + // than leaving the app permanently stuck between two modes. + p.Takeover = "" + _ = savePrefs(p) + return false + } + return true +} + +// adoptForeignAgent migrates the existing configuration, backs up what it +// replaces, and installs this app's wrapper and plist. +func adoptForeignAgent(b bundle) error { + envPath, err := foreignEnvPath() + if err != nil { + return err + } + + // Migrate first. Taking over an agent and then pointing it at a fresh, + // empty database would look like the takeover destroyed the user's index. + if envPath != "" { + if err := migrateForeignEnv(envPath); err != nil { + return fmt.Errorf("could not read the existing configuration at %s: %w", envPath, err) + } + } else if needsFirstRun() { + return errors.New("the existing agent's configuration file could not be located, " + + "so its database and API key cannot be carried over") + } + + if err := backupForeignAgent(); err != nil { + // Refuse rather than proceed: without a backup the previous setup is + // not recoverable, and this is the one step that overwrites it. + return fmt.Errorf("could not back up the existing agent: %w", err) + } + + if err := launchdBootout(); err != nil { + return fmt.Errorf("could not unload the existing agent: %w", err) + } + if err := writeLaunchdFiles(b, autostartEnabled()); err != nil { + return err + } + if err := launchdBootstrap(); err != nil { + return err + } + logf("took over the launchd agent previously managed by install-server.sh") + return nil +} + +// foreignEnvPath extracts the env file the existing wrapper sources. +// +// install-server.sh generates `source ""` into its wrapper (:775-784). +// Parsing that beats guessing at a location, because the path points into +// whichever checkout the operator installed from. +func foreignEnvPath() (string, error) { + wrapper, err := wrapperPath() + if err != nil { + return "", err + } + data, err := os.ReadFile(wrapper) + if err != nil { + return "", nil // no wrapper to read; caller decides whether that is fatal + } + for line := range strings.SplitSeq(string(data), "\n") { + line = strings.TrimSpace(line) + rest, ok := strings.CutPrefix(line, "source ") + if !ok { + rest, ok = strings.CutPrefix(line, ". ") + } + if !ok { + continue + } + path := strings.Trim(strings.TrimSpace(rest), `"'`) + if path != "" { + return path, nil + } + } + return "", nil +} + +// migrateForeignEnv copies the settings that identify the server's data into +// our own server.env. +// +// Only these four keys. The rest of an install-server.sh .env is that +// installation's business — model tuning, GPU layers, tunnel credentials — and +// copying it wholesale would silently import settings the user never chose for +// this app. These four are the ones that decide *which server* it is. +func migrateForeignEnv(path string) error { + src, err := readEnvFile(path) + if err != nil { + return err + } + + dst, err := readServerEnv() + if err != nil { + dst = map[string]string{} + } + for _, key := range []string{"CIX_PORT", "CIX_API_KEY", "CIX_SQLITE_PATH", "CIX_DATA_DIR"} { + if v, ok := src[key]; ok && strings.TrimSpace(v) != "" { + dst[key] = v + } + } + // The app owns updating itself; leaving the server's own version check on + // would mean two components offering two different update prompts. + dst["CIX_VERSION_CHECK_ENABLED"] = "false" + + logf("migrated %d settings from %s", len(dst), path) + return writeServerEnv(dst) +} + +// backupForeignAgent copies the plist and wrapper aside before they are +// overwritten, so the previous setup can be restored by hand. +func backupForeignAgent() error { + home, err := cixHome() + if err != nil { + return err + } + stamp := time.Now().Format("20060102-150405") + dir := filepath.Join(home, "backup", "install-server-"+stamp) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + + plist, err := plistPath() + if err != nil { + return err + } + wrapper, err := wrapperPath() + if err != nil { + return err + } + for _, src := range []string{plist, wrapper} { + data, err := os.ReadFile(src) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return err + } + if err := os.WriteFile(filepath.Join(dir, filepath.Base(src)), data, 0o600); err != nil { + return err + } + } + logf("backed up the previous agent to %s", dir) + return nil +} + +// describeForeignAgent renders what was found, for the prompt. Being concrete +// is what lets someone recognise their own installation instead of guessing. +func describeForeignAgent() string { + var parts []string + if envPath, _ := foreignEnvPath(); envPath != "" { + parts = append(parts, "Configuration: "+envPath) + } + if wrapper, err := wrapperPath(); err == nil { + if data, err := os.ReadFile(wrapper); err == nil { + for line := range strings.SplitSeq(string(data), "\n") { + if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "exec "); ok { + parts = append(parts, "Server: "+strings.Trim(strings.TrimSpace(rest), `"'`)) + break + } + } + } + } + if len(parts) == 0 { + return "Its configuration could not be read." + } + return strings.Join(parts, "\n") +} diff --git a/doc/MACOS_APP.md b/doc/MACOS_APP.md index 6dd56bd8..05ef5d31 100644 --- a/doc/MACOS_APP.md +++ b/doc/MACOS_APP.md @@ -7,9 +7,8 @@ The app sits in the menu bar and shows whether the server is running and what its embedding provider is doing, with start/stop and a link to the dashboard. There is no Dock icon and no window. -> **Not in this release yet:** the autostart toggle, password reset from the -> menu, and self-update. Autostart can be set up with `install-server.sh`, and -> password reset is a command, described below. +> **Not in this release yet:** self-update. Everything else — start/stop, +> autostart, password recovery, network exposure — is in the menu. ## Requirements @@ -115,6 +114,8 @@ What it writes: | `~/.cix/launchd/run-cix-server.sh` | launchd entry point; sources `server.env` | | `~/Library/LaunchAgents/com.cix.server.plist` | the launchd agent | | `~/.cix/config.yaml` | a `local` server entry, so the `cix` CLI works | +| `~/.cix/launcher.json` | remembered answers, currently only the takeover choice | +| `~/.cix/logs/launcher.log` | the app's own log (mode 0600) | `server.env` is the single source of truth for the server process — the plist carries no configuration. To change a setting, edit that file and use @@ -137,7 +138,9 @@ macOS announces any newly registered background agent. Stop Server Server 0.12.4 Open Dashboard ───────────── +Start at Login ✓ Allow Network Access ✓ +Reset Password… ───────────── Quit (server keeps running) ``` @@ -158,6 +161,31 @@ placement for free. loads the embedding model and can take anywhere from 30 seconds to several minutes, in silence — a server showing "Starting…" is working, not stuck. +### Start at Login + +Whether launchd starts the server when you log in. Off after installation. + +Toggling it reloads the launchd agent, which stops the server for a moment — +launchd reads a job's configuration only when the job is loaded, so there is no +way to change this in place. The app restarts the server if it was running, so +the interruption is a few seconds and nothing else. + +This is `RunAtLoad` in the plist, and the menu reads it back from the file +rather than remembering it, so the checkbox cannot drift from what launchd will +actually do. + +### Reset Password… + +Generates a new temporary password for an account and signs out its other +sessions; the next sign-in forces a change. The server does not need to be +stopped — this opens the database directly, so holding the database file is the +authorisation. + +The underlying command prints every account in the database when it does not +recognise an address, which is reasonable at a terminal and not something to put +on screen. The dialog says only "No account with that email address."; the full +output goes to `~/.cix/logs/launcher.log` (mode 0600). + ### Allow Network Access Off, the server binds to `127.0.0.1` and only this Mac can reach it. On, it @@ -185,11 +213,29 @@ configured server — that one may legitimately point at a remote cix. ### If you already use install-server.sh -Both use the same launchd label, `com.cix.server`. When the app finds an agent -it did not create, it does not touch it: status, the provider row and the -dashboard link keep working over HTTP, and Start/Stop are disabled and labelled -*managed externally*. The app is then useful alongside a development checkout -instead of fighting it. +Both use the same launchd label, `com.cix.server`, and `install-server.sh` +points it at a repo checkout. So on a machine that already runs cix from a +clone, the app finds an agent it did not create, pointing at a server it does +not own, holding the port it would use. + +It asks once what to do, shows you the paths it found, and remembers the answer +in `~/.cix/launcher.json`: + +- **Leave It Alone** — observe-only. Status, the provider row, the dashboard + link and password reset keep working over HTTP; Start/Stop, autostart and the + network toggle are disabled. The app is then useful alongside a development + checkout instead of fighting it. +- **Take Over** — the app copies the port, API key and database paths out of + the `.env` the old wrapper sourced, backs the old plist and wrapper up under + `~/.cix/backup/`, and installs its own. Re-running `install-server.sh` will + claim the agent back. + +Only those four settings are migrated. The rest of an `install-server.sh` `.env` +— model tuning, GPU layers, tunnel credentials — belongs to that installation, +and importing it wholesale would apply settings you never chose for this app. + +The first-run wizard never runs while a foreign agent is present: it would set +up a second server that cannot bind the port, against a second, empty database. ## Using the CLI @@ -200,9 +246,9 @@ bundle after an update: ln -sf /Applications/cix.app/Contents/MacOS/cix /usr/local/bin/cix ``` -### Forgotten password +### Forgotten password, from a terminal -`cix-server` can reset a password offline, without the server being stopped: +The menu's **Reset Password…** does this for you. The same thing by hand: ```bash /Applications/cix.app/Contents/MacOS/cix-server -reset-password you@example.com From 66b0bb96350bcf5d2faa0b2d8e1ad5afb5ce02bb Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 15:55:00 +0100 Subject: [PATCH 09/12] feat(mac): self-update from the mac/v* release stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app replaces itself whole — bundle and all — rather than swapping individual binaries. Two reasons, and the first is not negotiable: the bundle's ad-hoc signature seals every executable inside it, so replacing one of them breaks `codesign --verify --strict` and Apple Silicon then refuses to run it. The second is that cix-server, the cix CLI and llama-server are built and tested as one artefact, and mixing release streams would produce a combination nobody has run. It is also what makes "update the CLI" mean anything: the cix on PATH is a symlink into the bundle. Checks are cheap by construction. The releases listing is fetched with the previous ETag, so an unchanged check is a 304 — which does not count against GitHub's 60-per-hour unauthenticated limit — and the ETag is persisted so that holds across restarts too. On top of that the automatic check is throttled to 30 minutes and says nothing when there is nothing to offer. The version comparison is ported from server/internal/versioncheck rather than imported, since that package is under the server module's internal/. One rule is deliberately inverted: versioncheck treats an unparseable current version as "anything is newer", which is right for a dashboard banner and wrong for something that overwrites the running application — a development build is usually newer than the last release. Here an unidentifiable build is never replaced, and dev builds skip the check entirely. Every step before the swap is reversible, and the swap itself moves rather than deletes: live → .old, staged → live, then remove .old. If the second move fails there is still a complete application on disk to restore, which "delete then copy" would not leave. The swap runs from an embedded script in its own session because the application being replaced is the one running the update, and a process cannot outlive the deletion of its own bundle to reopen it. Not asking for admin is a decision, not an omission. If the folder holding the app is not writable the update stops before downloading anything and says to install by hand. An unsigned app requesting an administrator password to overwrite itself is indistinguishable from malware, and teaching users to approve that is worse than an update that asks them to drag an icon. The SHA-256 check against checksums.txt proves the download arrived intact and is explicitly not a trust anchor — the checksums travel the same channel as the image. Without a Developer ID there is nothing stronger available; a detached signature over the checksums is the obvious next step and is noted in the code. Verified end to end twice against a local release API serving a real, locally built DMG — the update path replaces the running application, so the only way to know it works is to run it. First pass: 0.3.0 → 0.4.0, signature still valid after the swap, no .new or .old left behind, app reopened by itself. That run exposed a real defect, since the server happened to be down: the "was it running" test used the health check, so a server still loading its embedding model — pid alive, no /health yet — read as stopped, and the update would have replaced the bundle under a live cix-server. Now it tests for a process. Second pass with a running server: 0.4.0 → 0.5.0 and the server came back on its own. Co-Authored-By: Claude Opus 5 --- cli/internal/release/release.go | 163 ++++++++++++ cli/internal/release/release_test.go | 162 ++++++++++++ cli/internal/release/semver.go | 101 +++++++ cli/launcher/main_darwin.go | 4 + cli/launcher/menu_darwin.go | 83 +++++- cli/launcher/prefs_darwin.go | 10 + cli/launcher/status_darwin_test.go | 77 ++++++ cli/launcher/swap.sh | 70 +++++ cli/launcher/update_darwin.go | 376 +++++++++++++++++++++++++++ doc/MACOS_APP.md | 37 ++- 10 files changed, 1080 insertions(+), 3 deletions(-) create mode 100644 cli/internal/release/release.go create mode 100644 cli/internal/release/release_test.go create mode 100644 cli/internal/release/semver.go create mode 100644 cli/launcher/swap.sh create mode 100644 cli/launcher/update_darwin.go diff --git a/cli/internal/release/release.go b/cli/internal/release/release.go new file mode 100644 index 00000000..4ad0b0bb --- /dev/null +++ b/cli/internal/release/release.go @@ -0,0 +1,163 @@ +package release + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// DefaultRepo is the GitHub repository releases are published to. +const DefaultRepo = "dvcdsys/code-index" + +// Release is one published release of the requested tag stream. +type Release struct { + // Version is the tag with its stream prefix stripped: "0.3.1", not + // "mac/v0.3.1". + Version string + TagName string + HTMLURL string + Assets []Asset +} + +// Asset is a downloadable file attached to a release. +type Asset struct { + Name string + URL string + Size int64 +} + +// AssetByName returns the named asset. The DMG and checksums.txt are located by +// name rather than by position, because the release workflow's upload order is +// not a contract. +func (r Release) AssetByName(name string) (Asset, bool) { + for _, a := range r.Assets { + if a.Name == name { + return a, true + } + } + return Asset{}, false +} + +// AssetBySuffix returns the first asset whose name ends in suffix — used for +// the DMG, whose filename carries the version. +func (r Release) AssetBySuffix(suffix string) (Asset, bool) { + for _, a := range r.Assets { + if strings.HasSuffix(a.Name, suffix) { + return a, true + } + } + return Asset{}, false +} + +// Client queries the GitHub releases API. +// +// Unauthenticated, which caps it at 60 requests per hour per IP — shared with +// anything else on the machine doing the same. That is why every response's +// ETag is kept and replayed: a 304 does not count against the limit, so an app +// that checks on every menu open costs nothing on the days there is no release. +type Client struct { + HTTP *http.Client + BaseURL string + Repo string + + // TagPrefix selects the stream, e.g. "mac/v". + TagPrefix string + + // ETag is sent as If-None-Match and updated from each 200 response. Callers + // persist it between runs. + ETag string +} + +func New(repo, tagPrefix string) *Client { + return &Client{ + HTTP: &http.Client{Timeout: 20 * time.Second}, + BaseURL: "https://api.github.com", + Repo: repo, + TagPrefix: tagPrefix, + } +} + +// ErrNotModified is returned when GitHub answers 304, meaning the cached result +// is still current. +var ErrNotModified = fmt.Errorf("not modified") + +// Latest returns the highest-versioned published release of the stream. +// +// Returns a zero Release and no error when the stream has no releases yet — +// that is a normal state for a new tag stream, not a failure. +func (c *Client) Latest(ctx context.Context) (Release, error) { + url := fmt.Sprintf("%s/repos/%s/releases?per_page=30", c.BaseURL, c.Repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return Release{}, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + if c.ETag != "" { + req.Header.Set("If-None-Match", c.ETag) + } + + resp, err := c.HTTP.Do(req) + if err != nil { + return Release{}, err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusNotModified: + return Release{}, ErrNotModified + case http.StatusOK: + case http.StatusForbidden, http.StatusTooManyRequests: + // Distinguished from a generic failure because it is self-healing and + // the caller should stay quiet about it rather than alarm the user. + return Release{}, fmt.Errorf("github rate limit reached (resets hourly)") + default: + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return Release{}, fmt.Errorf("github returned %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + var raw []struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + Assets []struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + Size int64 `json:"size"` + } `json:"assets"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&raw); err != nil { + return Release{}, fmt.Errorf("decode releases: %w", err) + } + c.ETag = resp.Header.Get("ETag") + + var best Release + for _, r := range raw { + if r.Draft || r.Prerelease { + continue + } + version, ok := strings.CutPrefix(r.TagName, c.TagPrefix) + if !ok { + continue + } + // Belt and braces: drop anything still shaped like a prerelease or a + // build-metadata tag even when GitHub did not flag it. + if strings.ContainsAny(version, "-+") { + continue + } + if best.Version != "" && CompareSemver(version, best.Version) <= 0 { + continue + } + rel := Release{Version: version, TagName: r.TagName, HTMLURL: r.HTMLURL} + for _, a := range r.Assets { + rel.Assets = append(rel.Assets, Asset{Name: a.Name, URL: a.URL, Size: a.Size}) + } + best = rel + } + return best, nil +} diff --git a/cli/internal/release/release_test.go b/cli/internal/release/release_test.go new file mode 100644 index 00000000..ebd1ec07 --- /dev/null +++ b/cli/internal/release/release_test.go @@ -0,0 +1,162 @@ +package release + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCompareSemver(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"1.2.3", "1.2.3", 0}, + {"1.2.4", "1.2.3", 1}, + {"1.2.3", "1.2.4", -1}, + {"1.10.0", "1.9.0", 1}, // numeric, not lexicographic + {"2.0.0", "1.99.99", 1}, + {"1.2", "1.2.0", 0}, // missing components are zero + {"1.2.1", "1.2", 1}, + } + for _, tc := range tests { + if got := CompareSemver(tc.a, tc.b); got != tc.want { + t.Errorf("CompareSemver(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want) + } + } +} + +func TestIsNewer(t *testing.T) { + if !IsNewer("0.3.0", "0.3.1") { + t.Error("0.3.1 should be newer than 0.3.0") + } + if IsNewer("0.3.1", "0.3.1") { + t.Error("a version is not newer than itself") + } + if IsNewer("0.4.0", "0.3.9") { + t.Error("an older release should not be offered") + } + if IsNewer("0.3.0", "") { + t.Error("an empty latest is not an update") + } + // Deliberately unlike the server's version-check, which treats an + // unparseable current version as "anything is newer". That is right for a + // banner and wrong for something that replaces the running application: a + // development build is usually newer than the last release, so offering to + // overwrite it would be a downgrade wearing an update's clothes. + for _, current := range []string{"", "dev", "0.3.0-dev", "0.12.4-129-g8abef0b"} { + if IsNewer(current, "9.9.9") { + t.Errorf("IsNewer(%q, \"9.9.9\") = true; an unidentifiable build must not be replaced", current) + } + } + // A "v" prefix on the installed version is tolerated. + if !IsNewer("v0.3.0", "0.3.1") { + t.Error("a leading v on the current version should be ignored") + } +} + +const releasesJSON = `[ + {"tag_name":"mac/v0.3.0","html_url":"https://example.test/0.3.0","draft":false,"prerelease":false, + "assets":[{"name":"cix-0.3.0-arm64.dmg","browser_download_url":"https://example.test/a.dmg","size":123}, + {"name":"checksums.txt","browser_download_url":"https://example.test/c.txt","size":45}]}, + {"tag_name":"mac/v0.4.0","html_url":"https://example.test/0.4.0","draft":true,"prerelease":false,"assets":[]}, + {"tag_name":"mac/v0.3.5","html_url":"https://example.test/0.3.5","draft":false,"prerelease":true,"assets":[]}, + {"tag_name":"mac/v0.3.2-rc1","html_url":"https://example.test/rc","draft":false,"prerelease":false,"assets":[]}, + {"tag_name":"server/v9.9.9","html_url":"https://example.test/server","draft":false,"prerelease":false,"assets":[]}, + {"tag_name":"mac/v0.3.1","html_url":"https://example.test/0.3.1","draft":false,"prerelease":false, + "assets":[{"name":"cix-0.3.1-arm64.dmg","browser_download_url":"https://example.test/b.dmg","size":999}]} +]` + +func TestLatestFiltersTheStream(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("ETag", `W/"abc123"`) + w.Write([]byte(releasesJSON)) + })) + defer srv.Close() + + c := New(DefaultRepo, "mac/v") + c.BaseURL = srv.URL + + got, err := c.Latest(context.Background()) + if err != nil { + t.Fatalf("Latest: %v", err) + } + // 0.4.0 is a draft, 0.3.5 a prerelease, 0.3.2-rc1 carries a prerelease + // suffix GitHub did not flag, and server/v9.9.9 is a different stream + // entirely — sharing one repository with three tag streams is exactly why + // the prefix filter exists. + if got.Version != "0.3.1" { + t.Errorf("Version = %q, want 0.3.1", got.Version) + } + if got.TagName != "mac/v0.3.1" { + t.Errorf("TagName = %q, want mac/v0.3.1", got.TagName) + } + if c.ETag != `W/"abc123"` { + t.Errorf("ETag = %q, want it captured for the next request", c.ETag) + } + if _, ok := got.AssetBySuffix(".dmg"); !ok { + t.Error("the DMG asset should be findable by suffix") + } +} + +func TestLatestSendsAndHonoursETag(t *testing.T) { + var sawIfNoneMatch string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawIfNoneMatch = r.Header.Get("If-None-Match") + w.WriteHeader(http.StatusNotModified) + })) + defer srv.Close() + + c := New(DefaultRepo, "mac/v") + c.BaseURL = srv.URL + c.ETag = `W/"cached"` + + _, err := c.Latest(context.Background()) + if !errors.Is(err, ErrNotModified) { + t.Errorf("Latest() = %v, want ErrNotModified", err) + } + // The whole point: a 304 does not count against the 60-per-hour + // unauthenticated limit, so checking often is free on quiet days. + if sawIfNoneMatch != `W/"cached"` { + t.Errorf("If-None-Match = %q, want the cached ETag", sawIfNoneMatch) + } + if c.ETag != `W/"cached"` { + t.Errorf("ETag = %q; a 304 must not clear it", c.ETag) + } +} + +func TestLatestOnEmptyStream(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte(`[{"tag_name":"server/v1.0.0","draft":false,"prerelease":false,"assets":[]}]`)) + })) + defer srv.Close() + + c := New(DefaultRepo, "mac/v") + c.BaseURL = srv.URL + + // A stream with no releases yet is a normal state for a new tag stream, not + // an error — the app should stay quiet, not report a failure. + got, err := c.Latest(context.Background()) + if err != nil { + t.Fatalf("Latest on an empty stream returned an error: %v", err) + } + if got.Version != "" { + t.Errorf("Version = %q, want empty", got.Version) + } +} + +func TestLatestRateLimit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + c := New(DefaultRepo, "mac/v") + c.BaseURL = srv.URL + + if _, err := c.Latest(context.Background()); err == nil { + t.Fatal("a 403 should be reported as an error") + } +} diff --git a/cli/internal/release/semver.go b/cli/internal/release/semver.go new file mode 100644 index 00000000..521ebb46 --- /dev/null +++ b/cli/internal/release/semver.go @@ -0,0 +1,101 @@ +// Package release finds the newest published release of a tagged stream on +// GitHub. +// +// This is a deliberate port of server/internal/versioncheck, not an import: the +// server and the CLI are separate modules and that package lives under the +// server's internal/, so it is structurally unreachable from here. The +// filtering and comparison rules are kept identical so a tag is judged newer by +// the same standard on both sides, and the tests below mirror that package's. +// +// One rule is deliberately NOT shared. versioncheck.isNewer treats a `-dev` +// build as "anything is newer", which is right for a dashboard banner nudging +// an operator onto a release. It is wrong for something that replaces the +// running application: a development build is usually newer than the last +// release, and offering to overwrite it with an older one is a regression +// disguised as an update. Callers here skip the check for dev builds instead. +package release + +import ( + "strconv" + "strings" +) + +// CompareSemver compares two MAJOR.MINOR.PATCH strings numerically, returning +// -1, 0 or 1. Non-numeric components fall back to a lexicographic comparison — +// a safety net, since the release filter rejects anything that is not plain +// numeric to begin with. +func CompareSemver(a, b string) int { + pa := strings.Split(a, ".") + pb := strings.Split(b, ".") + n := max(len(pa), len(pb)) + + for i := range n { + ai, aOK := 0, true + bi, bOK := 0, true + if i < len(pa) { + ai, aOK = atoi(pa[i]) + } + if i < len(pb) { + bi, bOK = atoi(pb[i]) + } + if aOK && bOK { + if ai != bi { + if ai < bi { + return -1 + } + return 1 + } + continue + } + var as, bs string + if i < len(pa) { + as = pa[i] + } + if i < len(pb) { + bs = pb[i] + } + if as != bs { + if as < bs { + return -1 + } + return 1 + } + } + return 0 +} + +// IsNewer reports whether latest is a strictly newer version than current. +// +// Unlike the server's equivalent, an unparseable current version is NOT treated +// as "anything is newer" — see the package comment. Here it means "do not +// offer", because the only safe answer when we cannot tell which build is newer +// is to leave the installed one alone. +func IsNewer(current, latest string) bool { + if latest == "" { + return false + } + cur := strings.TrimPrefix(current, "v") + if cur == "" || !looksNumeric(cur) { + return false + } + return CompareSemver(latest, cur) > 0 +} + +func atoi(s string) (int, bool) { + n, err := strconv.Atoi(s) + return n, err == nil +} + +// looksNumeric reports whether every dot-separated component parses as a +// number — i.e. the string is a plain MAJOR.MINOR.PATCH with no suffix. +func looksNumeric(s string) bool { + if s == "" { + return false + } + for part := range strings.SplitSeq(s, ".") { + if _, ok := atoi(part); !ok { + return false + } + } + return true +} diff --git a/cli/launcher/main_darwin.go b/cli/launcher/main_darwin.go index e4717f1a..7cacd297 100644 --- a/cli/launcher/main_darwin.go +++ b/cli/launcher/main_darwin.go @@ -91,6 +91,10 @@ func main() { if err := writeLaunchdFiles(b, autostartEnabled()); err != nil { logf("could not refresh launchd files: %v", err) } + // Ordered after the plist rewrite, not before: an update replaced the + // binary the agent points at, and starting the server first would run + // the previous version's path. + resumeAfterUpdate() } runMenu(b) diff --git a/cli/launcher/menu_darwin.go b/cli/launcher/menu_darwin.go index 669590a8..f29d81dc 100644 --- a/cli/launcher/menu_darwin.go +++ b/cli/launcher/menu_darwin.go @@ -35,6 +35,9 @@ type menu struct { autostartItem *systray.MenuItem networkItem *systray.MenuItem resetPWItem *systray.MenuItem + updateItem *systray.MenuItem + + updater *updater // detail holds the submenu rows under the server row. They are created // once and retitled on every render: systray has no way to remove an item, @@ -47,7 +50,7 @@ type menu struct { const detailRows = 6 func runMenu(b bundle) { - m := &menu{bundle: b, poll: newPoller(), stop: make(chan struct{})} + m := &menu{bundle: b, poll: newPoller(), stop: make(chan struct{}), updater: newUpdater(b)} systray.Run(m.onReady, m.onExit) } @@ -97,6 +100,9 @@ func (m *menu) onReady() { m.networkItem = systray.AddMenuItemCheckbox("Allow Network Access", "", false) m.resetPWItem = systray.AddMenuItem("Reset Password…", "") + systray.AddSeparator() + m.updateItem = systray.AddMenuItem("Check for Updates…", "") + systray.AddSeparator() // Spelled out rather than left to a tooltip. Quit here closes the menu bar // app and leaves the launchd agent running, which is the opposite of what @@ -106,6 +112,11 @@ func (m *menu) onReady() { go m.poll.run(m.stop) go m.watch() + // A quiet background check. It only speaks up when there is something to + // offer, and it is throttled and ETag-cached, so an app left open all day + // costs a handful of 304s. + go m.checkForUpdates(false) + go func() { for { select { @@ -119,6 +130,8 @@ func (m *menu) onReady() { go m.toggleNetworkAccess() case <-m.resetPWItem.ClickedCh: go m.resetPasswordFlow() + case <-m.updateItem.ClickedCh: + go m.checkForUpdates(true) case <-quitItem.ClickedCh: systray.Quit() return @@ -240,6 +253,74 @@ func (m *menu) toggleAutostart() { m.poll.refresh() } +// checkForUpdates looks for a newer release and, if the user agrees, installs +// it. `explicit` distinguishes the menu item from the background check: a +// background check that finds nothing says nothing. +func (m *menu) checkForUpdates(explicit bool) { + if isDevBuild() { + if explicit { + _ = alert("cix", "This is a development build, so there is nothing to update it to.\n\n"+ + "Released builds check for updates automatically.") + } + return + } + + rel, newer := m.updater.check(explicit) + if !newer { + if explicit { + _ = alert("cix is up to date", fmt.Sprintf("You are running version %s.", version)) + } + return + } + + ok, err := ask("A new version of cix is available", + fmt.Sprintf("cix %s is available. You are running %s.\n\n"+ + "The update is downloaded, checked, and installed in place. "+ + "cix will restart, and the server will be stopped and started again with it.", + rel.Version, version), + "Update Now", "Not Now") + if err != nil || !ok { + return + } + + // "Is there a process", not "is it answering". A server still loading its + // embedding model has a pid and no /health yet; treating that as stopped + // would skip the shutdown and then replace the bundle out from under a live + // cix-server, which macOS answers by killing it. Captured before anything + // is touched, because after the swap this process no longer exists to + // remember it. + wasRunning := m.poll.snapshotNow().PID != 0 + + if err := m.updater.install(rel, wasRunning); err != nil { + logf("update to %s failed: %v", rel.Version, err) + _ = alert("Could not install the update", err.Error()) + m.poll.refresh() + return + } + + // From here the swap helper owns the outcome: it waits for this process to + // exit before moving anything, so quitting is the last required step. + systray.Quit() +} + +// resumeAfterUpdate restarts the server when the update that just completed +// had stopped a running one. +func resumeAfterUpdate() { + p := loadPrefs() + if !p.RestartServerAfterUpdate { + return + } + p.RestartServerAfterUpdate = false + if err := savePrefs(p); err != nil { + logf("could not clear the post-update restart flag: %v", err) + } + if err := startServer(); err != nil { + logf("could not restart the server after the update: %v", err) + return + } + logf("server restarted after the update") +} + // renderDetail fills the submenu under the server row. Slots with nothing to // say are hidden, so the submenu never shows an empty line. func (m *menu) renderDetail(s snapshot) { diff --git a/cli/launcher/prefs_darwin.go b/cli/launcher/prefs_darwin.go index 40ad41dc..a78a01be 100644 --- a/cli/launcher/prefs_darwin.go +++ b/cli/launcher/prefs_darwin.go @@ -19,6 +19,16 @@ type prefs struct { // install-server.sh was found under the same launchd label: "takeover" or // "keep". Empty means the question has not been asked yet. Takeover string `json:"takeover,omitempty"` + + // UpdateETag is GitHub's ETag from the last release listing. Replaying it + // turns an unchanged check into a 304, which does not count against the + // unauthenticated hourly limit — so it is worth surviving a restart. + UpdateETag string `json:"update_etag,omitempty"` + + // RestartServerAfterUpdate carries one bit across the update: the process + // that stops the server is replaced before the one that should start it + // again exists, so the intent has to live on disk in between. + RestartServerAfterUpdate bool `json:"restart_server_after_update,omitempty"` } const ( diff --git a/cli/launcher/status_darwin_test.go b/cli/launcher/status_darwin_test.go index 75b43e6a..af515508 100644 --- a/cli/launcher/status_darwin_test.go +++ b/cli/launcher/status_darwin_test.go @@ -1,6 +1,8 @@ package main import ( + "crypto/sha256" + "fmt" "os" "path/filepath" "strings" @@ -410,3 +412,78 @@ func TestIsUserCancelled(t *testing.T) { } } } + +func TestChecksumFor(t *testing.T) { + // The release workflow generates this with `shasum -a 256 ./*.dmg`, which + // records a leading "./" — a plain equality check on the filename would + // find nothing and abort every update. + sums := "" + + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./cix-0.3.1-arm64.dmg\n" + + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 ./checksums-other.txt\n" + + got, err := checksumFor(sums, "cix-0.3.1-arm64.dmg") + if err != nil { + t.Fatalf("checksumFor: %v", err) + } + if got != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" { + t.Errorf("checksumFor() = %q", got) + } + + // A name that is not listed must be an error, never a silent pass: the + // checksum is the only integrity check an unnotarised download gets. + if _, err := checksumFor(sums, "cix-9.9.9-arm64.dmg"); err == nil { + t.Error("checksumFor() on a missing entry returned no error") + } + if _, err := checksumFor("", "anything.dmg"); err == nil { + t.Error("checksumFor() on empty input returned no error") + } +} + +func TestVerifyChecksum(t *testing.T) { + dir := t.TempDir() + payload := filepath.Join(dir, "cix-0.3.1-arm64.dmg") + if err := os.WriteFile(payload, []byte("pretend disk image"), 0o600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte("pretend disk image")) + sums := filepath.Join(dir, "checksums.txt") + if err := os.WriteFile(sums, fmt.Appendf(nil, "%x ./cix-0.3.1-arm64.dmg\n", sum), 0o600); err != nil { + t.Fatal(err) + } + + if err := verifyChecksum(payload, sums, "cix-0.3.1-arm64.dmg"); err != nil { + t.Errorf("verifyChecksum on a matching file = %v, want nil", err) + } + + // A truncated or tampered download must not install. + if err := os.WriteFile(payload, []byte("pretend disk imag"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifyChecksum(payload, sums, "cix-0.3.1-arm64.dmg"); err == nil { + t.Error("verifyChecksum accepted a file whose contents changed") + } +} + +func TestParseMountPoint(t *testing.T) { + // hdiutil prints one line per partition; the volume is on the last one. + out := "/dev/disk4 \tGUID_partition_scheme \t\n" + + "/dev/disk4s1 \tApple_HFS \t/Volumes/cix\n" + if got, want := parseMountPoint(out), "/Volumes/cix"; got != want { + t.Errorf("parseMountPoint() = %q, want %q", got, want) + } + if got := parseMountPoint("/dev/disk9\tGUID_partition_scheme\t\n"); got != "" { + t.Errorf("parseMountPoint() with no volume = %q, want empty", got) + } +} + +func TestCheckWritable(t *testing.T) { + if err := checkWritable(t.TempDir()); err != nil { + t.Errorf("checkWritable on a temp dir = %v, want nil", err) + } + // The point of the preflight: find out before spending a download, while + // the installed app is still untouched and the advice can be "drag the new + // one over the old one". + if err := checkWritable("/usr/bin"); err == nil { + t.Skip("running as root, or /usr/bin is writable — preflight cannot be exercised") + } +} diff --git a/cli/launcher/swap.sh b/cli/launcher/swap.sh new file mode 100644 index 00000000..e8b1b47b --- /dev/null +++ b/cli/launcher/swap.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# swap.sh — replace a running .app with a staged copy, then reopen it. +# +# Embedded into cix-launcher and written to a temp file at update time. It has +# to be a separate process for one reason: the application being replaced is the +# one running the update, and a process cannot outlive the deletion of its own +# bundle to reopen it. +# +# $1 pid of the launcher to wait for +# $2 live bundle (/Applications/cix.app) +# $3 staged bundle (/Applications/.cix.app.new) +# $4 log file +# +# Deliberately /bin/bash, not /usr/bin/env bash: this runs detached, after the +# app is gone, with whatever environment launchd or the shell left behind. The +# system bash is the one path guaranteed to exist. +set -uo pipefail + +LAUNCHER_PID="${1:?pid required}" +LIVE="${2:?live bundle required}" +STAGED="${3:?staged bundle required}" +LOG="${4:-/dev/null}" + +log() { printf '%s swap: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$LOG"; } + +log "waiting for launcher pid $LAUNCHER_PID to exit" +# Bounded: a launcher that never exits must not leave a script polling forever, +# and swapping the bundle out from under a running process is how you get a +# half-updated app. +for _ in $(seq 1 120); do + kill -0 "$LAUNCHER_PID" 2>/dev/null || break + sleep 0.5 +done +if kill -0 "$LAUNCHER_PID" 2>/dev/null; then + log "launcher still running after 60s — aborting, nothing was changed" + exit 1 +fi + +if [ ! -d "$STAGED" ]; then + log "staged bundle missing at $STAGED — aborting" + exit 1 +fi + +OLD="${LIVE}.old" +rm -rf "$OLD" + +# Move the live app aside rather than deleting it. If the second move fails — +# a full disk, a permissions change between the preflight and now — there is +# still a complete application on disk to put back, which "rm -rf then copy" +# would not leave. +if [ -d "$LIVE" ]; then + if ! mv "$LIVE" "$OLD"; then + log "could not move $LIVE aside — aborting, the installed app is untouched" + exit 1 + fi +fi + +if ! mv "$STAGED" "$LIVE"; then + log "could not move the staged bundle into place; restoring the previous app" + mv "$OLD" "$LIVE" || log "RESTORE FAILED — the previous app is at $OLD" + exit 1 +fi + +rm -rf "$OLD" +log "replaced $LIVE" + +# Reopen. -n would start a second instance; the old one is gone, so a plain +# open is what puts the user back where they were. +open "$LIVE" || log "could not reopen $LIVE" +log "done" diff --git a/cli/launcher/update_darwin.go b/cli/launcher/update_darwin.go new file mode 100644 index 00000000..fa455ecb --- /dev/null +++ b/cli/launcher/update_darwin.go @@ -0,0 +1,376 @@ +package main + +import ( + "context" + "crypto/sha256" + _ "embed" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/dvcdsys/code-index/cli/internal/release" +) + +// Self-update. +// +// The app replaces itself whole — bundle and all — rather than swapping +// individual binaries. That is not laziness: cix-server, the cix CLI and +// llama-server are versioned together and tested together, and an update that +// left one of them behind would produce a combination nobody has ever run. +// Moving the .app is also the only way "update the CLI" can mean anything, +// since the CLI on PATH is a symlink into the bundle. + +//go:embed swap.sh +var swapScript []byte + +// macTagPrefix selects this app's tag stream. server/v* and cli/v* live in the +// same repository and are released on their own schedules. +const macTagPrefix = "mac/v" + +// updateCheckInterval throttles the automatic check. The request itself is +// nearly free — a cached ETag makes a no-change check a 304, which does not +// count against GitHub's unauthenticated hourly limit — but the limit is per +// IP and shared with everything else on the machine, so there is no reason to +// spend it more often than this. +const updateCheckInterval = 30 * time.Minute + +type updater struct { + bundle bundle + client *release.Client + + lastCheck time.Time + latest release.Release +} + +func newUpdater(b bundle) *updater { + u := &updater{bundle: b, client: release.New(release.DefaultRepo, macTagPrefix)} + u.client.ETag = loadPrefs().UpdateETag + + // Test seam. The update path replaces the running application, so the only + // way to know it works is to run it — against a local server, with a + // locally built image, rather than by publishing a release and hoping. + // Unset in every normal run; there is nothing to configure here. + if base := os.Getenv("CIX_UPDATE_BASE_URL"); base != "" { + u.client.BaseURL = strings.TrimRight(base, "/") + logf("update base URL overridden to %s (CIX_UPDATE_BASE_URL)", u.client.BaseURL) + } + return u +} + +func updatesCacheDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "Caches", "cix", "updates"), nil +} + +// check asks GitHub for the newest release, at most once per interval unless +// forced. Returns the release when one is newer than the running build. +func (u *updater) check(force bool) (release.Release, bool) { + if isDevBuild() { + // A development build is normally newer than the last release, so + // "updating" it would be a downgrade. Nothing to offer. + return release.Release{}, false + } + if !force && time.Since(u.lastCheck) < updateCheckInterval { + return u.latest, release.IsNewer(version, u.latest.Version) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + rel, err := u.client.Latest(ctx) + switch { + case errors.Is(err, release.ErrNotModified): + // Nothing changed since the last check; keep what we already knew. + u.lastCheck = time.Now() + return u.latest, release.IsNewer(version, u.latest.Version) + case err != nil: + logf("update check failed: %v", err) + return release.Release{}, false + } + + u.lastCheck = time.Now() + u.latest = rel + if etag := u.client.ETag; etag != "" { + p := loadPrefs() + if p.UpdateETag != etag { + p.UpdateETag = etag + if err := savePrefs(p); err != nil { + logf("could not persist the update ETag: %v", err) + } + } + } + return rel, release.IsNewer(version, rel.Version) +} + +// install downloads, verifies and stages the release, then hands over to the +// swap script and quits. +// +// Every step before the swap is reversible: a failure leaves the installed app +// exactly as it was, which is why the staging directory is built and validated +// in full before anything live is touched. +func (u *updater) install(rel release.Release, serverWasRunning bool) error { + dmgAsset, ok := rel.AssetBySuffix(".dmg") + if !ok { + return fmt.Errorf("release %s has no disk image attached", rel.TagName) + } + sumsAsset, ok := rel.AssetByName("checksums.txt") + if !ok { + return fmt.Errorf("release %s has no checksums.txt attached", rel.TagName) + } + + // Preflight before spending a download. Writing into the bundle's parent is + // what the swap ultimately needs; find out now, while the app is untouched + // and the message can still be "reinstall from the DMG". + parent := filepath.Dir(u.bundle.Root) + if err := checkWritable(parent); err != nil { + return fmt.Errorf("cix cannot update itself because %s is not writable by you.\n\n"+ + "Download the new version and drag it over the old one instead.", parent) + } + + cacheDir, err := updatesCacheDir() + if err != nil { + return err + } + if err := os.MkdirAll(cacheDir, 0o700); err != nil { + return err + } + defer os.RemoveAll(cacheDir) + + dmgPath := filepath.Join(cacheDir, dmgAsset.Name) + if err := download(dmgAsset.URL, dmgPath); err != nil { + return fmt.Errorf("could not download the update: %w", err) + } + sumsPath := filepath.Join(cacheDir, "checksums.txt") + if err := download(sumsAsset.URL, sumsPath); err != nil { + return fmt.Errorf("could not download the checksums: %w", err) + } + + if err := verifyChecksum(dmgPath, sumsPath, dmgAsset.Name); err != nil { + // With no Developer ID signature this checksum is the entire integrity + // story, so a mismatch is fatal and loud rather than a warning. + logf("checksum verification failed for %s: %v", dmgAsset.Name, err) + return fmt.Errorf("the downloaded update failed its checksum check and was discarded.\n\n%v", err) + } + + staged := filepath.Join(parent, ".cix.app.new") + os.RemoveAll(staged) + if err := stageFromDMG(dmgPath, staged); err != nil { + os.RemoveAll(staged) + return err + } + + // Record the intent before quitting: the process that acts on it is the one + // that starts after the swap, and it has no other way to know the server + // was up. + p := loadPrefs() + p.RestartServerAfterUpdate = serverWasRunning + if err := savePrefs(p); err != nil { + logf("could not record the post-update restart flag: %v", err) + } + + // The old cix-server's executable lives inside the bundle about to be + // moved. macOS kills a process whose signed binary is replaced underneath + // it, so this is a controlled stop rather than a surprise SIGKILL. + if serverWasRunning { + if err := stopServer(); err != nil { + logf("could not stop the server before the swap: %v", err) + } + waitForServerStop(20 * time.Second) + } + + return u.launchSwap(staged) +} + +// launchSwap writes the swap script to a temp file and starts it detached. +func (u *updater) launchSwap(staged string) error { + dir, err := os.MkdirTemp("", "cix-update-") + if err != nil { + return err + } + script := filepath.Join(dir, "swap.sh") + if err := os.WriteFile(script, swapScript, 0o755); err != nil { + return err + } + + logPath, err := launcherLogPath() + if err != nil { + logPath = os.DevNull + } + + cmd := exec.Command("/bin/bash", script, + fmt.Sprint(os.Getpid()), u.bundle.Root, staged, logPath) + // Its own session, so it is not taken down with the launcher it is waiting + // for — which is the entire job. + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return fmt.Errorf("could not start the update helper: %w", err) + } + // Release the child rather than waiting: this process is about to exit and + // the helper must outlive it. + _ = cmd.Process.Release() + logf("update staged at %s; swap helper started, quitting", staged) + return nil +} + +// stageFromDMG mounts the image, copies the app out, and validates it. +func stageFromDMG(dmgPath, staged string) error { + out, err := exec.Command("hdiutil", "attach", "-nobrowse", "-readonly", "-noautoopen", dmgPath).CombinedOutput() + if err != nil { + return fmt.Errorf("could not open the downloaded disk image: %s", strings.TrimSpace(string(out))) + } + mount := parseMountPoint(string(out)) + if mount == "" { + return fmt.Errorf("could not determine where the disk image was mounted") + } + defer exec.Command("hdiutil", "detach", mount, "-force", "-quiet").Run() + + src := filepath.Join(mount, "cix.app") + if _, err := os.Stat(src); err != nil { + return fmt.Errorf("the disk image does not contain cix.app") + } + + // ditto, not cp -R: it preserves the extended attributes and metadata a + // code signature is sealed over. cp -R drops some of them, and the bundle + // then fails the verification two lines below. + if out, err := exec.Command("ditto", src, staged).CombinedOutput(); err != nil { + return fmt.Errorf("could not copy the new version: %s", strings.TrimSpace(string(out))) + } + + // Quarantine came from the download; left in place, the nested llama-server + // is SIGKILLed on exec with empty stderr. + _ = exec.Command("xattr", "-cr", staged).Run() + + if out, err := exec.Command("codesign", "--verify", "--strict", staged).CombinedOutput(); err != nil { + return fmt.Errorf("the downloaded application failed signature verification and was discarded: %s", + strings.TrimSpace(string(out))) + } + return nil +} + +// parseMountPoint pulls /Volumes/... out of hdiutil attach's output. +func parseMountPoint(out string) string { + var mount string + for line := range strings.SplitSeq(out, "\n") { + if i := strings.Index(line, "/Volumes/"); i >= 0 { + mount = strings.TrimSpace(line[i:]) + } + } + return mount +} + +func download(url, dest string) error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s returned %s", url, resp.Status) + } + + f, err := os.Create(dest) + if err != nil { + return err + } + defer f.Close() + if _, err := io.Copy(f, resp.Body); err != nil { + return err + } + return f.Sync() +} + +// verifyChecksum compares the file against its line in a shasum-format list. +// +// Worth being explicit about what this does and does not prove: it establishes +// that the bytes on disk are the bytes the release lists, which catches a +// truncated or corrupted download. It is not a trust anchor — checksums.txt +// arrives over the same channel as the image, so anyone able to replace one can +// replace the other. Without a Developer ID signature there is nothing better +// available here; a detached signature over the checksums would be. +func verifyChecksum(path, sumsPath, name string) error { + sums, err := os.ReadFile(sumsPath) + if err != nil { + return err + } + want, err := checksumFor(string(sums), name) + if err != nil { + return err + } + + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, want) { + return fmt.Errorf("expected %s, got %s", want, got) + } + return nil +} + +// checksumFor finds a file's hash in shasum output. +// +// The name is matched against the last path component because the release +// workflow generates the file with `shasum -a 256 ./*.dmg`, which records +// "./cix-0.3.1-arm64.dmg" — a leading ./ that a plain equality check would miss. +func checksumFor(sums, name string) (string, error) { + for line := range strings.SplitSeq(sums, "\n") { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) < 2 { + continue + } + if filepath.Base(fields[len(fields)-1]) == name { + return fields[0], nil + } + } + return "", fmt.Errorf("checksums.txt has no entry for %s", name) +} + +// checkWritable reports whether the current user can create files in dir. +// +// Deliberately not answered by escalating: an unsigned application asking for +// an administrator password in order to overwrite itself is indistinguishable +// from malware, and teaching users to approve that is worse than an update that +// asks them to drag an icon. +func checkWritable(dir string) error { + f, err := os.CreateTemp(dir, ".cix-write-test-") + if err != nil { + return err + } + name := f.Name() + f.Close() + return os.Remove(name) +} + +func waitForServerStop(timeout time.Duration) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if launchdPID() == 0 { + return + } + time.Sleep(250 * time.Millisecond) + } +} diff --git a/doc/MACOS_APP.md b/doc/MACOS_APP.md index 05ef5d31..0f49f8cd 100644 --- a/doc/MACOS_APP.md +++ b/doc/MACOS_APP.md @@ -7,8 +7,8 @@ The app sits in the menu bar and shows whether the server is running and what its embedding provider is doing, with start/stop and a link to the dashboard. There is no Dock icon and no window. -> **Not in this release yet:** self-update. Everything else — start/stop, -> autostart, password recovery, network exposure — is in the menu. +It keeps itself up to date, checks its downloads, and never asks for an +administrator password to do it. ## Requirements @@ -142,6 +142,8 @@ Start at Login ✓ Allow Network Access ✓ Reset Password… ───────────── +Check for Updates… +───────────── Quit (server keeps running) ``` @@ -186,6 +188,37 @@ recognise an address, which is reasonable at a terminal and not something to put on screen. The dialog says only "No account with that email address."; the full output goes to `~/.cix/logs/launcher.log` (mode 0600). +### Check for Updates… + +cix looks for a newer `mac/v*` release when it starts and at most every 30 +minutes after that, and only speaks up when there is one. The menu item does the +same check immediately. + +Accepting downloads the disk image and its `checksums.txt`, verifies the +SHA-256, copies the application out to a staging directory beside the installed +one, and only then replaces it. Anything that fails before that point leaves the +installed app untouched. The server is stopped first — its executable is inside +the bundle being replaced, and macOS kills a process whose signed binary is +swapped underneath it — and started again afterwards if it had been running. + +The whole `.app` is replaced, never individual files. The three binaries inside +are built and tested as one thing, and the bundle's signature seals all of them, +so replacing one would both create an untested combination and break +verification. It is also why updating the app updates the `cix` command: the +one on your `PATH` is a symlink into the bundle. + +If the folder containing cix.app is not writable by you, the update stops before +downloading anything and tells you to install the new version by hand. It will +not ask for an administrator password — an unsigned app requesting admin rights +to overwrite itself is exactly what malware looks like, and it is not a habit +worth teaching. + +> The checksum proves the download arrived intact. It is not a trust anchor: +> `checksums.txt` travels the same path as the image, so anyone who can replace +> one can replace the other. Without a Developer ID signature there is nothing +> stronger available here — a detached signature over the checksums would be +> the next step. + ### Allow Network Access Off, the server binds to `127.0.0.1` and only this Mac can reach it. On, it From ed04087faeffd0fdb84f20d0b5b60941a61e27ab Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 16:52:43 +0100 Subject: [PATCH 10/12] fix(server): re-derive the llama sidecar paths on every boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cix-server persists its embedding provider config on first boot and treats the stored blob as authoritative from then on. Two of the ollama fields in it are not choices anyone made — they are derived from the running process: bin_dir filepath.Dir(os.Executable())/llama socket_path /cix-llama-.sock Frozen at first boot, bin_dir names the directory the server was installed in when the row was written. Move or upgrade the installation and every later boot keeps launching the *old* llama-server, until that directory is deleted — at which point embeddings stop working with no configuration having changed. A frozen socket_path defeats the uniqueness it exists for: a new server can find an orphaned llama-server already bound to that name and talk to it instead of spawning its own. Both fields are already documented as deployment-level and are deliberately absent from the dashboard's edit schema (the ollama factory's SchemaJSON). This makes that true on the second boot as well as the first. Everything a person can choose — the model, the context size, the GPU layers — still comes from the database untouched. The refreshed blob is not written back: the stored row stays the record of what was chosen. Found while splitting the macOS app's runtime into versioned directories, where it is fatal — the app would keep running the previous release's sidecar and break outright once that version was pruned. It is not macOS-specific: the same applies to a container whose image layout changes. Co-Authored-By: Claude Opus 5 --- server/cmd/cix-server/main.go | 16 ++++ server/internal/embeddings/service.go | 37 +++++++++ .../internal/embeddings/sidecar_paths_test.go | 78 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 server/internal/embeddings/sidecar_paths_test.go diff --git a/server/cmd/cix-server/main.go b/server/cmd/cix-server/main.go index 9f6dbae9..ef808fc5 100644 --- a/server/cmd/cix-server/main.go +++ b/server/cmd/cix-server/main.go @@ -248,6 +248,22 @@ func run() error { hasProv = true } + // Two fields of a persisted ollama config describe where this process is + // and what pid it has, not what the user chose, and both were frozen at + // first boot. Re-derive them — see RefreshOllamaSidecarPaths for what goes + // wrong otherwise, which is an installation that keeps launching a + // llama-server from a directory that no longer exists. + if hasProv && persistedProv.Kind == provider.KindOllama { + refreshed, rerr := embeddings.RefreshOllamaSidecarPaths(cfg, persistedProv.Config) + if rerr != nil { + // Malformed JSON, which the Build below will report properly. Not + // worth failing here over. + logger.Warn("could not refresh the embedding sidecar paths; using the stored config unchanged", "err", rerr) + } else { + persistedProv.Config = refreshed + } + } + var embedSvc *embeddings.Service if !cfg.EmbeddingsEnabled || !hasProv { // Legacy / disabled path — fall back to env-only ollama wiring. diff --git a/server/internal/embeddings/service.go b/server/internal/embeddings/service.go index 13d7060a..7fe8927c 100644 --- a/server/internal/embeddings/service.go +++ b/server/internal/embeddings/service.go @@ -189,6 +189,43 @@ func BuildOllamaConfigFromEnv(cfg *config.Config) ([]byte, error) { return json.Marshal(c) } +// RefreshOllamaSidecarPaths re-derives the two ollama config fields that +// describe THIS process rather than the user's choices, and returns the updated +// blob. +// +// The persisted blob is authoritative for everything a person can choose — the +// model, the context size, the GPU layers — and that must not change. It cannot +// be authoritative for these two: +// +// - bin_dir comes from filepath.Dir(os.Executable())/llama, so it names the +// directory the server was installed in when the row was first written. +// Move or upgrade the installation and every later boot keeps launching the +// old llama-server — until that directory is deleted, at which point +// embeddings stop working with no configuration having changed. The macOS +// app, which installs each release into its own versioned directory, hits +// this on the first update. +// - socket_path is /cix-llama-.sock, and the pid is the one from +// first boot. Persisting it defeats the uniqueness it exists for: every +// later boot reuses that one name, so a server can find an orphaned +// llama-server already bound to it and talk to that instead of spawning its +// own. +// +// Both are already documented as deployment-level and are deliberately absent +// from the dashboard's edit schema (see the ollama factory's SchemaJSON) — this +// is what makes that true on the second boot as well as the first. +// +// The result is not written back to the database. The stored row stays the +// record of what was chosen; this is only what the running process uses. +func RefreshOllamaSidecarPaths(cfg *config.Config, blob []byte) ([]byte, error) { + var c ollama.Config + if err := json.Unmarshal(blob, &c); err != nil { + return nil, err + } + c.BinDir = cfg.LlamaBinDir + c.SocketPath = cfg.LlamaSocketPath + return json.Marshal(c) +} + // EnvSecrets returns the production SecretLookup: os.LookupEnv. main.go // and the admin handlers pass it to provider.Build / Service.SwitchProvider. func EnvSecrets() provider.SecretLookup { return envSecrets } diff --git a/server/internal/embeddings/sidecar_paths_test.go b/server/internal/embeddings/sidecar_paths_test.go new file mode 100644 index 00000000..69efc6df --- /dev/null +++ b/server/internal/embeddings/sidecar_paths_test.go @@ -0,0 +1,78 @@ +package embeddings + +import ( + "encoding/json" + "testing" + + "github.com/dvcdsys/code-index/server/internal/config" + "github.com/dvcdsys/code-index/server/internal/embeddings/provider/ollama" +) + +// The persisted ollama blob froze two values at first boot that describe the +// installation rather than the user's choices. Re-deriving them is what keeps +// an upgraded or relocated install pointing at the llama-server it actually +// shipped with — and what stops a second server adopting an orphaned sidecar +// through a recycled socket name. +func TestRefreshOllamaSidecarPaths(t *testing.T) { + stored, err := json.Marshal(ollama.Config{ + Model: "awhiteside/CodeRankEmbed-Q8_0-GGUF", + BinDir: "/Users/someone/.cix/runtime/0.5.0/llama", + SocketPath: "/tmp/cix-llama-4242.sock", + Transport: "unix", + CacheDir: "/Users/someone/Library/Caches/cix/models", + CtxSize: 4096, + NGpuLayers: -1, + NThreads: 7, + BatchSize: 2048, + CacheRAMMiB: 0, + StartupSec: 300, + }) + if err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + LlamaBinDir: "/Users/someone/.cix/runtime/0.6.0/llama", + LlamaSocketPath: "/tmp/cix-llama-9001.sock", + } + + out, err := RefreshOllamaSidecarPaths(cfg, stored) + if err != nil { + t.Fatal(err) + } + + var got ollama.Config + if err := json.Unmarshal(out, &got); err != nil { + t.Fatal(err) + } + + if got.BinDir != cfg.LlamaBinDir { + t.Errorf("bin_dir = %q, want %q", got.BinDir, cfg.LlamaBinDir) + } + if got.SocketPath != cfg.LlamaSocketPath { + t.Errorf("socket_path = %q, want %q", got.SocketPath, cfg.LlamaSocketPath) + } + + // Everything a person can choose has to survive untouched. Overwriting the + // model would silently change the embedding identity and force a reindex of + // every project; overwriting the tuning would discard dashboard edits on + // every boot. + if got.Model != "awhiteside/CodeRankEmbed-Q8_0-GGUF" { + t.Errorf("model = %q, want it unchanged", got.Model) + } + if got.CtxSize != 4096 || got.NGpuLayers != -1 || got.NThreads != 7 || got.BatchSize != 2048 { + t.Errorf("tuning changed: %+v", got) + } + if got.Transport != "unix" || got.CacheDir != "/Users/someone/Library/Caches/cix/models" { + t.Errorf("transport/cache_dir changed: transport=%q cache_dir=%q", got.Transport, got.CacheDir) + } + if got.StartupSec != 300 { + t.Errorf("startup_sec = %d, want 300", got.StartupSec) + } +} + +func TestRefreshOllamaSidecarPathsRejectsMalformedBlob(t *testing.T) { + if _, err := RefreshOllamaSidecarPaths(&config.Config{}, []byte("not json")); err == nil { + t.Fatal("RefreshOllamaSidecarPaths accepted a malformed blob") + } +} From fbc65082eb92d39e27ffc9286bc3f9428910a570 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 16:53:03 +0100 Subject: [PATCH 11/12] feat(mac): install the server outside the app, update it in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app carried everything: launcher, cix-server, the cix CLI and a Metal llama-server, 102 MB in one bundle. Every server update therefore replaced the whole application through a trampoline that had to quit the launcher, move the bundle aside, move the new one in, and reopen. That machinery existed only because the server lived inside the app. The Mac now gets two things, released separately because they are separate things: cix--arm64.dmg mac/v* the app 3.9 MB (was 41) cix-runtime--darwin-arm64.tar.gz server/v* the server 35 MB The runtime IS the server, so it carries the server's version and ships from the server's tag — the same server/vX.Y.Z and the same workflow run that publishes the Docker images. A Mac install on 0.12.8 and a container on 0.12.8 are the same server, and a llama bump remains what it has always been: a server release. release-server.yml grows a macos-runtime job on macos-latest and attaches the tarball plus a checksums.txt to the release; release-mac.yml now builds only the app, and no longer needs a server/v* or cli/v* tag reachable because nothing in it is stamped from one. The runtime installs into ~/.cix/runtime// with a `current` symlink. Updating it is a download and a rename: the app stays open, the launchd wrapper needs no rewrite because it execs the stable `current` path, and the version just replaced is kept. If the new server exits instead of starting, the symlink goes back and the old one is restarted, with no download and no prompt. "Exits" is the test, not "did not answer /health" — a cold start loads an embedding model and can take minutes. The app and the server update independently, on their own schedules, and neither waits for the other: a server release reaches a Mac the day it reaches Docker Hub. The launcher watches both streams, with an ETag each. The CLI travels with the server because it speaks a specific server's API, so /usr/local/bin/cix becomes a symlink into ~/.cix/runtime/current and follows updates without /usr/local being touched. llama travels with the server for the same reason it always has: cix-server resolves its sidecar next to its own executable, so shipping them apart would mean carrying CIX_LLAMA_BIN_DIR forever. Two things fell out of the split rather than being designed in: - An app update no longer stops the server. Nothing a running server touches is inside the bundle any more, so updating cix no longer interrupts indexing — and the prefs flag that carried "restart it afterwards" across the swap is gone. - Info.plist no longer records CIXServerVersion/CIXCLIVersion/ CIXLlamaVersion. With the runtime outside, those would be claims about somebody else's files, wrong the first time either half updates alone. build-runtime.sh round-trips its own tarball before returning: extract, codesign --verify --strict every Mach-O, check llama-server's @rpath dependencies, and actually exec the server. That check is in the script so local builds get it too, and because the failure it catches is silent — a signature the kernel rejects is SIGKILL with empty stderr. It also refuses to ship a payload whose server reports a different version from its label, which is what a stale server/dist produces. Verified on an M3 Max: fresh install, launchd wiring, a live 0.5.0 → 0.6.0 → 0.7.0 runtime update with the server restarting on the new one each time, and a deliberate rollback from a runtime that installs cleanly and exits on exec. Unit tests cover the tar containment checks, the execute bit, the symlink swap, pruning, and the full download → checksum → unpack → verify path against a served release. Co-Authored-By: Claude Opus 5 --- .github/workflows/release-mac.yml | 106 ++--- .github/workflows/release-server.yml | 117 +++++- cli/internal/release/release.go | 38 +- cli/launcher/bundle_darwin.go | 18 +- cli/launcher/firstrun_darwin.go | 15 +- cli/launcher/launchd_darwin.go | 28 +- cli/launcher/main_darwin.go | 93 ++++- cli/launcher/menu_darwin.go | 107 ++--- cli/launcher/prefs_darwin.go | 23 +- cli/launcher/resetpw_darwin.go | 32 +- cli/launcher/runtime_darwin.go | 568 +++++++++++++++++++++++++++ cli/launcher/runtime_darwin_test.go | 468 ++++++++++++++++++++++ cli/launcher/takeover_darwin.go | 16 +- cli/launcher/update_darwin.go | 303 ++++++++++---- doc/MACOS_APP.md | 164 +++++--- mac/Info.plist.in | 12 +- mac/README.md | 159 ++++++-- mac/scripts/build-app.sh | 112 ++---- mac/scripts/build-runtime.sh | 204 ++++++++++ mac/scripts/common.sh | 100 +++++ mac/scripts/sign-app.sh | 56 ++- 21 files changed, 2275 insertions(+), 464 deletions(-) create mode 100644 cli/launcher/runtime_darwin.go create mode 100644 cli/launcher/runtime_darwin_test.go create mode 100755 mac/scripts/build-runtime.sh create mode 100644 mac/scripts/common.sh diff --git a/.github/workflows/release-mac.yml b/.github/workflows/release-mac.yml index 0dee01fc..d9f1557d 100644 --- a/.github/workflows/release-mac.yml +++ b/.github/workflows/release-mac.yml @@ -1,14 +1,17 @@ name: "Release: macOS app" # Triggered by macOS-app tags (e.g. `mac/v0.1.0`). This is a third, independent -# tag stream alongside `server/v*` and `cli/v*`: the .app is versioned by what -# the app itself does, not by the versions of the two binaries it happens to -# bundle. Those are stamped separately (see the "Resolve versions" step) and -# recorded in the bundle's Info.plist. +# tag stream alongside `server/v*` and `cli/v*`, and it releases exactly one +# thing: the menu bar app, ~4 MB, holding a single executable. # -# Cut these tags on `main`. `git describe` walks ancestors, so a tag cut on -# develop resolves to whatever server/v* is reachable from there — which on this -# repo has been several releases behind what actually shipped. +# The server it manages is NOT built here. It ships from the server/v* tag +# stream — the same tag and the same workflow run that publishes the Docker +# images (see release-server.yml's macos-runtime job) — so a Mac install and a +# container on the same version are the same server. cix.app downloads it into +# ~/.cix/runtime/ and updates it independently of itself. +# +# That means this workflow no longer needs a server/v* or cli/v* tag to be +# reachable: nothing here is stamped with them. on: push: tags: @@ -37,11 +40,8 @@ jobs: uses: actions/checkout@v7 with: ref: ${{ github.event.inputs.ref || github.ref }} - # Full history: the server and CLI versions come from `git describe` - # against tags that are not the tag being built. - fetch-depth: 0 - - name: Resolve versions + - name: Resolve version id: ver run: | set -euo pipefail @@ -51,51 +51,19 @@ jobs: else MAC_VERSION="${GITHUB_REF_NAME#mac/v}" fi - - SERVER_TAG="$(git describe --tags --match 'server/v*' --abbrev=0 2>/dev/null || true)" - CLI_TAG="$(git describe --tags --match 'cli/v*' --abbrev=0 2>/dev/null || true)" - - # Guard, not a nicety. Without a reachable tag on each stream the - # binaries would ship stamped 0.0.0-dev, which is indistinguishable - # from a local developer build and useless in a bug report. - if [ -z "$SERVER_TAG" ] || [ -z "$CLI_TAG" ]; then - echo "::error title=Unreleasable commit::A mac/v* tag must be cut from a commit with both a server/v* and a cli/v* tag reachable. Found server='${SERVER_TAG:-none}' cli='${CLI_TAG:-none}'. Cut the tag on main." - exit 1 - fi - - { - echo "mac=$MAC_VERSION" - echo "server=${SERVER_TAG#server/v}" - echo "cli=${CLI_TAG#cli/v}" - } >> "$GITHUB_OUTPUT" - - echo "app=$MAC_VERSION server=$SERVER_TAG cli=$CLI_TAG" + echo "mac=$MAC_VERSION" >> "$GITHUB_OUTPUT" + echo "app=$MAC_VERSION" - name: Set up Go uses: actions/setup-go@v7 with: - # The server module has the higher `go` directive of the two, and a - # newer toolchain builds the CLI module fine. One install, both builds. - go-version-file: server/go.mod - cache-dependency-path: | - server/go.sum - cli/go.sum - - - name: Set up Node - # The server binary embeds the built dashboard via go:embed, so a - # release build needs the frontend toolchain even though nothing about - # the .app is JavaScript. - uses: actions/setup-node@v7 - with: - node-version: 22 - cache: npm - cache-dependency-path: server/dashboard/package-lock.json + # Only the CLI module is built here — the launcher lives in it. + go-version-file: cli/go.mod + cache-dependency-path: cli/go.sum - name: Build cix.app env: MAC_VERSION: ${{ steps.ver.outputs.mac }} - SERVER_VERSION: ${{ steps.ver.outputs.server }} - CLI_VERSION: ${{ steps.ver.outputs.cli }} run: mac/scripts/build-app.sh - name: Verify the bundle @@ -108,22 +76,19 @@ jobs: # sealed resources drifted from what is on disk. codesign --verify --strict --verbose=2 "$APP" - # Each binary reports its own version. This is the check that catches - # a bundle assembled from stale server/dist artefacts. + # Reports the launcher's own version, and that no runtime is installed + # — correct on a build machine, where the two halves have not met yet. "$APP/Contents/MacOS/cix-launcher" -report - # Every @rpath dependency of llama-server must be present. Missing - # ones only fail at dyld load time, on the user's machine, with an - # abort — which is how the b10238 library-layout change was found. - missing=0 - for dep in $(otool -L "$APP/Contents/MacOS/llama/llama-server" \ - | awk '/@rpath\//{sub(/^.*@rpath\//,"",$1); print $1}'); do - if [ ! -e "$APP/Contents/MacOS/llama/$dep" ]; then - echo "::error title=Broken bundle::llama-server dependency not bundled: $dep" - missing=1 - fi - done - [ "$missing" -eq 0 ] + # The app must contain exactly one executable. A leftover cix-server + # or llama/ from a pre-split build would be sealed into the signature + # and shipped as ~90 MB nobody uses. + found="$(find "$APP/Contents/MacOS" -type f | wc -l | tr -d ' ')" + if [ "$found" != "1" ]; then + echo "::error title=Unexpected bundle contents::Contents/MacOS holds $found files, expected only cix-launcher" + find "$APP/Contents/MacOS" -type f + exit 1 + fi - name: Build DMG env: @@ -132,9 +97,8 @@ jobs: - name: Compute checksums working-directory: mac/dist - # The self-updater (a later release) verifies a downloaded DMG against - # this file. Without a Developer ID signature it is the only integrity - # check there is, so it ships from the first release onward. + # The updater verifies the download against this file. Without a + # Developer ID signature it is the only integrity check there is. run: shasum -a 256 ./*.dmg > checksums.txt - name: Upload artifacts @@ -161,9 +125,9 @@ jobs: body: | ## cix for macOS — Apple Silicon - Bundles `cix-server` ${{ steps.ver.outputs.server }}, the `cix` CLI - ${{ steps.ver.outputs.cli }}, and a Metal-accelerated `llama-server` - for local embeddings. Everything runs on your machine. + A menu bar app for a local cix server: start and stop it, see what + its embedding provider is doing, and open the dashboard. Everything + runs on your machine. ### Install @@ -171,6 +135,12 @@ jobs: 2. Drag **cix.app** onto **Applications**. 3. Open it from Applications. + On first launch the app downloads the server itself — `cix-server`, + the `cix` CLI and a Metal-accelerated `llama-server`, about 40 MB — + from the newest [server release](https://github.com/dvcdsys/code-index/releases?q=server%2Fv). + That is the same build the Docker images are cut from, and it + updates on its own schedule: a new server does not need a new app. + ### First launch will be blocked — this is expected cix is open source and is **not** signed with a paid Apple Developer diff --git a/.github/workflows/release-server.yml b/.github/workflows/release-server.yml index c981fd67..f4d89202 100644 --- a/.github/workflows/release-server.yml +++ b/.github/workflows/release-server.yml @@ -156,6 +156,88 @@ jobs: openapi=doc tags: ${{ steps.tags.outputs.tags }} + macos-runtime: + name: Build macOS runtime (arm64) + # The same server, packaged for a Mac instead of a container: cix-server, + # the cix CLI and a Metal llama-server, installed by cix.app into + # ~/.cix/runtime//. It ships from THIS tag, not from mac/v*, so a + # Mac and a container on the same version are running the same server. + # + # macos-latest is arm64, and it has to be: upstream llama.cpp publishes one + # macOS asset, macos-arm64, so there is no x86_64 build to make. + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ github.event.inputs.ref || github.ref }} + # Full history: the CLI version comes from `git describe` against a + # tag that is not the tag being built. + fetch-depth: 0 + + - name: Resolve versions + id: ver + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + SERVER_VERSION="${{ github.event.inputs.version }}" + else + SERVER_VERSION="${GITHUB_REF_NAME#server/}" + fi + # The Docker tags are v-prefixed; the runtime version is not. It + # becomes a directory name under ~/.cix/runtime/ and is compared + # against the tag stream with the prefix already stripped. + SERVER_VERSION="${SERVER_VERSION#v}" + + CLI_TAG="$(git describe --tags --match 'cli/v*' --abbrev=0 2>/dev/null || true)" + if [ -z "$CLI_TAG" ]; then + echo "::error title=No CLI version::The runtime bundles the cix CLI, and no cli/v* tag is reachable from this commit — it would ship stamped 0.0.0-dev. Cut server/v* tags on main." + exit 1 + fi + + { + echo "server=$SERVER_VERSION" + echo "cli=${CLI_TAG#cli/v}" + } >> "$GITHUB_OUTPUT" + + echo "server=$SERVER_VERSION cli=$CLI_TAG" + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: server/go.mod + cache-dependency-path: | + server/go.sum + cli/go.sum + + - name: Set up Node + # The server binary embeds the built dashboard via go:embed, so this + # needs the frontend toolchain even though nothing here is JavaScript. + uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + cache-dependency-path: server/dashboard/package-lock.json + + - name: Build the runtime + env: + SERVER_VERSION: ${{ steps.ver.outputs.server }} + CLI_VERSION: ${{ steps.ver.outputs.cli }} + # The script round-trips the tarball it writes before returning: + # extract, codesign --verify --strict every Mach-O, check llama-server's + # @rpath dependencies, and exec the server. That check is in the script + # rather than here so local builds get it too, and because the failure + # it catches is silent — a signature the kernel rejects is SIGKILL with + # empty stderr. + run: mac/scripts/build-runtime.sh + + - name: Upload the runtime + uses: actions/upload-artifact@v7 + with: + name: cix-runtime-darwin-arm64 + path: mac/dist/*.tar.gz + prune: name: Prune stale Docker Hub tags needs: [docker-cpu, docker-cuda] @@ -178,7 +260,10 @@ jobs: release: name: Publish release - needs: [docker-cpu, docker-cuda] + # macos-runtime is a hard dependency, not a nicety: cix.app installs its + # server from this release's assets, so a release published without the + # runtime attached is one no Mac can install or update to. + needs: [docker-cpu, docker-cuda, macos-runtime] runs-on: ubuntu-latest # Skip GitHub release creation on manual rebuilds — the release # already exists for the version being rebuilt. @@ -191,11 +276,30 @@ jobs: id: ver run: echo "version=${GITHUB_REF_NAME#server/}" >> "$GITHUB_OUTPUT" + - name: Download the macOS runtime + uses: actions/download-artifact@v8 + with: + name: cix-runtime-darwin-arm64 + path: dist + + - name: Compute checksums + working-directory: dist + # cix.app verifies its download against this file. Without a Developer + # ID signature it is the only integrity check there is. + # + # sha256sum, not shasum: this job runs on ubuntu, and that is what + # release-cli.yml uses there. The macOS jobs use shasum because macOS + # ships no sha256sum. Same output format either way. + run: sha256sum ./*.tar.gz > checksums.txt + - name: Create GitHub release uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: name: "Server ${{ steps.ver.outputs.version }}" generate_release_notes: true + files: | + dist/*.tar.gz + dist/checksums.txt body: | ## Docker Images @@ -204,6 +308,17 @@ jobs: | CPU (multi-arch) | `dvcdsys/code-index:${{ steps.ver.outputs.version }}` | | CUDA 12.8 | `dvcdsys/code-index:${{ steps.ver.outputs.version }}-cu128` | + ## macOS (Apple Silicon) + + The same server for a Mac, installed and managed by + [cix.app](https://github.com/dvcdsys/code-index/releases?q=mac%2Fv). + The app downloads it on its own — the tarball is attached so the + checksums cover it, not because it needs downloading by hand. + + | Asset | Contents | + |---|---| + | `cix-runtime-*-darwin-arm64.tar.gz` | `cix-server`, the `cix` CLI, Metal `llama-server` | + site-version: name: Check the site advertises this release needs: [docker-cpu, docker-cuda] diff --git a/cli/internal/release/release.go b/cli/internal/release/release.go index 4ad0b0bb..34f849e9 100644 --- a/cli/internal/release/release.go +++ b/cli/internal/release/release.go @@ -90,10 +90,27 @@ var ErrNotModified = fmt.Errorf("not modified") // Returns a zero Release and no error when the stream has no releases yet — // that is a normal state for a new tag stream, not a failure. func (c *Client) Latest(ctx context.Context) (Release, error) { + releases, err := c.list(ctx) + if err != nil { + return Release{}, err + } + + var best Release + for _, rel := range releases { + if best.Version != "" && CompareSemver(rel.Version, best.Version) <= 0 { + continue + } + best = rel + } + return best, nil +} + +// list fetches the published releases of this stream, newest page first. +func (c *Client) list(ctx context.Context) ([]Release, error) { url := fmt.Sprintf("%s/repos/%s/releases?per_page=30", c.BaseURL, c.Repo) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - return Release{}, err + return nil, err } req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("X-GitHub-Api-Version", "2022-11-28") @@ -103,21 +120,21 @@ func (c *Client) Latest(ctx context.Context) (Release, error) { resp, err := c.HTTP.Do(req) if err != nil { - return Release{}, err + return nil, err } defer resp.Body.Close() switch resp.StatusCode { case http.StatusNotModified: - return Release{}, ErrNotModified + return nil, ErrNotModified case http.StatusOK: case http.StatusForbidden, http.StatusTooManyRequests: // Distinguished from a generic failure because it is self-healing and // the caller should stay quiet about it rather than alarm the user. - return Release{}, fmt.Errorf("github rate limit reached (resets hourly)") + return nil, fmt.Errorf("github rate limit reached (resets hourly)") default: body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return Release{}, fmt.Errorf("github returned %s: %s", resp.Status, strings.TrimSpace(string(body))) + return nil, fmt.Errorf("github returned %s: %s", resp.Status, strings.TrimSpace(string(body))) } var raw []struct { @@ -132,11 +149,11 @@ func (c *Client) Latest(ctx context.Context) (Release, error) { } `json:"assets"` } if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&raw); err != nil { - return Release{}, fmt.Errorf("decode releases: %w", err) + return nil, fmt.Errorf("decode releases: %w", err) } c.ETag = resp.Header.Get("ETag") - var best Release + var out []Release for _, r := range raw { if r.Draft || r.Prerelease { continue @@ -150,14 +167,11 @@ func (c *Client) Latest(ctx context.Context) (Release, error) { if strings.ContainsAny(version, "-+") { continue } - if best.Version != "" && CompareSemver(version, best.Version) <= 0 { - continue - } rel := Release{Version: version, TagName: r.TagName, HTMLURL: r.HTMLURL} for _, a := range r.Assets { rel.Assets = append(rel.Assets, Asset{Name: a.Name, URL: a.URL, Size: a.Size}) } - best = rel + out = append(out, rel) } - return best, nil + return out, nil } diff --git a/cli/launcher/bundle_darwin.go b/cli/launcher/bundle_darwin.go index 4baae218..ba3824cc 100644 --- a/cli/launcher/bundle_darwin.go +++ b/cli/launcher/bundle_darwin.go @@ -12,22 +12,17 @@ import ( // bundle describes the .app this launcher is running from. // -// The layout is fixed by mac/scripts/build-app.sh and by one hard runtime -// constraint: cix-server resolves its llama-server via -// filepath.Dir(os.Executable())/llama, so llama/ must sit next to cix-server, -// which means every executable lives in Contents/MacOS/ — Resources/ is not an -// option (codesign --verify --strict rejects executables there). +// The .app contains the launcher and nothing else that runs. cix-server, the +// cix CLI and llama-server live in ~/.cix/runtime/ — see runtime_darwin.go for +// why they were moved out and how they are managed. // // cix.app/Contents/ -// MacOS/ cix-launcher cix-server cix llama/{llama-server,*.dylib} -// Resources/ AppIcon.icns menubar.png menubar@2x.png +// MacOS/ cix-launcher +// Resources/ cix.icns cixTemplate.png cixTemplate@2x.png type bundle struct { Root string // …/cix.app MacOS string // …/cix.app/Contents/MacOS Resources string // …/cix.app/Contents/Resources - Server string // …/Contents/MacOS/cix-server - CLI string // …/Contents/MacOS/cix - LlamaDir string // …/Contents/MacOS/llama } // errNotBundled is returned when the launcher runs from a plain directory @@ -60,9 +55,6 @@ func locateBundle() (bundle, error) { Root: root, MacOS: macOS, Resources: filepath.Join(contents, "Resources"), - Server: filepath.Join(macOS, "cix-server"), - CLI: filepath.Join(macOS, "cix"), - LlamaDir: filepath.Join(macOS, "llama"), }, nil } diff --git a/cli/launcher/firstrun_darwin.go b/cli/launcher/firstrun_darwin.go index b8dee140..1879567e 100644 --- a/cli/launcher/firstrun_darwin.go +++ b/cli/launcher/firstrun_darwin.go @@ -42,10 +42,11 @@ func needsFirstRun() bool { // // Returns errCancelled if the user backs out, which is not an error condition — // the app stays running with Start disabled until they complete setup. -func runFirstRun(b bundle) error { +func runFirstRun(u *updater) error { intro := "cix needs an administrator account before it can start.\n\n" + "Enter the email address to sign in with. A password will be generated for you, " + - "and you will be asked to change it the first time you log in." + "and you will be asked to change it the first time you log in.\n\n" + + "Setup then downloads the cix server itself — around 40 MB — which takes a moment." email, err := prompt("Set up cix", intro, "") if err != nil { @@ -63,6 +64,14 @@ func runFirstRun(b bundle) error { email = strings.TrimSpace(email) } + // Before anything is written: the runtime is the one step here that can fail + // for reasons outside this machine, and a setup that wrote server.env and a + // launchd agent pointing at a server that was never downloaded would look + // complete and be broken. + if err := ensureRuntime(u, logProgress); err != nil { + return fmt.Errorf("could not install the cix server: %w", err) + } + password, err := generatePassword() if err != nil { return fmt.Errorf("generate password: %w", err) @@ -105,7 +114,7 @@ func runFirstRun(b bundle) error { return fmt.Errorf("write server.env: %w", err) } - if err := writeLaunchdFiles(b, false); err != nil { + if err := writeLaunchdFiles(false); err != nil { return fmt.Errorf("write launchd files: %w", err) } if err := startServer(); err != nil { diff --git a/cli/launcher/launchd_darwin.go b/cli/launcher/launchd_darwin.go index 1d2e72a6..7d92be54 100644 --- a/cli/launcher/launchd_darwin.go +++ b/cli/launcher/launchd_darwin.go @@ -170,11 +170,17 @@ func stopServer() error { return nil } -// writeLaunchdFiles regenerates the wrapper and the plist for this bundle. +// writeLaunchdFiles regenerates the wrapper and the plist. // -// Called on every launch, so an app that was moved, or replaced by an update, -// re-points the agent at itself without the user doing anything. -func writeLaunchdFiles(b bundle, runAtLoad bool) error { +// Called on every launch. It no longer has to chase a moving bundle — the +// wrapper execs ~/.cix/runtime/current/cix-server, a path that survives both an +// app that was moved and a runtime that was updated — but it is still what puts +// the files back if something else overwrote them. +func writeLaunchdFiles(runAtLoad bool) error { + server, err := runtimeServerPath() + if err != nil { + return err + } wrapper, err := wrapperPath() if err != nil { return err @@ -202,7 +208,13 @@ func writeLaunchdFiles(b bundle, runAtLoad bool) error { // static: launchd only re-reads a plist at bootstrap, but the wrapper is // read afresh on every start, so editing server.env and restarting is // enough. CIX_LLAMA_BIN_DIR is not set — cix-server resolves llama/ next to - // its own executable, and inside the bundle that is already correct. + // its own executable, and inside the runtime directory that is already + // correct, through the symlink or resolved past it. + // + // It execs the `current` symlink rather than the versioned directory behind + // it. That is the whole point of the symlink: a runtime update swaps where + // it points and this file does not change, so no plist rewrite and no + // bootout/bootstrap cycle is needed to pick up a new server. script := fmt.Sprintf(`#!/usr/bin/env bash %s # Generated by cix.app — launchd entry point for cix-server. @@ -212,7 +224,7 @@ set -a source %q set +a exec %q -`, managedByMarker, envPath, b.Server) +`, managedByMarker, envPath, server) if err := os.WriteFile(wrapper, []byte(script), 0o755); err != nil { return err @@ -286,8 +298,8 @@ func autostartEnabled() bool { // // Rebootstrapping restarts a running server. That is why the caller restores // the process afterwards rather than this function pretending the cost is zero. -func setAutostart(b bundle, enabled bool) error { - if err := writeLaunchdFiles(b, enabled); err != nil { +func setAutostart(enabled bool) error { + if err := writeLaunchdFiles(enabled); err != nil { return err } return launchdBootstrap() diff --git a/cli/launcher/main_darwin.go b/cli/launcher/main_darwin.go index 7cacd297..d8b2c880 100644 --- a/cli/launcher/main_darwin.go +++ b/cli/launcher/main_darwin.go @@ -45,6 +45,11 @@ func main() { return } + // One updater for the process: it owns the cached ETag and the release + // client, and both the runtime installer and the menu's update check need + // them. Two of these would mean two ETags racing over one preferences file. + u := newUpdater(b) + if isTranslocated(b) { // Gatekeeper is running the app from a randomised read-only copy, which // it does to any quarantined app opened from outside /Applications. @@ -67,10 +72,16 @@ func main() { // Asks once, remembers the answer, and defaults to leaving it alone. // When the user declines, the app stays in observe-only mode: status // and the dashboard work, Start/Stop do not. - handleForeignAgent(b) + // + // No runtime is installed on this path. Observing somebody else's server + // needs no binaries of our own, and downloading 90 MB to watch an + // install the user asked us not to touch would be presumptuous. The one + // feature that does need it — password reset — offers the download when + // it is used. + handleForeignAgent(u) case needsFirstRun(): - if err := runFirstRun(b); err != nil { + if err := runFirstRun(u); err != nil { if errors.Is(err, errCancelled) { // Setup is resumable: the app stays in the menu bar with Start // disabled, and the next launch offers the wizard again. @@ -84,22 +95,46 @@ func main() { } default: - // Re-point the launchd agent at this bundle, preserving the user's - // autostart choice. An app that was moved, or replaced by an update, - // would otherwise keep a job aimed at a path that no longer holds the - // binary. - if err := writeLaunchdFiles(b, autostartEnabled()); err != nil { + // A configured install with no runtime is the first launch after + // upgrading from a version that carried its server inside the bundle: + // the old app is gone, and with it the binary the launchd wrapper was + // pointing at. Say so before spending a minute on a download, because + // otherwise this is a menu bar app that appears to do nothing at all. + // + // Not said when a local tarball is supplied: there is no download to + // warn about, and this is the path a developer takes on every build. + if !runtimeReady() && os.Getenv("CIX_RUNTIME_TARBALL") == "" { + _ = alert("cix needs to finish updating", + "cix now keeps its server outside the application, so it updates without restarting.\n\n"+ + "It will download that part now — around 40 MB, once. The menu bar icon appears when it is done.") + } + if err := ensureRuntime(u, logProgress); err != nil { + logf("could not install the runtime: %v", err) + _ = alert("cix could not install its server", + fmt.Sprintf("%v\n\nThe menu bar app still works, but the server cannot start until this succeeds.", err)) + break + } + // Only after the runtime exists: pointing the launchd wrapper at a + // binary that is not there would break a working install rather than + // leave it alone. + // + // Nothing is started here. An app update no longer stops the server — + // the bundle holds none of what it runs — so there is no interrupted + // state to resume, and starting a server the user had deliberately + // stopped would be the app overriding them. + if err := writeLaunchdFiles(autostartEnabled()); err != nil { logf("could not refresh launchd files: %v", err) } - // Ordered after the plist rewrite, not before: an update replaced the - // binary the agent points at, and starting the server first would run - // the previous version's path. - resumeAfterUpdate() } - runMenu(b) + runMenu(b, u) } +// logProgress is the progress sink for work that happens before the menu bar +// item exists. There is nowhere to show it, so it goes in the log — which is +// where anyone investigating a slow first launch will look. +func logProgress(msg string) { logf("%s", msg) } + // stripQuarantine clears com.apple.quarantine from the whole bundle, once. // // Not cosmetic: the nested llama-server inherits the quarantine flag from the @@ -116,21 +151,37 @@ func fileExists(path string) bool { return err == nil } -// bundleReport asks each bundled binary for its own version rather than -// printing what the build script believed it packaged. That difference is the -// point: it catches a bundle assembled from stale dist/ artefacts, which is the -// failure this pipeline is most likely to produce. +// bundleReport prints what this installation actually consists of: the app, and +// whichever runtime it is pointing at. +// +// The runtime half asks the binaries for their own versions rather than +// trusting runtime.json, because the difference is the point — it catches a +// runtime assembled from stale dist/ artefacts, which is the failure this +// pipeline is most likely to produce. CI runs this against a freshly built app, +// where the answer is "no runtime", and that is correct: the two halves of a +// release are built separately and only meet on a user's machine. func bundleReport(b bundle) string { var sb strings.Builder fmt.Fprintf(&sb, "Launcher: cix-launcher %s\n", version) - fmt.Fprintf(&sb, "Server: %s\n", binaryVersion(b.Server, "-v")) - fmt.Fprintf(&sb, "CLI: %s\n", binaryVersion(b.CLI, "--version")) + fmt.Fprintf(&sb, "Bundle: %s\n", b.Root) + + if !runtimeReady() { + sb.WriteString("Runtime: not installed\n") + return sb.String() + } + + dir, _ := runtimeCurrentDir() + fmt.Fprintf(&sb, "Runtime: %s (%s)\n", currentRuntimeVersion(), dir) + fmt.Fprintf(&sb, "Server: %s\n", binaryVersion(filepath.Join(dir, "cix-server"), "-v")) + fmt.Fprintf(&sb, "CLI: %s\n", binaryVersion(filepath.Join(dir, "cix"), "--version")) - if _, err := os.Stat(b.LlamaDir); err == nil { - sb.WriteString("Embeddings: bundled llama-server (Metal)\n") + llama := filepath.Join(dir, "llama") + if _, err := os.Stat(llama); err == nil { + info, _ := readRuntimeInfo() + fmt.Fprintf(&sb, "Embeddings: llama-server %s (Metal)\n", info.LlamaVersion) } else { - fmt.Fprintf(&sb, "Embeddings: MISSING — %s not found\n", b.LlamaDir) + fmt.Fprintf(&sb, "Embeddings: MISSING — %s not found\n", llama) } return sb.String() diff --git a/cli/launcher/menu_darwin.go b/cli/launcher/menu_darwin.go index f29d81dc..4ab0df14 100644 --- a/cli/launcher/menu_darwin.go +++ b/cli/launcher/menu_darwin.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "time" "fyne.io/systray" @@ -47,13 +48,28 @@ type menu struct { // detailRows is the fixed number of submenu slots. Rows with nothing to say are // hidden rather than left blank. -const detailRows = 6 +const detailRows = 7 -func runMenu(b bundle) { - m := &menu{bundle: b, poll: newPoller(), stop: make(chan struct{}), updater: newUpdater(b)} +func runMenu(b bundle, u *updater) { + m := &menu{bundle: b, poll: newPoller(), stop: make(chan struct{}), updater: u} systray.Run(m.onReady, m.onExit) } +// setProgress puts a word next to the menu bar icon while something slow is +// happening, and clears it when passed "". +// +// The alternative was a modal dialog, and it is the wrong shape: a runtime +// download takes tens of seconds, during which a menu bar app that shows nothing +// reads as hung and one that blocks with an alert cannot be dismissed. AppKit +// draws this text beside the template icon, so it is visible without being in +// the way. +func (m *menu) setProgress(msg string) { + systray.SetTitle(msg) + if msg != "" { + logf("%s", msg) + } +} + func (m *menu) onReady() { if icon, err := os.ReadFile(filepath.Join(m.bundle.Resources, "cixTemplate@2x.png")); err == nil { // SetTemplateIcon, not SetIcon: macOS recolours a template image for @@ -237,7 +253,7 @@ func (m *menu) toggleAutostart() { } enable := !s.Autostart - if err := setAutostart(m.bundle, enable); err != nil { + if err := setAutostart(enable); err != nil { _ = alert("cix", fmt.Sprintf("Could not change the start-at-login setting.\n\n%v", err)) m.render(m.poll.snapshotNow()) return @@ -257,27 +273,35 @@ func (m *menu) toggleAutostart() { // it. `explicit` distinguishes the menu item from the background check: a // background check that finds nothing says nothing. func (m *menu) checkForUpdates(explicit bool) { - if isDevBuild() { + av := m.updater.check(explicit) + if !av.any() { if explicit { - _ = alert("cix", "This is a development build, so there is nothing to update it to.\n\n"+ - "Released builds check for updates automatically.") + _ = alert("cix is up to date", fmt.Sprintf( + "You are running cix %s with %s.", displayVersion(), strings.ToLower(runtimeSummary()))) } return } - rel, newer := m.updater.check(explicit) - if !newer { - if explicit { - _ = alert("cix is up to date", fmt.Sprintf("You are running version %s.", version)) - } - return + // The app and the server are separate releases and feel completely + // different to update: the server restarts and the app never goes away, the + // app closes and reopens while the server keeps running. Naming which one + // is happening is the difference between an expected restart and an + // unexplained one. + var what, effect string + switch { + case av.App.Version == "": + what = fmt.Sprintf("cix server %s is available. You are running %s.", av.Runtime.Version, currentRuntimeVersion()) + effect = "The server will be updated and restarted. This app stays open, and if the new server does not start, cix goes back to the current one." + case av.Runtime.Version == "": + what = fmt.Sprintf("cix %s is available. You are running %s.", av.App.Version, displayVersion()) + effect = "cix will close and reopen. The server keeps running throughout." + default: + what = fmt.Sprintf("cix %s and cix server %s are available.", av.App.Version, av.Runtime.Version) + effect = "The server will be updated and restarted, then cix will close and reopen." } - ok, err := ask("A new version of cix is available", - fmt.Sprintf("cix %s is available. You are running %s.\n\n"+ - "The update is downloaded, checked, and installed in place. "+ - "cix will restart, and the server will be stopped and started again with it.", - rel.Version, version), + ok, err := ask("An update is available", + fmt.Sprintf("%s\n\nEverything is downloaded and checked before anything is replaced. %s", what, effect), "Update Now", "Not Now") if err != nil || !ok { return @@ -285,42 +309,31 @@ func (m *menu) checkForUpdates(explicit bool) { // "Is there a process", not "is it answering". A server still loading its // embedding model has a pid and no /health yet; treating that as stopped - // would skip the shutdown and then replace the bundle out from under a live - // cix-server, which macOS answers by killing it. Captured before anything - // is touched, because after the swap this process no longer exists to - // remember it. + // would skip the shutdown and swap the runtime under a live cix-server, + // leaving it with a llama sidecar from a different version. wasRunning := m.poll.snapshotNow().PID != 0 - if err := m.updater.install(rel, wasRunning); err != nil { - logf("update to %s failed: %v", rel.Version, err) + quit, err := m.updater.install(av, wasRunning, m.setProgress) + if err != nil { + logf("update failed: %v", err) _ = alert("Could not install the update", err.Error()) m.poll.refresh() return } + if !quit { + // Server-only update: the app is not going anywhere, so it has to say + // the update finished. The launcher path says nothing here because it + // is about to disappear and reappear, which speaks for itself. + m.poll.refresh() + _ = alert("cix server updated", fmt.Sprintf("The cix server is now running %s.", av.Runtime.Version)) + return + } // From here the swap helper owns the outcome: it waits for this process to // exit before moving anything, so quitting is the last required step. systray.Quit() } -// resumeAfterUpdate restarts the server when the update that just completed -// had stopped a running one. -func resumeAfterUpdate() { - p := loadPrefs() - if !p.RestartServerAfterUpdate { - return - } - p.RestartServerAfterUpdate = false - if err := savePrefs(p); err != nil { - logf("could not clear the post-update restart flag: %v", err) - } - if err := startServer(); err != nil { - logf("could not restart the server after the update: %v", err) - return - } - logf("server restarted after the update") -} - // renderDetail fills the submenu under the server row. Slots with nothing to // say are hidden, so the submenu never shows an empty line. func (m *menu) renderDetail(s snapshot) { @@ -330,6 +343,10 @@ func (m *menu) renderDetail(s snapshot) { s.DetailNetwork(), s.DetailModel(), s.DetailVersion(), + // What is installed, as opposed to what the running server reports. The + // row above is empty whenever the server is not answering — which is + // exactly when someone wants to know which server is on disk. + runtimeSummary(), s.DetailManaged(), } for i, line := range lines { @@ -352,9 +369,9 @@ func (m *menu) toggleServer() { if s.State == stateRunning { err = stopServer() } else { - // Re-point the agent at this bundle before starting. The app may have - // been moved or replaced by an update since the files were written. - if err = writeLaunchdFiles(m.bundle, autostartEnabled()); err == nil { + // Rewrite the wrapper before starting. It is generated, not edited, and + // something else may have replaced it — install-server.sh most obviously. + if err = writeLaunchdFiles(autostartEnabled()); err == nil { err = startServer() } } diff --git a/cli/launcher/prefs_darwin.go b/cli/launcher/prefs_darwin.go index a78a01be..c308e891 100644 --- a/cli/launcher/prefs_darwin.go +++ b/cli/launcher/prefs_darwin.go @@ -20,17 +20,22 @@ type prefs struct { // "keep". Empty means the question has not been asked yet. Takeover string `json:"takeover,omitempty"` - // UpdateETag is GitHub's ETag from the last release listing. Replaying it - // turns an unchanged check into a 304, which does not count against the - // unauthenticated hourly limit — so it is worth surviving a restart. - UpdateETag string `json:"update_etag,omitempty"` - - // RestartServerAfterUpdate carries one bit across the update: the process - // that stops the server is replaced before the one that should start it - // again exists, so the intent has to live on disk in between. - RestartServerAfterUpdate bool `json:"restart_server_after_update,omitempty"` + // UpdateETag and RuntimeETag are GitHub's ETags from the last listing of the + // mac/v* and server/v* streams. Replaying one turns an unchanged check into + // a 304, which does not count against the unauthenticated hourly limit — so + // they are worth surviving a restart. Two streams, two ETags: the app and + // the server it manages are released separately. + UpdateETag string `json:"update_etag,omitempty"` + RuntimeETag string `json:"runtime_etag,omitempty"` } +// There used to be a third field here, restart_server_after_update, carrying one +// bit across a launcher swap: the process that stopped the server was replaced +// before the one that should start it again existed. Moving the runtime out of +// the bundle removed the reason for it — an app update no longer touches the +// server at all, and a runtime update restarts it within a single process. An +// old file that still carries the key is simply ignored. + const ( takeoverAdopt = "takeover" takeoverKeep = "keep" diff --git a/cli/launcher/resetpw_darwin.go b/cli/launcher/resetpw_darwin.go index b28c1b90..df004120 100644 --- a/cli/launcher/resetpw_darwin.go +++ b/cli/launcher/resetpw_darwin.go @@ -38,7 +38,33 @@ func (m *menu) resetPasswordFlow() { return } - password, err := runResetPassword(m.bundle, vars, email) + // The reset runs cix-server against the database directly, so it needs the + // runtime. In observe-only mode — where another installation owns the agent + // and we never installed one — this is the only feature that does, so the + // download is offered here rather than forced at startup. + if !runtimeReady() { + ok, err := confirm("Download the cix server?", + "Resetting a password runs cix-server against the database directly, and it is not "+ + "installed on this Mac yet.\n\nDownloading it is about 40 MB and changes nothing "+ + "about the installation you already have.", + "Download") + if err != nil || !ok { + return + } + if err := ensureRuntime(m.updater, m.setProgress); err != nil { + _ = alert("Could not install the cix server", err.Error()) + return + } + m.setProgress("") + } + + server, err := runtimeServerPath() + if err != nil { + _ = alert("cix", fmt.Sprintf("Could not locate the cix server.\n\n%v", err)) + return + } + + password, err := runResetPassword(server, vars, email) if err != nil { _ = alert("Could not reset the password", err.Error()) return @@ -58,11 +84,11 @@ func (m *menu) resetPasswordFlow() { // at a terminal who already holds the DB file, and indefensible in a GUI alert, // where a typo would enumerate accounts to whoever is looking at the screen. So // the full output goes to the log and the dialog gets one sentence. -func runResetPassword(b bundle, vars map[string]string, email string) (string, error) { +func runResetPassword(server string, vars map[string]string, email string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, b.Server, "-reset-password", email) + cmd := exec.CommandContext(ctx, server, "-reset-password", email) // The subprocess reads config from the environment, so it must see the same // database the server uses. Without these it would fall back to the default diff --git a/cli/launcher/runtime_darwin.go b/cli/launcher/runtime_darwin.go new file mode 100644 index 00000000..924656ad --- /dev/null +++ b/cli/launcher/runtime_darwin.go @@ -0,0 +1,568 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/dvcdsys/code-index/cli/internal/release" +) + +// The cix runtime: cix-server, the cix CLI, and the Metal llama-server they +// depend on. It is not inside the .app. +// +// Layout +// ------ +// +// ~/.cix/runtime/ +// 0.12.8/ cix-server cix llama/… runtime.json +// 0.12.7/ the version this one replaced, kept for rollback +// current -> 0.12.8 +// +// Versioned by the SERVER, from the server/v* tag stream — the same tag and the +// same workflow run that publishes the Docker images. A Mac install on 0.12.8 +// and a container on 0.12.8 are the same server. The app is versioned +// separately, on mac/v*, because it changes for different reasons; the two +// update independently and neither waits for the other. +// +// Why outside the bundle +// ---------------------- +// The runtime is 90% of the weight and nearly all of the churn. With it inside, +// every server update replaced a 102 MB application through a trampoline that +// had to quit the launcher, move the bundle aside, move the new one in, and +// reopen. Out here, an update is a download and a rename, with the app still +// running. +// +// Why a symlink rather than a fixed directory +// ------------------------------------------- +// Renaming a symlink over another is atomic, so there is no window in which +// `current` points at half a runtime — and it is reversible in microseconds, +// which is what makes the automatic rollback below possible without a second +// download. The launchd wrapper execs the `current` path, so a swap needs no +// plist rewrite and no bootout/bootstrap cycle either. +// +// The symlink target is relative (`0.5.0`, not an absolute path) so the tree +// survives being moved with the home directory. + +const ( + runtimeLinkName = "current" + runtimeManifestName = "runtime.json" + + // The directory inside the tarball, and the asset's name. Both are set by + // mac/scripts/build-runtime.sh; they are a format, not a convention. + runtimeDirPrefix = "cix-runtime-" + runtimeAssetSuffix = "-darwin-arm64.tar.gz" +) + +// runtimeInfo is runtime.json, written by build-runtime.sh. +// +// Reading a manifest rather than exec'ing each binary with -v is what lets the +// menu show what is installed without three process spawns per open — and it is +// the only place the llama version is recorded at all. There is no separate +// runtime version: the runtime IS the server, so the directory it lives in is +// named for ServerVersion. +type runtimeInfo struct { + ServerVersion string `json:"server_version"` + CLIVersion string `json:"cli_version"` + LlamaVersion string `json:"llama_version"` + Platform string `json:"platform"` +} + +func runtimeRoot() (string, error) { + dir, err := cixHome() + if err != nil { + return "", err + } + return filepath.Join(dir, "runtime"), nil +} + +// runtimeCurrentDir returns the symlink path itself, deliberately unresolved. +// +// Everything that has to survive a version swap — the launchd wrapper, the +// /usr/local/bin/cix symlink — must point here rather than at a versioned +// directory, or it would pin itself to the version that happened to be current +// when it was written. +func runtimeCurrentDir() (string, error) { + root, err := runtimeRoot() + if err != nil { + return "", err + } + return filepath.Join(root, runtimeLinkName), nil +} + +func runtimeServerPath() (string, error) { + dir, err := runtimeCurrentDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "cix-server"), nil +} + +func runtimeCLIPath() (string, error) { + dir, err := runtimeCurrentDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "cix"), nil +} + +func runtimeVersionDir(version string) (string, error) { + root, err := runtimeRoot() + if err != nil { + return "", err + } + return filepath.Join(root, version), nil +} + +// currentRuntimeVersion reads the symlink, returning "" when nothing is +// installed. It reads the link rather than the manifest so that a runtime whose +// manifest is missing or unreadable is still identifiable. +func currentRuntimeVersion() string { + link, err := runtimeCurrentDir() + if err != nil { + return "" + } + target, err := os.Readlink(link) + if err != nil { + return "" + } + return filepath.Base(target) +} + +// runtimeReady reports whether the current runtime is usable — the symlink +// resolves and the server binary is actually there. Both halves matter: a +// symlink pointing at a pruned directory reads as installed until something +// tries to exec it. +func runtimeReady() bool { + server, err := runtimeServerPath() + if err != nil { + return false + } + info, err := os.Stat(server) + return err == nil && !info.IsDir() +} + +// readRuntimeInfo loads the manifest of the current runtime. +func readRuntimeInfo() (runtimeInfo, error) { + dir, err := runtimeCurrentDir() + if err != nil { + return runtimeInfo{}, err + } + data, err := os.ReadFile(filepath.Join(dir, runtimeManifestName)) + if err != nil { + return runtimeInfo{}, err + } + var info runtimeInfo + if err := json.Unmarshal(data, &info); err != nil { + return runtimeInfo{}, err + } + return info, nil +} + +// runtimeAssetName is the tarball attached to server release `version`. +func runtimeAssetName(version string) string { + return runtimeDirPrefix + version + runtimeAssetSuffix +} + +// runtimeVersionFromTarball recovers the version from an asset filename — +// cix-runtime-0.12.8-darwin-arm64.tar.gz → 0.12.8. Used only for the local +// CIX_RUNTIME_TARBALL path; a downloaded release carries its version in the +// release itself. +func runtimeVersionFromTarball(path string) string { + name := filepath.Base(path) + name = strings.TrimSuffix(name, runtimeAssetSuffix) + if v, ok := strings.CutPrefix(name, runtimeDirPrefix); ok && v != "" { + return v + } + return "local" +} + +// installRuntime downloads, verifies and unpacks the runtime for a release, +// leaving it staged in its versioned directory. It does NOT touch `current` — +// activateRuntime does that, separately, so that everything fallible happens +// while the running system is still untouched. +func installRuntime(rel release.Release, progress func(string)) error { + dest, err := runtimeVersionDir(rel.Version) + if err != nil { + return err + } + + asset, ok := rel.AssetByName(runtimeAssetName(rel.Version)) + if !ok { + // Fall back to matching by shape. A release whose tarball was named + // from a differently-formatted version string is still installable, and + // failing on the filename would be pedantry. + asset, ok = rel.AssetBySuffix(runtimeAssetSuffix) + } + if !ok { + return fmt.Errorf("release %s has no runtime attached", rel.TagName) + } + sums, ok := rel.AssetByName("checksums.txt") + if !ok { + return fmt.Errorf("release %s has no checksums.txt attached", rel.TagName) + } + + cacheDir, err := updatesCacheDir() + if err != nil { + return err + } + if err := os.MkdirAll(cacheDir, 0o700); err != nil { + return err + } + defer os.RemoveAll(cacheDir) + + // GitHub reports the asset size, but a release built by hand or served by a + // test harness may not. "(0 MB)" would be worse than saying nothing. + if asset.Size > 0 { + progress(fmt.Sprintf("Downloading the cix runtime (%d MB)…", (asset.Size+(1<<19))>>20)) + } else { + progress("Downloading the cix runtime…") + } + + tarball := filepath.Join(cacheDir, asset.Name) + if err := download(asset.URL, tarball); err != nil { + return fmt.Errorf("could not download the runtime: %w", err) + } + sumsPath := filepath.Join(cacheDir, "checksums.txt") + if err := download(sums.URL, sumsPath); err != nil { + return fmt.Errorf("could not download the checksums: %w", err) + } + if err := verifyChecksum(tarball, sumsPath, asset.Name); err != nil { + logf("checksum verification failed for %s: %v", asset.Name, err) + return fmt.Errorf("the downloaded runtime failed its checksum check and was discarded.\n\n%v", err) + } + + progress("Installing the cix runtime…") + return unpackRuntime(tarball, dest) +} + +// unpackRuntime extracts a runtime tarball into dest, then proves the result is +// runnable before letting anything point at it. +// +// Extraction goes to a sibling temp directory and is renamed into place, so an +// interrupted install can never leave a half-populated version directory that +// looks complete to currentRuntimeVersion. +func unpackRuntime(tarball, dest string) error { + root := filepath.Dir(dest) + if err := os.MkdirAll(root, 0o755); err != nil { + return err + } + + staging, err := os.MkdirTemp(root, ".staging-") + if err != nil { + return err + } + defer os.RemoveAll(staging) + + if err := extractTarGz(tarball, staging); err != nil { + return fmt.Errorf("could not unpack the runtime: %w", err) + } + + // The archive carries one top-level directory. Descend into it rather than + // trusting its name: the version in the filename and the version in the + // release tag have been the same so far, but nothing enforces it. + entries, err := os.ReadDir(staging) + if err != nil { + return err + } + tree := staging + if len(entries) == 1 && entries[0].IsDir() && strings.HasPrefix(entries[0].Name(), runtimeDirPrefix) { + tree = filepath.Join(staging, entries[0].Name()) + } + + // Quarantine is not applied to files this process downloads over HTTP, but + // it costs nothing to be certain: a quarantined llama-server is SIGKILLed on + // exec with EMPTY STDERR, which is indistinguishable from a crash and has + // already cost this project a debugging session (see server/Makefile). + _ = exec.Command("xattr", "-cr", tree).Run() + + if err := verifyRuntimeTree(tree); err != nil { + return err + } + + // Replace atomically-ish: the old directory is moved aside, the new one + // renamed in, and only then is the old one deleted. A reinstall of the + // version currently in use is the case this protects. + old := "" + if _, err := os.Stat(dest); err == nil { + old = dest + ".replaced" + os.RemoveAll(old) + if err := os.Rename(dest, old); err != nil { + return err + } + } + if err := os.Rename(tree, dest); err != nil { + if old != "" { + os.Rename(old, dest) + } + return err + } + if old != "" { + os.RemoveAll(old) + } + return nil +} + +// verifyRuntimeTree checks an unpacked runtime the way a stranger would. +// +// codesign --verify says the signature is well-formed; only exec proves the +// kernel agrees, and that is the check that matters, because the failure it +// catches — an ad-hoc signature the kernel rejects — kills the process with no +// output at all. +func verifyRuntimeTree(tree string) error { + for _, rel := range []string{"cix-server", "cix", filepath.Join("llama", "llama-server")} { + path := filepath.Join(tree, rel) + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("the runtime is incomplete: %s is missing", rel) + } + if out, err := exec.Command("codesign", "--verify", "--strict", path).CombinedOutput(); err != nil { + return fmt.Errorf("%s failed signature verification and was discarded: %s", + rel, strings.TrimSpace(string(out))) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, filepath.Join(tree, "cix-server"), "-v").CombinedOutput() + if err != nil { + return fmt.Errorf("the downloaded server does not run on this Mac: %v %s", err, strings.TrimSpace(string(out))) + } + logf("runtime staged at %s reports: %s", tree, strings.TrimSpace(string(out))) + return nil +} + +// extractTarGz unpacks a gzipped tar into dir. +// +// Written against archive/tar rather than shelling out to /usr/bin/tar for one +// reason that matters: path containment. A tar member named ../../something is +// a valid archive and a directory traversal, and the check belongs where the +// paths are joined. +func extractTarGz(path, dir string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + + target, err := safeJoin(dir, hdr.Name) + if err != nil { + return err + } + + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + // The execute bit is the payload here — cix-server, cix and + // llama-server all arrive as 0755 and are useless without it. + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode).Perm()) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + case tar.TypeSymlink: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + // Containment applies to what the link points at as well: a symlink + // escaping the tree would let a later member be written outside it. + if _, err := safeJoin(dir, filepath.Join(filepath.Dir(hdr.Name), hdr.Linkname)); err != nil { + return fmt.Errorf("archive symlink escapes the destination: %s -> %s", hdr.Name, hdr.Linkname) + } + os.Remove(target) + if err := os.Symlink(hdr.Linkname, target); err != nil { + return err + } + default: + // Device nodes, fifos, hard links: nothing this archive should + // contain, so refuse rather than silently skip. + return fmt.Errorf("unexpected entry type %q in the runtime archive: %s", hdr.Typeflag, hdr.Name) + } + } +} + +// safeJoin resolves name under dir and refuses anything that would land outside +// it — the tar equivalent of zip-slip. +// +// An absolute member name is refused rather than quietly re-rooted the way tar +// does it. filepath.Join would swallow the leading slash and produce a path +// inside dir, so this is not a containment hole; it is an archive that is not +// the one build-runtime.sh writes, and reading it as if it were is how a real +// difference gets ignored. +func safeJoin(dir, name string) (string, error) { + if filepath.IsAbs(name) { + return "", fmt.Errorf("archive entry has an absolute path: %s", name) + } + clean := filepath.Clean(filepath.Join(dir, name)) + if clean != dir && !strings.HasPrefix(clean, dir+string(os.PathSeparator)) { + return "", fmt.Errorf("archive entry escapes the destination: %s", name) + } + return clean, nil +} + +// activateRuntime points `current` at a version and returns the version it +// replaced, so the caller can put it back. +// +// The rename is what makes this safe: rename(2) over an existing symlink is +// atomic, so there is no instant at which `current` is missing or dangling. +func activateRuntime(version string) (previous string, err error) { + root, err := runtimeRoot() + if err != nil { + return "", err + } + dir := filepath.Join(root, version) + if _, err := os.Stat(dir); err != nil { + return "", fmt.Errorf("runtime %s is not installed", version) + } + + previous = currentRuntimeVersion() + + tmp := filepath.Join(root, ".current.new") + os.Remove(tmp) + if err := os.Symlink(version, tmp); err != nil { + return previous, err + } + if err := os.Rename(tmp, filepath.Join(root, runtimeLinkName)); err != nil { + os.Remove(tmp) + return previous, err + } + logf("runtime %s activated (was %q)", version, previous) + return previous, nil +} + +// pruneRuntimes deletes every installed runtime except the named ones. +// +// The previous version is deliberately among them: it is what an automatic +// rollback swaps back to, and keeping it costs ~90 MB against having to +// re-download during an incident. +func pruneRuntimes(keep ...string) { + root, err := runtimeRoot() + if err != nil { + return + } + entries, err := os.ReadDir(root) + if err != nil { + return + } + kept := map[string]bool{} + for _, k := range keep { + if k != "" { + kept[k] = true + } + } + for _, e := range entries { + name := e.Name() + if name == runtimeLinkName || kept[name] || strings.HasPrefix(name, ".") { + continue + } + if err := os.RemoveAll(filepath.Join(root, name)); err != nil { + logf("could not prune runtime %s: %v", name, err) + continue + } + logf("pruned runtime %s", name) + } +} + +// ensureRuntime installs the runtime when none is present. +// +// Called before anything that needs a server binary. A missing runtime is the +// normal state of a fresh install and of the first launch after upgrading from +// a version that carried its runtime inside the bundle. +func ensureRuntime(u *updater, progress func(string)) error { + if runtimeReady() { + return nil + } + + // Development seam, and the only way to install a runtime for a build that + // has no release behind it. Same shape as CIX_UPDATE_BASE_URL: unset in + // every normal run, and nothing here needs configuring. + if local := os.Getenv("CIX_RUNTIME_TARBALL"); local != "" { + logf("installing runtime from CIX_RUNTIME_TARBALL=%s", local) + // Named from the tarball rather than from the app: the two versions are + // unrelated now, and build-runtime.sh puts the server version in the + // filename precisely so it can be read back here. + v := runtimeVersionFromTarball(local) + dest, err := runtimeVersionDir(v) + if err != nil { + return err + } + if err := unpackRuntime(local, dest); err != nil { + return err + } + _, err = activateRuntime(v) + return err + } + + // The newest published server, not one pinned to this app's version. They + // are separate release streams on purpose: a server release should reach a + // Mac the same day it reaches Docker Hub, without waiting for the app to be + // re-tagged for it. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + rel, err := u.runtime.Latest(ctx) + if err != nil { + return fmt.Errorf("could not find a cix server to install: %w", err) + } + if rel.Version == "" { + return fmt.Errorf("no published cix server release was found to install") + } + if err := installRuntime(rel, progress); err != nil { + return err + } + if _, err := activateRuntime(rel.Version); err != nil { + return err + } + return nil +} + +// runtimeSummary is the line the details submenu shows. Kept here rather than in +// status_darwin.go because it is about what is installed, not about what the +// server is currently doing — which is why it still says something useful when +// the server is stopped. +func runtimeSummary() string { + v := currentRuntimeVersion() + if v == "" { + return "Server: not installed" + } + if info, err := readRuntimeInfo(); err == nil && info.LlamaVersion != "" { + return fmt.Sprintf("Server %s (llama %s)", v, info.LlamaVersion) + } + return "Server " + v +} diff --git a/cli/launcher/runtime_darwin_test.go b/cli/launcher/runtime_darwin_test.go new file mode 100644 index 00000000..6e7d4332 --- /dev/null +++ b/cli/launcher/runtime_darwin_test.go @@ -0,0 +1,468 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/dvcdsys/code-index/cli/internal/release" +) + +// The runtime is downloaded from the internet and unpacked into the user's home +// directory, and the symlink swap is what a failed update has to reverse. Both +// are worth testing without a network, a signed binary, or a real release. + +func TestSafeJoinRejectsEscapes(t *testing.T) { + dir := t.TempDir() + + for _, name := range []string{ + "../escape", + "a/../../escape", + "/absolute", + "cix-runtime-1.0.0/../../escape", + } { + if got, err := safeJoin(dir, name); err == nil { + t.Errorf("safeJoin(%q) = %q, want an error", name, got) + } + } + + for _, name := range []string{"cix-server", "llama/llama-server", "./cix"} { + if _, err := safeJoin(dir, name); err != nil { + t.Errorf("safeJoin(%q) returned %v, want it accepted", name, err) + } + } +} + +// writeTarGz builds an archive in memory. Each entry is name → content; a name +// ending in "/" becomes a directory. +func writeTarGz(t *testing.T, path string, entries map[string]string, modes map[string]int64) { + t.Helper() + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + for name, content := range entries { + hdr := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(content))} + if m, ok := modes[name]; ok { + hdr.Mode = m + } + if len(name) > 0 && name[len(name)-1] == '/' { + hdr.Typeflag = tar.TypeDir + hdr.Mode = 0o755 + hdr.Size = 0 + } else { + hdr.Typeflag = tar.TypeReg + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if hdr.Typeflag == tar.TypeReg { + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { + t.Fatal(err) + } +} + +// A tarball is an untrusted input even when we produced the one that normally +// arrives: the checksum proves it matches the release, and the release is served +// over the same channel as everything else. Containment is checked here rather +// than assumed. +func TestExtractTarGzRefusesTraversal(t *testing.T) { + dir := t.TempDir() + archive := filepath.Join(dir, "evil.tar.gz") + writeTarGz(t, archive, map[string]string{"../escaped": "owned"}, nil) + + dest := filepath.Join(dir, "out") + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatal(err) + } + if err := extractTarGz(archive, dest); err == nil { + t.Fatal("extractTarGz accepted an entry escaping the destination") + } + if _, err := os.Stat(filepath.Join(dir, "escaped")); err == nil { + t.Fatal("extractTarGz wrote outside the destination directory") + } +} + +// The execute bit is the payload: a cix-server extracted at 0644 is a runtime +// that installs cleanly and cannot be started. +func TestExtractTarGzPreservesExecuteBit(t *testing.T) { + dir := t.TempDir() + archive := filepath.Join(dir, "runtime.tar.gz") + writeTarGz(t, + archive, + map[string]string{ + "cix-runtime-1.0.0/": "", + "cix-runtime-1.0.0/cix-server": "#!/bin/sh\n", + "cix-runtime-1.0.0/runtime.json": `{"runtime_version":"1.0.0"}`, + "cix-runtime-1.0.0/llama/llama-server": "#!/bin/sh\n", + }, + map[string]int64{ + "cix-runtime-1.0.0/cix-server": 0o755, + "cix-runtime-1.0.0/llama/llama-server": 0o755, + }) + + dest := filepath.Join(dir, "out") + if err := extractTarGz(archive, dest); err != nil { + t.Fatal(err) + } + + for _, rel := range []string{"cix-server", "llama/llama-server"} { + info, err := os.Stat(filepath.Join(dest, "cix-runtime-1.0.0", rel)) + if err != nil { + t.Fatalf("%s: %v", rel, err) + } + if info.Mode().Perm()&0o111 == 0 { + t.Errorf("%s extracted as %v, want it executable", rel, info.Mode().Perm()) + } + } + + info, err := os.Stat(filepath.Join(dest, "cix-runtime-1.0.0", "runtime.json")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0o111 != 0 { + t.Errorf("runtime.json extracted as %v, want it non-executable", info.Mode().Perm()) + } +} + +// installRuntimeDir fakes an installed version, without the signed binaries a +// real one carries. +func installRuntimeDir(t *testing.T, home, version string) string { + t.Helper() + dir := filepath.Join(home, ".cix", "runtime", version) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "cix-server"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + return dir +} + +func TestActivateRuntimeSwapsAndReports(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + installRuntimeDir(t, home, "1.0.0") + installRuntimeDir(t, home, "1.1.0") + + if v := currentRuntimeVersion(); v != "" { + t.Fatalf("currentRuntimeVersion() = %q before any activation, want empty", v) + } + if runtimeReady() { + t.Fatal("runtimeReady() is true with no current symlink") + } + + previous, err := activateRuntime("1.0.0") + if err != nil { + t.Fatal(err) + } + if previous != "" { + t.Errorf("first activation reported previous = %q, want empty", previous) + } + if got := currentRuntimeVersion(); got != "1.0.0" { + t.Fatalf("currentRuntimeVersion() = %q, want 1.0.0", got) + } + if !runtimeReady() { + t.Fatal("runtimeReady() is false after activating a complete runtime") + } + + // The swap has to work over an existing symlink — that is the update path, + // and os.Symlink alone would fail with EEXIST. + previous, err = activateRuntime("1.1.0") + if err != nil { + t.Fatal(err) + } + if previous != "1.0.0" { + t.Errorf("activateRuntime reported previous = %q, want 1.0.0", previous) + } + if got := currentRuntimeVersion(); got != "1.1.0" { + t.Fatalf("currentRuntimeVersion() = %q, want 1.1.0", got) + } + + // Rollback is the same operation in reverse, which is the point of using a + // symlink at all. + if _, err := activateRuntime(previous); err != nil { + t.Fatal(err) + } + if got := currentRuntimeVersion(); got != "1.0.0" { + t.Fatalf("after rollback currentRuntimeVersion() = %q, want 1.0.0", got) + } + + // The link must stay relative, so the tree survives a moved home directory. + link, _ := runtimeCurrentDir() + target, err := os.Readlink(link) + if err != nil { + t.Fatal(err) + } + if filepath.IsAbs(target) { + t.Errorf("current -> %q is absolute, want a relative target", target) + } +} + +func TestActivateRuntimeRefusesMissingVersion(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + installRuntimeDir(t, home, "1.0.0") + + if _, err := activateRuntime("1.0.0"); err != nil { + t.Fatal(err) + } + if _, err := activateRuntime("9.9.9"); err == nil { + t.Fatal("activateRuntime accepted a version that is not installed") + } + // A refused activation must not have disturbed the working one. + if got := currentRuntimeVersion(); got != "1.0.0" { + t.Fatalf("currentRuntimeVersion() = %q after a failed activation, want 1.0.0", got) + } +} + +func TestPruneRuntimesKeepsCurrentAndPrevious(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + for _, v := range []string{"1.0.0", "1.1.0", "1.2.0", "1.3.0"} { + installRuntimeDir(t, home, v) + } + if _, err := activateRuntime("1.3.0"); err != nil { + t.Fatal(err) + } + + pruneRuntimes("1.3.0", "1.2.0") + + root := filepath.Join(home, ".cix", "runtime") + for _, v := range []string{"1.3.0", "1.2.0"} { + if _, err := os.Stat(filepath.Join(root, v)); err != nil { + t.Errorf("%s was pruned, want it kept: %v", v, err) + } + } + for _, v := range []string{"1.0.0", "1.1.0"} { + if _, err := os.Stat(filepath.Join(root, v)); err == nil { + t.Errorf("%s survived the prune, want it removed", v) + } + } + // Pruning must never take the symlink with it. + if got := currentRuntimeVersion(); got != "1.3.0" { + t.Fatalf("currentRuntimeVersion() = %q after pruning, want 1.3.0", got) + } +} + +func TestRuntimeAssetName(t *testing.T) { + if got, want := runtimeAssetName("0.12.8"), "cix-runtime-0.12.8-darwin-arm64.tar.gz"; got != want { + t.Errorf("runtimeAssetName() = %q, want %q", got, want) + } + // The round trip matters: the local-tarball path names the install directory + // by reading the version back out of the filename. + if got, want := runtimeVersionFromTarball("/tmp/cix-runtime-0.12.8-darwin-arm64.tar.gz"), "0.12.8"; got != want { + t.Errorf("runtimeVersionFromTarball() = %q, want %q", got, want) + } + if got := runtimeVersionFromTarball("/tmp/something-else.tar.gz"); got != "local" { + t.Errorf("runtimeVersionFromTarball() on an unrecognised name = %q, want local", got) + } +} + +func TestRuntimeSummaryWithoutInstall(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if got, want := runtimeSummary(), "Server: not installed"; got != want { + t.Errorf("runtimeSummary() = %q, want %q", got, want) + } +} + +// fakeRuntimeTarball builds a runtime that is structurally real: three ad-hoc +// signed Mach-O executables and a manifest, packed the way build-runtime.sh +// packs one. +// +// /bin/echo stands in for the binaries because the checks being exercised are +// real ones — codesign --verify --strict, and an actual exec of `cix-server -v` +// — and stubbing them out would test nothing. A copy of a system binary can be +// re-signed ad-hoc; the SIP-protected original could not. +func fakeRuntimeTarball(t *testing.T, dir, version string) string { + t.Helper() + + tree := filepath.Join(dir, runtimeDirPrefix+version) + if err := os.MkdirAll(filepath.Join(tree, "llama"), 0o755); err != nil { + t.Fatal(err) + } + for _, rel := range []string{"cix-server", "cix", "llama/llama-server"} { + dst := filepath.Join(tree, rel) + src, err := os.ReadFile("/bin/echo") + if err != nil { + t.Skipf("cannot read /bin/echo: %v", err) + } + if err := os.WriteFile(dst, src, 0o755); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("codesign", "--force", "--sign", "-", dst).CombinedOutput(); err != nil { + t.Skipf("codesign unavailable: %v %s", err, out) + } + } + manifest := `{"server_version":"` + version + `","cli_version":"8.8.8","llama_version":"bTEST","platform":"darwin-arm64"}` + if err := os.WriteFile(filepath.Join(tree, runtimeManifestName), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + + tarball := filepath.Join(dir, runtimeAssetName(version)) + cmd := exec.Command("tar", "-czf", tarball, "-C", dir, runtimeDirPrefix+version) + cmd.Env = append(os.Environ(), "COPYFILE_DISABLE=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("tar: %v %s", err, out) + } + return tarball +} + +func sha256File(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +// serveRelease publishes a tarball and its checksums the way a GitHub release +// does, and returns the Release the updater would have been handed. +func serveRelease(t *testing.T, version, tarball string, corruptChecksum bool) release.Release { + t.Helper() + + sum := sha256File(t, tarball) + if corruptChecksum { + sum = strings.Repeat("0", 64) + } + sums := sum + " ./" + filepath.Base(tarball) + "\n" + + mux := http.NewServeMux() + mux.HandleFunc("/runtime", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, tarball) + }) + mux.HandleFunc("/checksums", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, sums) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + return release.Release{ + Version: version, + TagName: serverTagPrefix + version, + Assets: []release.Asset{ + {Name: runtimeAssetName(version), URL: srv.URL + "/runtime"}, + {Name: "checksums.txt", URL: srv.URL + "/checksums"}, + }, + } +} + +// The whole install path, end to end: download, checksum, unpack, strip +// quarantine, verify every signature, exec the server, then activate. +func TestInstallRuntimeEndToEnd(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + tarball := fakeRuntimeTarball(t, t.TempDir(), "1.0.0") + rel := serveRelease(t, "1.0.0", tarball, false) + + if err := installRuntime(rel, func(string) {}); err != nil { + t.Fatalf("installRuntime: %v", err) + } + // Installing must not switch to it: everything fallible happens before the + // running system is touched, and the swap is a separate, reversible step. + if v := currentRuntimeVersion(); v != "" { + t.Fatalf("installRuntime activated %q on its own", v) + } + + if _, err := activateRuntime("1.0.0"); err != nil { + t.Fatal(err) + } + if !runtimeReady() { + t.Fatal("runtimeReady() is false after a successful install") + } + + info, err := readRuntimeInfo() + if err != nil { + t.Fatalf("readRuntimeInfo: %v", err) + } + if info.ServerVersion != "1.0.0" || info.LlamaVersion != "bTEST" { + t.Errorf("manifest = %+v, want server 1.0.0 / llama bTEST", info) + } + if got, want := runtimeSummary(), "Server 1.0.0 (llama bTEST)"; got != want { + t.Errorf("runtimeSummary() = %q, want %q", got, want) + } + + // The download cache is temporary by design — a 35 MB tarball has no reason + // to outlive the install that used it. + cache, _ := updatesCacheDir() + if _, err := os.Stat(cache); err == nil { + t.Errorf("%s survived the install, want it removed", cache) + } +} + +// Without a Developer ID signature the checksum is the entire integrity story, +// so a mismatch has to stop the install rather than warn about it. +func TestInstallRuntimeRefusesBadChecksum(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + tarball := fakeRuntimeTarball(t, t.TempDir(), "1.0.0") + rel := serveRelease(t, "1.0.0", tarball, true) + + if err := installRuntime(rel, func(string) {}); err == nil { + t.Fatal("installRuntime accepted a tarball that failed its checksum") + } + if _, err := os.Stat(filepath.Join(home, ".cix", "runtime", "1.0.0")); err == nil { + t.Error("a version directory was created despite the failed checksum") + } +} + +// An incomplete runtime must be rejected before anything can point at it — +// codesign --verify passes on the files that are present, so completeness is a +// separate check and worth its own test. +func TestUnpackRuntimeRejectsIncompleteTree(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + staging := t.TempDir() + tarball := fakeRuntimeTarball(t, staging, "1.0.0") + // Repack without the CLI. + tree := filepath.Join(staging, runtimeDirPrefix+"1.0.0") + if err := os.Remove(filepath.Join(tree, "cix")); err != nil { + t.Fatal(err) + } + cmd := exec.Command("tar", "-czf", tarball, "-C", staging, runtimeDirPrefix+"1.0.0") + cmd.Env = append(os.Environ(), "COPYFILE_DISABLE=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("tar: %v %s", err, out) + } + + dest := filepath.Join(home, ".cix", "runtime", "1.0.0") + err := unpackRuntime(tarball, dest) + if err == nil { + t.Fatal("unpackRuntime accepted a runtime with no cix CLI") + } + if !strings.Contains(err.Error(), "incomplete") { + t.Errorf("error = %q, want it to say the runtime is incomplete", err) + } + if _, statErr := os.Stat(dest); statErr == nil { + t.Error("the version directory was created despite the incomplete tree") + } +} diff --git a/cli/launcher/takeover_darwin.go b/cli/launcher/takeover_darwin.go index 0f363f01..14b09890 100644 --- a/cli/launcher/takeover_darwin.go +++ b/cli/launcher/takeover_darwin.go @@ -23,7 +23,7 @@ import ( // handleForeignAgent asks (once) what to do about an agent this app did not // create, and acts on the answer. Returns true when the app now owns the agent. -func handleForeignAgent(b bundle) bool { +func handleForeignAgent(u *updater) bool { p := loadPrefs() if p.Takeover == "" { @@ -56,7 +56,7 @@ func handleForeignAgent(b bundle) bool { return false } - if err := adoptForeignAgent(b); err != nil { + if err := adoptForeignAgent(u); err != nil { logf("takeover failed: %v", err) _ = alert("Could not take over the existing installation", fmt.Sprintf("%v\n\nThe existing installation was left untouched. "+ @@ -72,12 +72,20 @@ func handleForeignAgent(b bundle) bool { // adoptForeignAgent migrates the existing configuration, backs up what it // replaces, and installs this app's wrapper and plist. -func adoptForeignAgent(b bundle) error { +func adoptForeignAgent(u *updater) error { envPath, err := foreignEnvPath() if err != nil { return err } + // The wrapper written below execs ~/.cix/runtime/current/cix-server, so the + // runtime has to exist before the old agent is unloaded. Doing it first also + // means a failed download costs nothing: the existing installation is still + // running its own server, untouched. + if err := ensureRuntime(u, logProgress); err != nil { + return fmt.Errorf("could not install the cix server: %w", err) + } + // Migrate first. Taking over an agent and then pointing it at a fresh, // empty database would look like the takeover destroyed the user's index. if envPath != "" { @@ -98,7 +106,7 @@ func adoptForeignAgent(b bundle) error { if err := launchdBootout(); err != nil { return fmt.Errorf("could not unload the existing agent: %w", err) } - if err := writeLaunchdFiles(b, autostartEnabled()); err != nil { + if err := writeLaunchdFiles(autostartEnabled()); err != nil { return err } if err := launchdBootstrap(); err != nil { diff --git a/cli/launcher/update_darwin.go b/cli/launcher/update_darwin.go index fa455ecb..0722a596 100644 --- a/cli/launcher/update_darwin.go +++ b/cli/launcher/update_darwin.go @@ -16,51 +16,93 @@ import ( "syscall" "time" + "github.com/dvcdsys/code-index/cli/internal/client" "github.com/dvcdsys/code-index/cli/internal/release" ) // Self-update. // -// The app replaces itself whole — bundle and all — rather than swapping -// individual binaries. That is not laziness: cix-server, the cix CLI and -// llama-server are versioned together and tested together, and an update that -// left one of them behind would produce a combination nobody has ever run. -// Moving the .app is also the only way "update the CLI" can mean anything, -// since the CLI on PATH is a symlink into the bundle. +// A release has two halves, published together under one mac/vX.Y.Z tag and +// updated independently: +// +// - The runtime — cix-server, the cix CLI, llama-server — installed into +// ~/.cix/runtime// and switched on by moving a symlink. The app +// keeps running throughout, and a runtime that does not come back up is +// rolled back automatically. +// - The launcher — the .app itself — which can only be replaced by a detached +// helper that waits for this process to exit, because a process cannot +// overwrite its own signed executable and survive. +// +// In the normal case both change at once and both are done, runtime first: it +// is the reversible half, so a failure there costs nothing, whereas the launcher +// swap ends this process. +// +// The runtime carries the CLI rather than the app, so `cix` on PATH is a symlink +// into ~/.cix/runtime/current and follows updates without /usr/local being +// touched. //go:embed swap.sh var swapScript []byte -// macTagPrefix selects this app's tag stream. server/v* and cli/v* live in the -// same repository and are released on their own schedules. -const macTagPrefix = "mac/v" +// The two streams this app watches. They are separate releases of separate +// things and neither waits for the other: a server release reaches a Mac the +// same day it reaches Docker Hub, and the app ships when the app changes. +const ( + macTagPrefix = "mac/v" // cix.app — the launcher + serverTagPrefix = "server/v" // the runtime, same tag as the Docker images +) -// updateCheckInterval throttles the automatic check. The request itself is -// nearly free — a cached ETag makes a no-change check a 304, which does not -// count against GitHub's unauthenticated hourly limit — but the limit is per -// IP and shared with everything else on the machine, so there is no reason to -// spend it more often than this. +// updateCheckInterval throttles the automatic check. The requests are nearly +// free — a cached ETag makes a no-change check a 304, which does not count +// against GitHub's unauthenticated hourly limit — but the limit is per IP and +// shared with everything else on the machine, and this is now two requests +// rather than one, so there is no reason to spend them more often than this. const updateCheckInterval = 30 * time.Minute type updater struct { bundle bundle - client *release.Client + + app *release.Client + runtime *release.Client lastCheck time.Time - latest release.Release + + // The newest release seen on each stream, whether or not it is newer than + // what is installed. Kept so a 304 — which carries no body — leaves the + // previous answer standing instead of reading as "nothing published". + seenApp release.Release + seenRuntime release.Release } -func newUpdater(b bundle) *updater { - u := &updater{bundle: b, client: release.New(release.DefaultRepo, macTagPrefix)} - u.client.ETag = loadPrefs().UpdateETag +// available is what a check turned up: the halves that are behind, if any. A +// zero Release means that half is current. +type available struct { + App release.Release + Runtime release.Release +} + +func (a available) any() bool { return a.App.Version != "" || a.Runtime.Version != "" } - // Test seam. The update path replaces the running application, so the only - // way to know it works is to run it — against a local server, with a - // locally built image, rather than by publishing a release and hoping. - // Unset in every normal run; there is nothing to configure here. +func newUpdater(b bundle) *updater { + p := loadPrefs() + u := &updater{ + bundle: b, + app: release.New(release.DefaultRepo, macTagPrefix), + runtime: release.New(release.DefaultRepo, serverTagPrefix), + } + u.app.ETag = p.UpdateETag + u.runtime.ETag = p.RuntimeETag + + // Test seam. The update path replaces the running application and restarts + // the server, so the only way to know it works is to run it — against a + // local server, with locally built artefacts, rather than by publishing a + // release and hoping. Unset in every normal run; nothing here needs + // configuring. if base := os.Getenv("CIX_UPDATE_BASE_URL"); base != "" { - u.client.BaseURL = strings.TrimRight(base, "/") - logf("update base URL overridden to %s (CIX_UPDATE_BASE_URL)", u.client.BaseURL) + trimmed := strings.TrimRight(base, "/") + u.app.BaseURL = trimmed + u.runtime.BaseURL = trimmed + logf("update base URL overridden to %s (CIX_UPDATE_BASE_URL)", trimmed) } return u } @@ -73,53 +115,195 @@ func updatesCacheDir() (string, error) { return filepath.Join(home, "Library", "Caches", "cix", "updates"), nil } -// check asks GitHub for the newest release, at most once per interval unless -// forced. Returns the release when one is newer than the running build. -func (u *updater) check(force bool) (release.Release, bool) { - if isDevBuild() { - // A development build is normally newer than the last release, so - // "updating" it would be a downgrade. Nothing to offer. - return release.Release{}, false +// check asks GitHub about both streams, at most once per interval unless forced. +func (u *updater) check(force bool) available { + if force || time.Since(u.lastCheck) >= updateCheckInterval { + u.lastCheck = time.Now() + u.seenApp = u.refresh("app", u.app, u.seenApp, func(p *prefs, etag string) { p.UpdateETag = etag }) + u.seenRuntime = u.refresh("runtime", u.runtime, u.seenRuntime, func(p *prefs, etag string) { p.RuntimeETag = etag }) + } + + var av available + // A development build reports version "dev", which IsNewer refuses to + // compare — so an unstamped launcher never offers to "update" itself to a + // release that is probably older than the tree it was built from. Its + // runtime is a separate question and is still offered. + if release.IsNewer(version, u.seenApp.Version) { + av.App = u.seenApp } - if !force && time.Since(u.lastCheck) < updateCheckInterval { - return u.latest, release.IsNewer(version, u.latest.Version) + if release.IsNewer(currentRuntimeVersion(), u.seenRuntime.Version) { + av.Runtime = u.seenRuntime } + return av +} +// refresh polls one stream, keeping the previous answer when nothing came back. +func (u *updater) refresh(what string, c *release.Client, previous release.Release, setETag func(*prefs, string)) release.Release { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - rel, err := u.client.Latest(ctx) + rel, err := c.Latest(ctx) switch { case errors.Is(err, release.ErrNotModified): - // Nothing changed since the last check; keep what we already knew. - u.lastCheck = time.Now() - return u.latest, release.IsNewer(version, u.latest.Version) + return previous case err != nil: - logf("update check failed: %v", err) - return release.Release{}, false + logf("%s update check failed: %v", what, err) + return previous } - u.lastCheck = time.Now() - u.latest = rel - if etag := u.client.ETag; etag != "" { + if etag := c.ETag; etag != "" { p := loadPrefs() - if p.UpdateETag != etag { - p.UpdateETag = etag + before := p + setETag(&p, etag) + if p != before { if err := savePrefs(p); err != nil { - logf("could not persist the update ETag: %v", err) + logf("could not persist the %s update ETag: %v", what, err) } } } - return rel, release.IsNewer(version, rel.Version) + return rel } -// install downloads, verifies and stages the release, then hands over to the -// swap script and quits. +// install applies whichever halves are behind, runtime first. +// +// Runtime first because it is the reversible one: a failure there leaves the +// app untouched and the old server running, whereas the launcher swap ends this +// process. Returns quit=true when the launcher was staged — from that point the +// detached helper owns the outcome and waits for this process to exit. +func (u *updater) install(av available, serverWasRunning bool, progress func(string)) (quit bool, err error) { + if av.Runtime.Version != "" { + if err := u.updateRuntime(av.Runtime, serverWasRunning, progress); err != nil { + return false, err + } + } + if av.App.Version == "" { + return false, nil + } + if err := u.updateLauncher(av.App, progress); err != nil { + return false, err + } + return true, nil +} + +// updateRuntime installs the new runtime and switches to it, putting the old one +// back if the server does not survive. +// +// Ordering is the whole design: everything that can fail — download, checksum, +// unpack, signature check, a test exec — happens before the running server is +// touched. Only then is the symlink moved, and moving it back is a rename. +func (u *updater) updateRuntime(rel release.Release, serverWasRunning bool, progress func(string)) error { + defer progress("") + + if err := installRuntime(rel, progress); err != nil { + return err + } + + // Stopped first, deliberately. The running server's llama-server sidecar is + // resolved relative to its own executable, so a swap underneath a live + // process would leave the two halves of one runtime disagreeing about which + // version they are. + if serverWasRunning { + progress("Restarting the cix server…") + if err := stopServer(); err != nil { + logf("could not stop the server before the runtime swap: %v", err) + } + waitForServerStop(20 * time.Second) + } + + previous, err := activateRuntime(rel.Version) + if err != nil { + return fmt.Errorf("could not switch to the new runtime: %w", err) + } + + if serverWasRunning { + vars, _ := readServerEnv() + if err := startServer(); err != nil || !serverSurvivedRestart(localBaseURL(vars), runtimeHealthWindow) { + logf("runtime %s did not come back up (start error: %v); rolling back to %q", rel.Version, err, previous) + return u.rollbackRuntime(previous, rel.Version) + } + } + + // Keep the version just replaced. It is what a rollback needs, and ~90 MB is + // a cheap insurance premium against having to download during an incident. + pruneRuntimes(rel.Version, previous) + logf("runtime updated to %s", rel.Version) + return nil +} + +// runtimeHealthWindow is how long a freshly swapped server gets to answer. +// +// Not a deadline for "is it working" — a cold start loads an embedding model and +// can legitimately take minutes. It is a deadline for deciding, and the decision +// falls back to whether the process is still alive. See serverSurvivedRestart. +const runtimeHealthWindow = 45 * time.Second + +// rollbackRuntime returns to the previous runtime after a failed update. +func (u *updater) rollbackRuntime(previous, failed string) error { + if previous == "" || previous == failed { + // Nothing to go back to — a first install that will not run. Leave the + // directory in place: it is the only copy, and re-downloading it to + // investigate would be worse than 90 MB. + return fmt.Errorf("the new server (%s) did not start, and there is no previous version to fall back to.\n\n"+ + "See ~/.cix/logs/cix-server.err.", failed) + } + + _ = stopServer() + waitForServerStop(20 * time.Second) + + if _, err := activateRuntime(previous); err != nil { + return fmt.Errorf("the new server (%s) did not start, and cix could not switch back to %s: %v", + failed, previous, err) + } + if err := startServer(); err != nil { + return fmt.Errorf("the new server (%s) did not start. cix switched back to %s, "+ + "but could not restart it: %v", failed, previous, err) + } + + // Drop the version that just failed, and with it whatever else was lying + // around: the machine is back on a version known to work, and there is + // nothing to fall back FROM any more. Keeping a runtime that has been + // demonstrated not to start is 90 MB and an entry in ~/.cix/runtime that + // invites someone to point `current` at it by hand. + pruneRuntimes(previous) + + return fmt.Errorf("the new server (%s) did not start, so cix went back to %s.\n\n"+ + "The update was not applied. See ~/.cix/logs/cix-server.err.", failed, previous) +} + +// serverSurvivedRestart decides whether a just-started server is alive. +// +// Answering "healthy within the window" alone would be wrong: a cold start loads +// an embedding model and stays silent for minutes, and rolling a good runtime +// back for that would be worse than the bug it guards against. A process that +// has exited, on the other hand, is unambiguous — KeepAlive is false, so nothing +// restarts it and a crash leaves no pid behind. +func serverSurvivedRestart(baseURL string, timeout time.Duration) bool { + c := client.New(baseURL, "") + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if c.Health() == nil { + return true + } + time.Sleep(time.Second) + } + return launchdPID() != 0 +} + +// updateLauncher downloads, verifies and stages the new .app, then hands over to +// the swap script. The caller quits immediately afterwards. // // Every step before the swap is reversible: a failure leaves the installed app // exactly as it was, which is why the staging directory is built and validated // in full before anything live is touched. -func (u *updater) install(rel release.Release, serverWasRunning bool) error { +// +// The server is not stopped for this, and used to be. Nothing a running server +// touches is inside the bundle any more — the binary, its llama sidecar and the +// launchd wrapper all live under ~/.cix — so replacing the .app is invisible to +// it. Updating the app no longer interrupts indexing. +func (u *updater) updateLauncher(rel release.Release, progress func(string)) error { + defer progress("") + progress("Downloading the cix update…") + dmgAsset, ok := rel.AssetBySuffix(".dmg") if !ok { return fmt.Errorf("release %s has no disk image attached", rel.TagName) @@ -170,25 +354,6 @@ func (u *updater) install(rel release.Release, serverWasRunning bool) error { return err } - // Record the intent before quitting: the process that acts on it is the one - // that starts after the swap, and it has no other way to know the server - // was up. - p := loadPrefs() - p.RestartServerAfterUpdate = serverWasRunning - if err := savePrefs(p); err != nil { - logf("could not record the post-update restart flag: %v", err) - } - - // The old cix-server's executable lives inside the bundle about to be - // moved. macOS kills a process whose signed binary is replaced underneath - // it, so this is a controlled stop rather than a surprise SIGKILL. - if serverWasRunning { - if err := stopServer(); err != nil { - logf("could not stop the server before the swap: %v", err) - } - waitForServerStop(20 * time.Second) - } - return u.launchSwap(staged) } diff --git a/doc/MACOS_APP.md b/doc/MACOS_APP.md index 0f49f8cd..7f92f361 100644 --- a/doc/MACOS_APP.md +++ b/doc/MACOS_APP.md @@ -1,7 +1,8 @@ # cix for macOS -`cix.app` packages the cix server, the `cix` CLI and a Metal-accelerated -`llama-server` into a single drag-to-install application for Apple Silicon. +`cix.app` is a drag-to-install menu bar app for Apple Silicon that runs a local +cix server — the server itself, the `cix` CLI and a Metal-accelerated +`llama-server` for embeddings. The app sits in the menu bar and shows whether the server is running and what its embedding provider is doing, with start/stop and a link to the dashboard. @@ -10,6 +11,15 @@ There is no Dock icon and no window. It keeps itself up to date, checks its downloads, and never asks for an administrator password to do it. +The download is small — about 4 MB — because the server is not inside it. The +app fetches that part on first launch and keeps it in `~/.cix/runtime/`, which +is what lets it update the server without restarting itself, and roll back +automatically if a new one will not start. + +The server it installs is the same build the Docker images are cut from, with +the same version number, and it is released on its own schedule. A new cix +server reaches your Mac without waiting for a new version of this app. + ## Requirements - macOS 13 (Ventura) or later @@ -68,38 +78,60 @@ launcher detects this and asks you to move the app rather than half-working. ## What is inside +The app holds one executable: + ``` cix.app/Contents/ Info.plist MacOS/ - cix-launcher the app itself - cix-server indexing + search server - cix command-line client - llama/ Metal-accelerated llama-server + its libraries + cix-launcher the menu bar app Resources/ cix.icns app icon cixTemplate.png menu-bar glyph (and @2x) ``` -Everything executable lives in `Contents/MacOS/`, including `llama/`. That is -not a style choice: `codesign --verify --strict` rejects executable code under -`Resources/`, and `cix-server` looks for `llama-server` at -`

/llama`, so keeping them siblings means `CIX_LLAMA_BIN_DIR` -never has to be set. +Everything it runs lives outside it, under your home directory: + +``` +~/.cix/runtime/ + 0.12.8/ cix-server cix llama/ runtime.json + 0.12.7/ the version this one replaced, kept for rollback + current -> 0.12.8 +``` + +Those are *server* versions — the same ones on Docker Hub. The app has its own, +smaller version, and the two have nothing to do with each other. + +`llama/` sits next to `cix-server` because `cix-server` looks for +`llama-server` at `/llama`, so keeping them siblings means +`CIX_LLAMA_BIN_DIR` never has to be set. They are downloaded, checked and +installed as one thing — a llama version is part of a server release, not +something tracked separately. -The versions of all three components are recorded in `Info.plist` under the -`CIXServerVersion`, `CIXCLIVersion` and `CIXLlamaVersion` keys, and each binary -also reports its own: +`current` is a symlink, and that is the whole trick: updating the server means +extracting the new one beside the old and renaming the symlink, which is atomic +and instantly reversible. Nothing has to quit, and the version that was working +five seconds ago is still on disk. + +To see what is installed: ```bash /Applications/cix.app/Contents/MacOS/cix-launcher -report ``` +It asks each binary for its own version rather than reading the manifest, which +is what catches a runtime that is not what it claims to be. + ## First run -The very first launch asks for an email address, then generates a password and -an API key, starts the server, and shows you the credentials. You will be asked -to change the password when you first sign in. +The very first launch asks for an email address, downloads the runtime (about +40 MB), then generates a password and an API key, starts the server, and shows +you the credentials. You will be asked to change the password when you first +sign in. + +The download happens before anything is written. A setup that had created an +account and a background agent pointing at a server that was never downloaded +would look finished and be broken. This step is not decoration. `cix-server` refuses to start against an empty database unless it is told which admin account to create — it will not invent @@ -110,6 +142,7 @@ What it writes: | Path | Contents | |---|---| | `~/.cix/server.env` | port, data paths, API key, bootstrap credentials (mode 0600) | +| `~/.cix/runtime/` | the server, the CLI and llama-server; one directory per version | | `~/.cix/data/` | SQLite database and the index | | `~/.cix/launchd/run-cix-server.sh` | launchd entry point; sources `server.env` | | `~/Library/LaunchAgents/com.cix.server.plist` | the launchd agent | @@ -135,8 +168,8 @@ macOS announces any newly registered background agent. ● Embeddings: llama.cpp (bundled) Port: 21847 Model: awhiteside/Co…bed-Q8_0-GGUF Network: this Mac only ───────────── Model: awhiteside/CodeRankEmbed-Q8_0-GGUF -Stop Server Server 0.12.4 -Open Dashboard +Stop Server Server 0.12.8 +Open Dashboard Server 0.12.8 (llama b10238) ───────────── Start at Login ✓ Allow Network Access ✓ @@ -151,7 +184,10 @@ The dot carries the state: green running, amber starting, red stopped, grey unknown. Rows are truncated so the menu stays a predictable width instead of being as wide as whichever model happens to be configured; the submenu on the server row holds the full values and the things that do not fit — the process -id, the port, the network exposure and the untruncated model name. +id, the port, the network exposure, the untruncated model name and which server +is installed. The last two rows differ on purpose: the first is what the running +server reports over HTTP, the second is what is on disk — and only the second +still says anything when the server is not running, which is when it matters. There are no tooltips anywhere in this app, deliberately. AppKit's have two behaviours that cannot be changed through any API: once one of them has @@ -190,28 +226,39 @@ output goes to `~/.cix/logs/launcher.log` (mode 0600). ### Check for Updates… -cix looks for a newer `mac/v*` release when it starts and at most every 30 -minutes after that, and only speaks up when there is one. The menu item does the -same check immediately. - -Accepting downloads the disk image and its `checksums.txt`, verifies the -SHA-256, copies the application out to a staging directory beside the installed -one, and only then replaces it. Anything that fails before that point leaves the -installed app untouched. The server is stopped first — its executable is inside -the bundle being replaced, and macOS kills a process whose signed binary is -swapped underneath it — and started again afterwards if it had been running. - -The whole `.app` is replaced, never individual files. The three binaries inside -are built and tested as one thing, and the bundle's signature seals all of them, -so replacing one would both create an untested combination and break -verification. It is also why updating the app updates the `cix` command: the -one on your `PATH` is a symlink into the bundle. - -If the folder containing cix.app is not writable by you, the update stops before -downloading anything and tells you to install the new version by hand. It will -not ask for an administrator password — an unsigned app requesting admin rights -to overwrite itself is exactly what malware looks like, and it is not a habit -worth teaching. +cix watches two release streams — `mac/v*` for the app, `server/v*` for the +server — when it starts and at most every 30 minutes after that, and only speaks +up when one of them has something. The menu item does the same check +immediately. + +The two update independently, and the dialog says which one is happening, +because they feel completely different. + +**The server** — with the CLI and llama-server — is downloaded from its release, +checked against `checksums.txt`, unpacked beside the version in use, +signature-verified and test-run, all before anything live is touched. Only then +is the running server stopped, the `current` symlink moved, and the server +started again. The app stays open throughout; the menu bar item shows what it is +doing. + +If the new server does not come back, cix moves the symlink back and starts the +old one, without asking and without downloading anything. "Does not come back" +means the process exited — not that `/health` was slow, because a cold start +loads an embedding model and can legitimately take minutes. + +**The app** is replaced whole, by a detached helper that waits for the launcher +to quit and then swaps the bundle. A process cannot overwrite its own signed +executable and survive, so this half does mean cix closes and reopens. The +server is *not* stopped for it: nothing a running server touches is inside the +bundle any more. + +When both have something, the server goes first — it is the reversible one. + +If the folder containing cix.app is not writable by you, the app half stops +before downloading anything and tells you to install the new version by hand. It +will not ask for an administrator password — an unsigned app requesting admin +rights to overwrite itself is exactly what malware looks like, and it is not a +habit worth teaching. > The checksum proves the download arrived intact. It is not a trust anchor: > `checksums.txt` travels the same path as the image, so anyone who can replace @@ -254,10 +301,12 @@ not own, holding the port it would use. It asks once what to do, shows you the paths it found, and remembers the answer in `~/.cix/launcher.json`: -- **Leave It Alone** — observe-only. Status, the provider row, the dashboard - link and password reset keep working over HTTP; Start/Stop, autostart and the - network toggle are disabled. The app is then useful alongside a development - checkout instead of fighting it. +- **Leave It Alone** — observe-only. Status, the provider row and the dashboard + link keep working over HTTP; Start/Stop, autostart and the network toggle are + disabled. The app is then useful alongside a development checkout instead of + fighting it. No runtime is downloaded on this path — watching someone else's + server needs no binaries of our own. Password reset does, because it opens the + database directly, so it offers the download the first time you use it. - **Take Over** — the app copies the port, API key and database paths out of the `.env` the old wrapper sourced, backs the old plist and wrapper up under `~/.cix/backup/`, and installs its own. Re-running `install-server.sh` will @@ -272,19 +321,25 @@ up a second server that cannot bind the port, against a second, empty database. ## Using the CLI -Put it on your `PATH` as a **symlink**, so it keeps pointing at the current -bundle after an update: +Put it on your `PATH` as a **symlink** into the runtime, so it keeps following +updates: ```bash -ln -sf /Applications/cix.app/Contents/MacOS/cix /usr/local/bin/cix +ln -sf ~/.cix/runtime/current/cix /usr/local/bin/cix ``` +Point it at `current`, not at a version directory: `current` is what moves when +the runtime is updated, and old versions are eventually deleted. + +The CLI ships with the server rather than inside the app because it speaks to a +specific server's API, and pinning the two together is the point. + ### Forgotten password, from a terminal The menu's **Reset Password…** does this for you. The same thing by hand: ```bash -/Applications/cix.app/Contents/MacOS/cix-server -reset-password you@example.com +~/.cix/runtime/current/cix-server -reset-password you@example.com ``` It prints a generated temporary password. Point it at the same `CIX_DATA_DIR` / @@ -292,14 +347,15 @@ It prints a generated temporary password. Point it at the same `CIX_DATA_DIR` / ```bash set -a; source ~/.cix/server.env; set +a -/Applications/cix.app/Contents/MacOS/cix-server -reset-password you@example.com +~/.cix/runtime/current/cix-server -reset-password you@example.com ``` ## Building it yourself ```bash -MAC_VERSION=0.1.0-dev mac/scripts/build-app.sh -MAC_VERSION=0.1.0-dev mac/scripts/make-dmg.sh +SERVER_VERSION=0.0.0-dev mac/scripts/build-runtime.sh +MAC_VERSION=0.1.0-dev mac/scripts/build-app.sh +MAC_VERSION=0.1.0-dev mac/scripts/make-dmg.sh ``` See [`mac/README.md`](../mac/README.md) for the build pipeline, the signing @@ -312,5 +368,5 @@ launchctl bootout "gui/$(id -u)/com.cix.server" rm -f ~/Library/LaunchAgents/com.cix.server.plist ~/.cix/launchd/run-cix-server.sh rm -rf /Applications/cix.app rm -f /usr/local/bin/cix # if you created the symlink -rm -rf ~/.cix # config, database and index data +rm -rf ~/.cix # config, runtime, database and index data ``` diff --git a/mac/Info.plist.in b/mac/Info.plist.in index 8900d13d..8dc7ff2b 100644 --- a/mac/Info.plist.in +++ b/mac/Info.plist.in @@ -27,6 +27,12 @@ LSBackgroundOnly (that would hide the menu bar item too), and any login-item keys — autostart is launchd's job, configured at runtime, not baked into the bundle. + + Also deliberately absent, and once present: CIXServerVersion, CIXCLIVersion + and CIXLlamaVersion. Those three now live outside the app, in + ~/.cix/runtime/current/runtime.json, and update without this bundle changing. + A version recorded here would be a claim about somebody else's files, wrong + the first time either half is updated on its own. --> @@ -62,11 +68,5 @@ NSHumanReadableCopyright MIT licensed. https://github.com/dvcdsys/code-index - CIXServerVersion - @SERVER_VERSION@ - CIXCLIVersion - @CLI_VERSION@ - CIXLlamaVersion - @LLAMA_VERSION@ diff --git a/mac/README.md b/mac/README.md index 08953174..74e34465 100644 --- a/mac/README.md +++ b/mac/README.md @@ -6,35 +6,102 @@ User-facing installation and usage documentation lives in ``` mac/ - Info.plist.in bundle metadata template (placeholder tokens) - Resources/ icon set + DMG artwork — see Resources/README.md - scripts/build-app.sh assemble + sign mac/dist/cix.app - scripts/sign-app.sh ad-hoc codesign, innermost code first - scripts/make-dmg.sh wrap the app in a drag-to-Applications DMG - dist/ build output (gitignored) + Info.plist.in bundle metadata template (placeholder tokens) + Resources/ icon set + DMG artwork — see Resources/README.md + scripts/common.sh shared version derivation + signing helpers + scripts/build-runtime.sh package cix-server + cix + llama/ as a tarball + scripts/build-app.sh assemble + sign mac/dist/cix.app (launcher only) + scripts/sign-app.sh ad-hoc codesign + scripts/make-dmg.sh wrap the app in a drag-to-Applications DMG + dist/ build output (gitignored) ``` +## Two artefacts, two release streams + +The Mac gets two things, and they are released separately because they are +separate things: + +| Asset | Stream | Contents | Size | +|---|---|---|---| +| `cix--arm64.dmg` | `mac/v*` | the app — `cix-launcher` and its icons, nothing else | ~4 MB | +| `cix-runtime--darwin-arm64.tar.gz` | `server/v*` | `cix-server`, the `cix` CLI, `llama/` | ~35 MB | + +The runtime **is** the server, so it carries the server's version and ships from +the server's tag — the same `server/vX.Y.Z` and the same workflow run that +publishes the Docker images (`release-server.yml`, job `macos-runtime`). A Mac +install on 0.12.8 and a container on 0.12.8 are the same server. That is also +why llama has no version of its own here: a llama bump is a server release, as +it has always been. + +The app installs the runtime into `~/.cix/runtime//` and points a +`current` symlink at it. `cli/launcher/runtime_darwin.go` documents the design; +the short version is that a server update becomes a download and a `rename`, +with the app still running and the previous version kept for rollback. + +The two update independently and neither waits for the other: a server release +reaches a Mac the day it reaches Docker Hub, and the app ships when the app +changes. + ## Build ```bash -MAC_VERSION=0.1.0-dev mac/scripts/build-app.sh -MAC_VERSION=0.1.0-dev mac/scripts/make-dmg.sh +SERVER_VERSION=0.0.0-dev mac/scripts/build-runtime.sh +MAC_VERSION=0.1.0-dev mac/scripts/build-app.sh +MAC_VERSION=0.1.0-dev mac/scripts/make-dmg.sh ``` -`build-app.sh` reads these environment variables, all optional locally and all -set explicitly by CI: +Both default their versions from `git describe`, so the arguments are only +needed when you care what the result is labelled. -| Variable | Meaning | -|---|---| -| `MAC_VERSION` | version of the app itself, from the `mac/vX.Y.Z` tag | -| `SERVER_VERSION` | stamped into `cix-server` (default: nearest `server/v*` tag) | -| `CLI_VERSION` | stamped into `cix` (default: nearest `cli/v*` tag) | -| `OUT_DIR` | build directory (default `mac/dist`) | -| `SKIP_SERVER_BUILD` | `1` reuses an existing `server/dist` bundle — much faster when iterating on the launcher | -| `SKIP_SIGN` | `1` skips codesign; the result **will not run** | +| Variable | Used by | Meaning | +|---|---|---| +| `SERVER_VERSION` | runtime | the runtime's version (default: nearest `server/v*` tag) | +| `CLI_VERSION` | runtime | stamped into `cix` (default: nearest `cli/v*` tag) | +| `MAC_VERSION` | app | the app's version, from the `mac/vX.Y.Z` tag | +| `OUT_DIR` | both | build directory (default `mac/dist`) | +| `SKIP_SERVER_BUILD` | runtime | `1` reuses an existing `server/dist` bundle — much faster when iterating | +| `SKIP_VERIFY` | runtime | `1` skips the tarball round trip; do not use for a release | +| `SKIP_SIGN` | app | `1` skips codesign; the result **will not run** | `make-dmg.sh` additionally takes `DMG_LAYOUT` — see "Disk image" below. +`build-runtime.sh` unpacks the tarball it has just written into a temp +directory and checks it as a stranger would: `codesign --verify --strict` on +every Mach-O, `otool -L` for llama-server's `@rpath` dependencies, and an actual +`cix-server -v`. That check is in the script rather than in a workflow step so a +local build gets it too, and because the failure it catches is silent — a +signature the kernel rejects means SIGKILL with empty stderr, indistinguishable +from a crash. + +It also refuses to ship a payload that does not match its label: if the +extracted server reports a different version from `SERVER_VERSION`, the build +fails. That is usually `SKIP_SERVER_BUILD=1` against a stale `server/dist`. + +## Testing a runtime install without a release + +`CIX_RUNTIME_TARBALL` points the app at a local tarball instead of GitHub. It is +the only way to install a runtime for a build that has no release behind it: + +```bash +CIX_RUNTIME_TARBALL="$PWD/mac/dist/cix-runtime-0.0.0-dev-darwin-arm64.tar.gz" \ + open mac/dist/cix.app +``` + +The install directory is named from the tarball's filename, so it lands in +`~/.cix/runtime/0.0.0-dev/`. + +Update and rollback need a release to update *to*. Build a second runtime at a +higher version, serve it over HTTP with a `checksums.txt` beside it, and point +`CIX_UPDATE_BASE_URL` at a local stand-in for the GitHub API — it overrides the +base URL for **both** streams. Note that the updater filters out any version +containing `-` or `+`, so test versions have to be plain semver — `0.13.0`, not +`0.13.0-test`. + +Rollback is worth exercising deliberately, and a runtime whose `cix-server` is a +signed copy of `/bin/echo` is the cheapest way to do it: it passes every static +check and exits the moment launchd runs it, which is exactly the failure the +rollback exists for. + Release builds pass the versions in rather than deriving them, because `git describe` is not reliable on this repo: the three tag streams interleave, and at least one shipped server tag sits on a commit reachable from no branch. @@ -44,13 +111,18 @@ reachable from the tagged commit. ## Three tag streams `server/v*`, `cli/v*` and `mac/v*` are released independently. The app version -describes what the app does; the versions of the two binaries it bundles are -stamped separately and recorded in `Info.plist` as `CIXServerVersion`, -`CIXCLIVersion` and `CIXLlamaVersion`. +describes what the app does; the server it manages carries the server version. -Cut `mac/v*` tags on `main`. `git describe` walks ancestors, so a tag cut on -`develop` resolves to whatever `server/v*` happens to be reachable from there, -which has been several releases behind what actually shipped. +`Info.plist` deliberately records neither the server nor the llama version. It +used to carry `CIXServerVersion`, `CIXCLIVersion` and `CIXLlamaVersion`, and +with the runtime outside the bundle those would be claims about somebody else's +files — wrong the first time either half is updated on its own. What is actually +installed lives in `~/.cix/runtime/current/runtime.json`. + +`mac/v*` no longer needs a `server/v*` tag reachable, because nothing in the app +is stamped from one. `server/v*` still must be cut on `main`, and its +`macos-runtime` job needs a reachable `cli/v*` — the runtime bundles the CLI and +will not ship it stamped `0.0.0-dev`. ## Signing: ad-hoc, bottom up @@ -60,18 +132,21 @@ mandatory — on Apple Silicon the kernel refuses to run an unsigned executable all. It gets the code running; it does not satisfy Gatekeeper, hence the first-launch instructions shipped in the DMG. -`sign-app.sh` signs dylibs first, then each executable, then the bundle. Two -things about it are not stylistic: +`sign-app.sh` signs the launcher and then the bundle; `build-runtime.sh` signs +the runtime's dylibs first, then its executables. Two things about both are not +stylistic: - **`xattr -cr` runs first, on every build.** `server/Makefile` records the failure this avoids: on macOS 26, amfid `SIGKILL`s an ad-hoc-signed binary whose linked dylibs carry a stale signature or a `com.apple.provenance` extended attribute — with *empty stderr*. The process simply dies. Every `cp` into the staging tree recreates those conditions. -- **No `--deep`.** Apple deprecated it, and it is unreliable for a bundle that - carries four executables and ~35 dylibs directly in `Contents/MacOS` rather - than as nested `.app`/`.framework` bundles. Explicit bottom-up ordering is - verifiable at each step. +- **Dylibs before the executables that load them.** `llama-server` links its + ~35 dylibs by `@rpath`; a dylib whose signature is stale relative to the + executable loading it fails at dyld time, *after* `codesign` has reported + success on the executable itself. This is why `--deep` is not used anywhere + here — Apple deprecated it, and explicit bottom-up ordering is verifiable at + each step. `--options runtime` is deliberately not used: the hardened runtime only pays off alongside notarization, and it risks breaking `llama-server`. @@ -150,6 +225,26 @@ as a smudge. Upstream llama.cpp publishes one macOS asset, `macos-arm64`, and `server/scripts/fetch-llama.sh` refuses anything else. An x86_64 server binary -bundled with an arm64 `llama-server` assembles cleanly, signs cleanly, and dies -at the first embedding request — so `build-app.sh` checks `uname -m` and stops +paired with an arm64 `llama-server` assembles cleanly, signs cleanly, and dies +at the first embedding request — so both build scripts check `uname -m` and stop with an explanation instead. + +## A server-side bug the split surfaced + +`cix-server` persists its embedding provider config to the database on first +boot and treats the stored blob as authoritative from then on. Two of its fields +are derived from the process rather than chosen by anyone: `bin_dir`, which is +`filepath.Dir(os.Executable())/llama`, and `socket_path`, which is +`/cix-llama-.sock`. + +Frozen at first boot, `bin_dir` means every later boot launches the +`llama-server` of whichever install wrote the row — so the app would keep +running the *previous* runtime's sidecar after an update, and stop working +entirely once that version was pruned. A frozen `socket_path` defeats the +uniqueness it exists for: a new server can find an orphaned `llama-server` +already bound to that name and talk to it instead of spawning its own. + +`embeddings.RefreshOllamaSidecarPaths` re-derives both at boot +(`server/cmd/cix-server/main.go`). Everything a person can choose — the model, +the tuning — still comes from the database untouched. This is not macOS-specific: +the same thing happens to a container whose image layout changes. diff --git a/mac/scripts/build-app.sh b/mac/scripts/build-app.sh index e83895a6..b4e70e5a 100755 --- a/mac/scripts/build-app.sh +++ b/mac/scripts/build-app.sh @@ -1,111 +1,58 @@ #!/usr/bin/env bash -# build-app.sh — assemble mac/dist/cix.app from the three Go binaries plus the -# bundled llama-server. +# build-app.sh — assemble mac/dist/cix.app. # # Usage: mac/scripts/build-app.sh # -# Environment (all optional; CI sets the versions explicitly): -# MAC_VERSION version of the .app itself, from the mac/vX.Y.Z tag -# SERVER_VERSION stamped into cix-server (default: nearest server/v* tag) -# CLI_VERSION stamped into cix (default: nearest cli/v* tag) -# OUT_DIR build directory (default: mac/dist) -# SKIP_SERVER_BUILD "1" reuses an existing server/dist bundle -# SKIP_SIGN "1" skips codesign (leaves an unrunnable bundle; debug only) +# Environment (all optional; CI sets the version explicitly): +# MAC_VERSION version of the .app itself, from the mac/vX.Y.Z tag +# OUT_DIR build directory (default: mac/dist) +# SKIP_SIGN "1" skips codesign (leaves an unrunnable bundle; debug only) # -# Versions are passed in rather than derived here because `git describe` cannot -# be trusted on this repo: the tag streams interleave, and server/v0.12.8 sits on -# a commit reachable from no branch. A release must state what it is building. +# The .app contains the launcher and nothing else that runs. cix-server, the cix +# CLI and llama-server are built and packaged by build-runtime.sh, published +# alongside this bundle under the same tag, and installed into ~/.cix/runtime/ +# by the app itself — see cli/launcher/runtime_darwin.go for why. +# +# One consequence worth stating: this bundle carries no server version, so +# Info.plist records none. The truth about what is installed lives in the +# runtime's own runtime.json, which can change without this app changing. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=mac/scripts/common.sh +source "$REPO_ROOT/mac/scripts/common.sh" cd "$REPO_ROOT" +# The launcher itself is architecture-agnostic Go, but the app it manages is +# Apple Silicon only, so a bundle built anywhere else could never do its job. +require_apple_silicon "build-app" + OUT_DIR="${OUT_DIR:-$REPO_ROOT/mac/dist}" APP="$OUT_DIR/cix.app" -SERVER_BUNDLE="$REPO_ROOT/server/dist/cix-darwin-arm64" - -# --- host check ------------------------------------------------------------- -# Upstream llama.cpp publishes one macOS asset, macos-arm64, and -# server/scripts/fetch-llama.sh hard-refuses anything else. Building the Go -# binaries for x86_64 and pairing them with an arm64 llama-server produces a -# bundle that assembles cleanly, signs cleanly, and dies at first embedding — -# so refuse here instead, where the message can say why. -if [[ "$(uname -s)" != "Darwin" ]]; then - echo "build-app: macOS only (got $(uname -s))" >&2 - exit 1 -fi -if [[ "$(uname -m)" != "arm64" ]]; then - echo "build-app: Apple Silicon only — upstream llama.cpp ships no macOS x86_64 asset (got $(uname -m))" >&2 - exit 1 -fi - -# --- versions --------------------------------------------------------------- -describe_or_dev() { - local pattern="$1" prefix="$2" v - v="$(git describe --tags --match "$pattern" 2>/dev/null | sed "s|^$prefix||")" || true - printf '%s' "${v:-0.0.0-dev}" -} - MAC_VERSION="${MAC_VERSION:-dev}" -SERVER_VERSION="${SERVER_VERSION:-$(describe_or_dev 'server/v*' 'server/v')}" -CLI_VERSION="${CLI_VERSION:-$(describe_or_dev 'cli/v*' 'cli/v')}" -LLAMA_VERSION="$(sed -n 's/^LLAMA_VERSION[[:space:]]*?=[[:space:]]*//p' server/Makefile | head -n1)" -if [[ -z "$LLAMA_VERSION" ]]; then - echo "build-app: could not read LLAMA_VERSION from server/Makefile" >&2 - exit 1 -fi -echo "build-app: app=$MAC_VERSION server=$SERVER_VERSION cli=$CLI_VERSION llama=$LLAMA_VERSION" +echo "build-app: app=$MAC_VERSION" -# --- 1. server + bundled llama --------------------------------------------- -# LLAMA_STRICT=1: a release must not accept an unpinned upstream asset. See the -# strict-mode notes in server/scripts/fetch-llama.sh. -if [[ "${SKIP_SERVER_BUILD:-0}" == "1" ]]; then - echo "build-app: SKIP_SERVER_BUILD=1 — reusing $SERVER_BUNDLE" - [[ -x "$SERVER_BUNDLE/cix-server" ]] || { echo "build-app: no prebuilt server at $SERVER_BUNDLE" >&2; exit 1; } -else - # `make bundle` → `build` → `dashboard-build` runs npm scripts but never - # installs. On a clean checkout (which is every CI run) that surfaces as a - # missing-binary error from npm rather than "you need to install deps". - if [[ ! -d server/dashboard/node_modules ]]; then - echo "build-app: installing dashboard dependencies" - make -C server dashboard-deps - fi - make -C server bundle SERVER_VERSION="$SERVER_VERSION" LLAMA_STRICT=1 -fi - -# --- 2. cix CLI ------------------------------------------------------------- +# --- 1. launcher ------------------------------------------------------------ # -w without -s: govulncheck -mode=binary needs the Go symbol table. Same # reasoning, and same flags, as .github/workflows/release-cli.yml. -echo "build-app: building cix CLI" -(cd cli && go build \ - -trimpath \ - -ldflags "-w -X 'github.com/dvcdsys/code-index/cli/cmd.Version=${CLI_VERSION}'" \ - -o "$OUT_DIR/stage/cix" .) - -# --- 3. launcher ------------------------------------------------------------ echo "build-app: building cix-launcher" +mkdir -p "$OUT_DIR/stage" (cd cli && go build \ -trimpath \ -ldflags "-w -X 'main.version=${MAC_VERSION}'" \ -o "$OUT_DIR/stage/cix-launcher" ./launcher) -# --- 4. assemble ------------------------------------------------------------ +# --- 2. assemble ------------------------------------------------------------ # Rebuild the bundle from scratch. `cp` merges into an existing tree, so an -# incremental assembly accumulates artefacts from every previous build — the -# exact failure server/Makefile's `bundle` target had to fix for llama/. +# incremental assembly accumulates artefacts from every previous build — which +# now includes the server, CLI and llama/ this bundle no longer ships. echo "build-app: assembling $APP" rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" -# Everything executable lives in Contents/MacOS, including llama/. Two reasons: -# codesign --verify --strict rejects executable code under Resources/, and -# cix-server resolves llama-server at filepath.Dir(os.Executable())/llama — so -# keeping them siblings means CIX_LLAMA_BIN_DIR never has to be set. -cp "$SERVER_BUNDLE/cix-server" "$APP/Contents/MacOS/cix-server" -cp -R "$SERVER_BUNDLE/llama" "$APP/Contents/MacOS/llama" -cp "$OUT_DIR/stage/cix" "$APP/Contents/MacOS/cix" cp "$OUT_DIR/stage/cix-launcher" "$APP/Contents/MacOS/cix-launcher" +rm -rf "$OUT_DIR/stage" # The app icon is built from the iconset rather than committed as a binary, so # the PNGs stay the single source of truth. CFBundleIconFile in Info.plist.in @@ -121,9 +68,6 @@ cp mac/Resources/menubar/cixTemplate-36.png "$APP/Contents/Resources/cixTemplate sed \ -e "s|@SHORT_VERSION@|${MAC_VERSION}|g" \ -e "s|@BUNDLE_VERSION@|${MAC_VERSION}|g" \ - -e "s|@SERVER_VERSION@|${SERVER_VERSION}|g" \ - -e "s|@CLI_VERSION@|${CLI_VERSION}|g" \ - -e "s|@LLAMA_VERSION@|${LLAMA_VERSION}|g" \ mac/Info.plist.in > "$APP/Contents/Info.plist" # Catch an unsubstituted token before it ships as a literal @TOKEN@ version. @@ -138,9 +82,7 @@ plutil -lint "$APP/Contents/Info.plist" # and some archive tooling still expect it beside Info.plist, and it costs 8 bytes. printf 'APPL????' > "$APP/Contents/PkgInfo" -rm -rf "$OUT_DIR/stage" - -# --- 5. sign ---------------------------------------------------------------- +# --- 3. sign ---------------------------------------------------------------- if [[ "${SKIP_SIGN:-0}" == "1" ]]; then echo "build-app: SKIP_SIGN=1 — bundle is UNSIGNED and will be killed on launch" else diff --git a/mac/scripts/build-runtime.sh b/mac/scripts/build-runtime.sh new file mode 100755 index 00000000..f8a326b4 --- /dev/null +++ b/mac/scripts/build-runtime.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# build-runtime.sh — package the cix runtime: the server, the CLI, and the +# Metal llama-server they need. +# +# Usage: mac/scripts/build-runtime.sh +# +# Environment (all optional; CI sets the versions explicitly): +# SERVER_VERSION the runtime's version (default: nearest server/v* tag) +# CLI_VERSION stamped into cix (default: nearest cli/v* tag) +# OUT_DIR build directory (default: mac/dist) +# SKIP_SERVER_BUILD "1" reuses an existing server/dist bundle +# SKIP_VERIFY "1" skips the extract-and-check round trip (not advised) +# +# The runtime IS the server, so it carries the server's version and ships from +# the server's tag stream — the same `server/vX.Y.Z` tag that publishes the +# Docker images, built by the same workflow run. A Mac install and a container +# on the same version are the same server. The app has its own `mac/v*` stream +# and its own version, because it is a different thing that changes for +# different reasons. +# +# Why this is not inside the .app +# ------------------------------- +# It used to be. An app carrying all four binaries meant every server update +# replaced a 102 MB application through a swap trampoline that had to quit the +# launcher, move the bundle aside, move the new one in, and reopen itself. The +# runtime is 90% of that weight and nearly all of the churn. Split out, it is a +# payload the launcher installs into ~/.cix/runtime// and swaps by +# renaming a symlink, without the app going anywhere. +# +# Why the CLI travels here and not in the .app +# -------------------------------------------- +# It speaks a specific server's API, so pinning the two together is the point. +# /usr/local/bin/cix is a symlink into ~/.cix/runtime/current/, which means it +# follows updates without anyone touching /usr/local. +# +# Why llama travels with the server +# --------------------------------- +# cix-server resolves its sidecar at filepath.Dir(os.Executable())/llama. Ship +# them as siblings and CIX_LLAMA_BIN_DIR never has to be set — the same +# invariant server/Makefile's bundle target already relies on. Separating them +# would mean carrying that environment variable forever. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=mac/scripts/common.sh +source "$REPO_ROOT/mac/scripts/common.sh" +cd "$REPO_ROOT" + +require_apple_silicon "build-runtime" + +OUT_DIR="${OUT_DIR:-$REPO_ROOT/mac/dist}" +SERVER_BUNDLE="$REPO_ROOT/server/dist/cix-darwin-arm64" + +SERVER_VERSION="${SERVER_VERSION:-$(describe_or_dev 'server/v*' 'server/v')}" +CLI_VERSION="${CLI_VERSION:-$(describe_or_dev 'cli/v*' 'cli/v')}" +LLAMA_VERSION="$(read_llama_version "$REPO_ROOT")" + +# The directory name inside the tarball is also the name the launcher strips on +# extraction, and the version in it becomes the directory under +# ~/.cix/runtime/. Both are part of the format, not cosmetic. +RUNTIME_NAME="cix-runtime-$SERVER_VERSION" +STAGE="$OUT_DIR/runtime/$RUNTIME_NAME" +TARBALL="$OUT_DIR/$RUNTIME_NAME-darwin-arm64.tar.gz" + +echo "build-runtime: server=$SERVER_VERSION cli=$CLI_VERSION llama=$LLAMA_VERSION" + +# --- 1. server + bundled llama --------------------------------------------- +# LLAMA_STRICT=1: a release must not accept an unpinned upstream asset. See the +# strict-mode notes in server/scripts/fetch-llama.sh. +if [[ "${SKIP_SERVER_BUILD:-0}" == "1" ]]; then + echo "build-runtime: SKIP_SERVER_BUILD=1 — reusing $SERVER_BUNDLE" + [[ -x "$SERVER_BUNDLE/cix-server" ]] || { echo "build-runtime: no prebuilt server at $SERVER_BUNDLE" >&2; exit 1; } +else + # `make bundle` → `build` → `dashboard-build` runs npm scripts but never + # installs. On a clean checkout (which is every CI run) that surfaces as a + # missing-binary error from npm rather than "you need to install deps". + if [[ ! -d server/dashboard/node_modules ]]; then + echo "build-runtime: installing dashboard dependencies" + make -C server dashboard-deps + fi + make -C server bundle SERVER_VERSION="$SERVER_VERSION" LLAMA_STRICT=1 +fi + +# --- 2. cix CLI ------------------------------------------------------------- +# -w without -s: govulncheck -mode=binary needs the Go symbol table. Same +# reasoning, and same flags, as .github/workflows/release-cli.yml. +echo "build-runtime: building cix CLI" +mkdir -p "$OUT_DIR/stage" +(cd cli && go build \ + -trimpath \ + -ldflags "-w -X 'github.com/dvcdsys/code-index/cli/cmd.Version=${CLI_VERSION}'" \ + -o "$OUT_DIR/stage/cix" .) + +# --- 3. assemble ------------------------------------------------------------ +# Rebuild from scratch. `cp` merges into an existing tree, so an incremental +# assembly accumulates artefacts from every previous build — the exact failure +# server/Makefile's `bundle` target had to fix for llama/. +echo "build-runtime: assembling $STAGE" +rm -rf "$OUT_DIR/runtime" +mkdir -p "$STAGE" + +cp "$SERVER_BUNDLE/cix-server" "$STAGE/cix-server" +cp -R "$SERVER_BUNDLE/llama" "$STAGE/llama" +cp "$OUT_DIR/stage/cix" "$STAGE/cix" +rm -rf "$OUT_DIR/stage" + +# The manifest is what the menu reads to show what is installed. The alternative +# — exec'ing each binary with -v — costs three process spawns per menu open and +# still cannot report the llama version, which no binary here prints in a form +# worth parsing. +# +# Checked at the source rather than linted afterwards: every value below is +# interpolated straight into JSON, so the thing worth rejecting is a version +# string carrying a quote or a backslash, not a malformed file. `git describe` +# output and the Makefile pin are the only inputs, and neither should ever +# contain anything outside this set. +for v in "$SERVER_VERSION" "$CLI_VERSION" "$LLAMA_VERSION"; do + if [[ ! "$v" =~ ^[A-Za-z0-9._+-]+$ ]]; then + echo "build-runtime: version string is not safe to embed in JSON: '$v'" >&2 + exit 1 + fi +done + +cat > "$STAGE/runtime.json" <&2 + exit 1 +fi + +# --- 6. round trip ---------------------------------------------------------- +# The riskiest step in this whole pipeline is the one that looks like it cannot +# fail: does an ad-hoc signature survive tar → extract? If it does not, nothing +# says so — the binary is SIGKILLed on exec with empty stderr, indistinguishable +# from a crash. So unpack what was just written and check it as a stranger would. +if [[ "${SKIP_VERIFY:-0}" == "1" ]]; then + echo "build-runtime: SKIP_VERIFY=1 — tarball NOT round-tripped" +else + echo "build-runtime: verifying the tarball round trip" + CHECK_DIR="$(mktemp -d)" + trap 'rm -rf "$CHECK_DIR"' EXIT + + tar -xzf "$TARBALL" -C "$CHECK_DIR" + EXTRACTED="$CHECK_DIR/$RUNTIME_NAME" + + for target in cix-server cix llama/llama-server; do + codesign --verify --strict --verbose=2 "$EXTRACTED/$target" + done + for lib in "$EXTRACTED"/llama/*.dylib; do + codesign --verify --strict "$lib" + done + + check_llama_rpath_deps "$EXTRACTED/llama" + + # Actually run one. codesign --verify checks the signature is well-formed; + # only exec proves the kernel agrees, which is the thing that fails silently. + got="$("$EXTRACTED/cix-server" -v)" + echo "build-runtime: extracted server reports: $got" + case "$got" in + *"$SERVER_VERSION"*) ;; + *) + echo "build-runtime: extracted server reports '$got', expected $SERVER_VERSION" >&2 + echo "build-runtime: the payload does not match its label — $SERVER_BUNDLE is stale (a SKIP_SERVER_BUILD=1 build against an older commit?). Rebuild it." >&2 + exit 1 + ;; + esac + + "$EXTRACTED/cix" --version >/dev/null +fi + +echo "build-runtime: ready — $TARBALL" +du -sh "$TARBALL" | sed 's/^/build-runtime: size /' diff --git a/mac/scripts/common.sh b/mac/scripts/common.sh new file mode 100644 index 00000000..8a2dfa12 --- /dev/null +++ b/mac/scripts/common.sh @@ -0,0 +1,100 @@ +# shellcheck shell=bash +# common.sh — shared ground for the mac build scripts. Sourced, never executed. +# +# build-app.sh and build-runtime.sh produce two halves of one release: they are +# published under the same mac/vX.Y.Z tag and the launcher assumes they agree +# about which server, CLI and llama went into them. Deriving those versions +# twice, in two scripts, is how they would quietly stop agreeing — so it happens +# here, once. + +# --- host check ------------------------------------------------------------- +# Upstream llama.cpp publishes one macOS asset, macos-arm64, and +# server/scripts/fetch-llama.sh hard-refuses anything else. Building the Go +# binaries for x86_64 and pairing them with an arm64 llama-server produces a +# tree that assembles cleanly, signs cleanly, and dies at the first embedding — +# so refuse up front, where the message can say why. +require_apple_silicon() { + local who="${1:-build}" + if [[ "$(uname -s)" != "Darwin" ]]; then + echo "$who: macOS only (got $(uname -s))" >&2 + exit 1 + fi + if [[ "$(uname -m)" != "arm64" ]]; then + echo "$who: Apple Silicon only — upstream llama.cpp ships no macOS x86_64 asset (got $(uname -m))" >&2 + exit 1 + fi +} + +# --- versions --------------------------------------------------------------- +# Versions are passed in by CI rather than derived here because `git describe` +# cannot be trusted on this repo: the tag streams interleave, and server/v0.12.8 +# sits on a commit reachable from no branch. These fallbacks exist for local +# builds, where being obviously a development build is the correct answer. +describe_or_dev() { + local pattern="$1" prefix="$2" v + v="$(git describe --tags --match "$pattern" 2>/dev/null | sed "s|^$prefix||")" || true + printf '%s' "${v:-0.0.0-dev}" +} + +# The llama version is not a git tag — it is pinned in the Makefile that fetches +# the upstream release, which is the only place that knows what was downloaded. +read_llama_version() { + local repo_root="$1" v + v="$(sed -n 's/^LLAMA_VERSION[[:space:]]*?=[[:space:]]*//p' "$repo_root/server/Makefile" | head -n1)" + if [[ -z "$v" ]]; then + echo "could not read LLAMA_VERSION from $repo_root/server/Makefile" >&2 + exit 1 + fi + printf '%s' "$v" +} + +# --- signing ---------------------------------------------------------------- +# Ad-hoc (`--sign -`) is not a Gatekeeper credential and never will be without a +# paid Developer ID. It is still mandatory: on Apple Silicon the kernel refuses +# to run an executable carrying no signature at all. +sign_adhoc() { + local target="$1" + [[ -e "$target" ]] || { echo "sign: missing signing target: $target" >&2; exit 1; } + codesign --force --sign - "$target" +} + +# Sign a directory of Mach-O code bottom-up: libraries first, then the +# executables that load them. +# +# llama-server links its dylibs by @rpath. If a dylib's signature is stale +# relative to the executable loading it, the load fails at dyld time — after +# codesign has already reported success on the executable itself. Signing in +# this order is what prevents that, and it is why --deep (deprecated, and +# unreliable for loose executables outside nested bundles) is not used anywhere +# in this pipeline. +sign_llama_dir() { + local dir="$1" lib + local -a dylibs + shopt -s nullglob + dylibs=("$dir"/*.dylib) + shopt -u nullglob + if [[ ${#dylibs[@]} -eq 0 ]]; then + echo "sign: no dylibs found under $dir — the tree is incomplete" >&2 + exit 1 + fi + echo "sign: signing ${#dylibs[@]} dylib(s) in $(basename "$dir")" + for lib in "${dylibs[@]}"; do + sign_adhoc "$lib" + done + sign_adhoc "$dir/llama-server" +} + +# --- dependency check ------------------------------------------------------- +# Every @rpath dependency of llama-server must sit beside it. A missing one only +# fails at dyld load time, on the user's machine, with an abort — which is how +# the b10238 library-layout change was found. +check_llama_rpath_deps() { + local dir="$1" dep missing=0 + for dep in $(otool -L "$dir/llama-server" | awk '/@rpath\//{sub(/^.*@rpath\//,"",$1); print $1}'); do + if [[ ! -e "$dir/$dep" ]]; then + echo "llama-server dependency not present: $dep" >&2 + missing=1 + fi + done + return "$missing" +} diff --git a/mac/scripts/sign-app.sh b/mac/scripts/sign-app.sh index c6449a37..2458f73e 100755 --- a/mac/scripts/sign-app.sh +++ b/mac/scripts/sign-app.sh @@ -14,18 +14,18 @@ # # Why not --deep # -------------- -# `codesign --deep` is deprecated by Apple and unreliable for a bundle like this -# one, which carries four executables and a pile of dylibs directly in -# Contents/MacOS rather than as nested .app/.framework bundles. Signing bottom -# up is explicit, ordered, and each step is verifiable. +# `codesign --deep` is deprecated by Apple and unreliable in general. It is not +# needed here at all now that the bundle holds a single executable — the ordered +# dylib-then-executable signing this used to do moved to build-runtime.sh, which +# is where the dylibs went. # # Why xattr -cr first # ------------------- # server/Makefile records the failure this prevents: on macOS 26, amfid SIGKILLs -# an ad-hoc-signed binary whose linked dylibs carry a stale signature or a -# com.apple.provenance xattr, and it does so with EMPTY STDERR — the process -# just dies. Every `cp` into the staging tree recreates those conditions, so the -# strip has to happen on every build, not once at install time. +# an ad-hoc-signed binary carrying a stale signature or a com.apple.provenance +# xattr, and it does so with EMPTY STDERR — the process just dies. Every `cp` +# into the staging tree recreates those conditions, so the strip has to happen +# on every build, not once at install time. set -euo pipefail APP="${1:-}" @@ -43,34 +43,28 @@ xattr -cr "$APP" MACOS_DIR="$APP/Contents/MacOS" -sign_one() { - local target="$1" - [[ -e "$target" ]] || { echo "sign-app: missing signing target: $target" >&2; exit 1; } - codesign --force --sign - "$target" -} +# 1. The executable. +LAUNCHER="$MACOS_DIR/cix-launcher" +[[ -e "$LAUNCHER" ]] || { echo "sign-app: missing signing target: $LAUNCHER" >&2; exit 1; } -# 1. Libraries. llama-server links these by @rpath; if a dylib's signature is -# stale relative to the executable that loads it, the load fails at dyld -# time — after codesign has happily reported success on the executable. -shopt -s nullglob -dylibs=("$MACOS_DIR"/llama/*.dylib) -shopt -u nullglob -if [[ ${#dylibs[@]} -eq 0 ]]; then - echo "sign-app: no dylibs found under $MACOS_DIR/llama — the bundle is incomplete" >&2 +# The bundle should carry exactly one executable. Anything else means a stale +# tree — most likely a cix-server, cix or llama/ left over from a build that +# predates the runtime split, which would then be sealed into the signature and +# shipped as ~90 MB of dead weight. +shopt -s nullglob extglob +extra=("$MACOS_DIR"/!(cix-launcher)) +shopt -u nullglob extglob +if [[ ${#extra[@]} -gt 0 ]]; then + echo "sign-app: unexpected files in Contents/MacOS — this bundle was not built from scratch:" >&2 + printf ' %s\n' "${extra[@]}" >&2 exit 1 fi -echo "sign-app: signing ${#dylibs[@]} dylib(s)" -for lib in "${dylibs[@]}"; do - sign_one "$lib" -done -# 2. Executables, leaf-most first. -for bin in llama/llama-server cix cix-server cix-launcher; do - echo "sign-app: signing $bin" - sign_one "$MACOS_DIR/$bin" -done +echo "sign-app: signing cix-launcher" +codesign --force --sign - "$LAUNCHER" -# 3. The bundle last — this seals everything above into Contents/_CodeSignature. +# 2. The bundle last — this seals the executable and the resources into +# Contents/_CodeSignature. echo "sign-app: signing bundle" codesign --force --sign - "$APP" From 46963b3eb25243f595923b5f63a310eb2312fb40 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 17:37:00 +0100 Subject: [PATCH 12/12] test(cli): wait for FinishIndex instead of racing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tests in internal/watcher wait for BeginIndex and then assert on FinishIndex. Those are two sequential HTTP calls from the indexer goroutine, so the window between them is small but real, and on a loaded CI runner the assertion lands inside it. It surfaces as "expected FinishIndex to be called" on a test that took 0.02s — not a timeout, an assertion made too early. Adds waitForFinish for the tests that actually care about completion, and keeps waitForCalls for those that only assert BeginIndex. TestDebounce_MultipleEventsOnce had a different problem with the same shape: its deadline was ten times the 80ms debounce interval, which reads generous and is 800ms. A cold macOS runner loses that, and because the flush goroutine then ran after t.TempDir() cleanup, the failure printed as "cannot read a.go: no such file or directory" rather than as the assertion that failed. The property under test is that five events collapse into one flush, not how quickly, so it now waits on patience rather than on the interval. The shared deadline goes to 15s. It exits the instant the condition holds, so a long limit costs nothing when things work. Both failures appeared on macos-latest during the mac-runtime PRs (#229, #230) and passed on rerun; neither is caused by those changes, but the extra parallel load of a new test package is enough to make the races fire. Co-Authored-By: Claude Opus 5 --- cli/internal/watcher/watcher_test.go | 62 ++++++++++++++++------------ 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/cli/internal/watcher/watcher_test.go b/cli/internal/watcher/watcher_test.go index ec02e1a0..57113b2e 100644 --- a/cli/internal/watcher/watcher_test.go +++ b/cli/internal/watcher/watcher_test.go @@ -124,10 +124,32 @@ func newIndexServer(t *testing.T, dir string) (*httptest.Server, *serverCalls) { // waitForCalls polls the mock server counters until BeginIndex has been called // at least target times, or the deadline is reached. func waitForCalls(calls *serverCalls, target int) { - deadline := time.Now().Add(2 * time.Second) + waitForCounter(calls, target, func(c *serverCalls) int { return c.Begin }) +} + +// waitForFinish waits for FinishIndex, which is what a test asserting on +// calls.Finish actually needs. +// +// Waiting on BeginIndex and then asserting on Finish is a race, and it is the +// one this package kept losing on CI: they are two sequential HTTP calls from +// the indexer goroutine, so the window between them is small but real. It shows +// up as "expected FinishIndex to be called" on a test that took 0.02s — not a +// timeout, an assertion made too early. +func waitForFinish(calls *serverCalls, target int) { + waitForCounter(calls, target, func(c *serverCalls) int { return c.Finish }) +} + +// waitForCounter polls until a counter reaches target, or gives up. +// +// The deadline is generous on purpose. It exits the instant the condition holds, +// so a long limit costs nothing when things work and is the difference between a +// green run and a red one on a cold CI runner — which is where this package has +// failed, never locally. +func waitForCounter(calls *serverCalls, target int, get func(*serverCalls) int) { + deadline := time.Now().Add(15 * time.Second) for time.Now().Before(deadline) { calls.mu.Lock() - count := calls.Begin + count := get(calls) calls.mu.Unlock() if count >= target { return @@ -382,17 +404,10 @@ func TestFlushChanges_TriggersIncrementalReindex(t *testing.T) { w.pendingChanges[filepath.Join(dir, "main.go")] = true w.flushChanges() - // Wait for indexing to complete (it runs in a goroutine now) - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - calls.mu.Lock() - count := calls.Begin - calls.mu.Unlock() - if count >= 1 { - break - } - time.Sleep(10 * time.Millisecond) - } + // Indexing runs in a goroutine, and it ends with FinishIndex — so that is + // what to wait for. Waiting for BeginIndex and then asserting on Finish is + // the race this test used to lose on CI. + waitForFinish(calls, 1) calls.mu.Lock() defer calls.mu.Unlock() @@ -470,7 +485,7 @@ func TestFlushChanges_RecoveryAfterServerDown(t *testing.T) { w2.pendingChanges[filePath] = true w2.flushChanges() - waitForCalls(calls, 1) + waitForFinish(calls, 1) calls.mu.Lock() defer calls.mu.Unlock() @@ -492,7 +507,7 @@ func TestTriggerFullReindex_CallsAPI(t *testing.T) { w.triggerFullReindex() - waitForCalls(calls, 1) + waitForFinish(calls, 1) calls.mu.Lock() defer calls.mu.Unlock() @@ -664,17 +679,12 @@ func TestDebounce_MultipleEventsOnce(t *testing.T) { w.trackChange(filepath.Join(dir, "b.go")) } - // Poll until the debounce timer fires, with a generous deadline. - deadline := time.Now().Add(time.Duration(w.debounceMS*10) * time.Millisecond) - for time.Now().Before(deadline) { - calls.mu.Lock() - n := calls.Begin - calls.mu.Unlock() - if n >= 1 { - break - } - time.Sleep(10 * time.Millisecond) - } + // Wait for the debounce timer to fire. Ten times the debounce interval reads + // like a generous margin and is 800ms — which a cold CI runner loses. The + // property under test is that five events collapse into one flush, not how + // fast the flush arrives, so the wait is bounded by patience rather than by + // the interval. + waitForCalls(calls, 1) calls.mu.Lock() defer calls.mu.Unlock()