diff --git a/cli/go.mod b/cli/go.mod
index 0af244a..effec5c 100644
--- a/cli/go.mod
+++ b/cli/go.mod
@@ -3,7 +3,6 @@ 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
@@ -34,7 +33,6 @@ 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 33a492b..1916907 100644
--- a/cli/go.sum
+++ b/cli/go.sum
@@ -1,5 +1,3 @@
-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=
@@ -43,8 +41,6 @@ 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/launcher/dialog_darwin.go b/cli/launcher/dialog_darwin.go
index 6f9d682..26bdc4b 100644
--- a/cli/launcher/dialog_darwin.go
+++ b/cli/launcher/dialog_darwin.go
@@ -10,11 +10,14 @@ import (
"time"
)
-// osascript is the dialog mechanism for the whole launcher.
+// osascript dialogs — the FALLBACK layer.
//
-// 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.
+// The app's dialogs live inside the panel now (paneldialog_darwin.go and
+// panel.html's #dialog): alert/confirm/ask/prompt/alertWithSecret there route
+// to the webview once the AppKit side is up. What remains here is the same
+// primitives over osascript, used only in the window before the panel exists —
+// a translocated bundle refusing to run, a version query gone wrong — where a
+// native modal is the only surface available.
//
// Two rules, both load-bearing:
// - Every string that reaches AppleScript goes through quoteAS. Text here is
@@ -36,20 +39,17 @@ func quoteAS(s string) string {
return strings.Join(parts, " & return & ")
}
-// 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.
+// dialogIcon is the POSIX path to the icon osascript dialogs are drawn with.
+// Set once at startup from the bundle; empty when running outside a .app.
var dialogIcon string
-// alert shows a modal informational dialog and blocks until it is dismissed.
+// osaAlert shows a modal informational dialog and blocks until 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 {
+// folder icon. `display dialog` takes an explicit icon.
+func osaAlert(title, message string) error {
var script string
if dialogIcon != "" {
script = fmt.Sprintf(
@@ -65,25 +65,6 @@ func alert(title, message string) error {
return runOsascript(2*time.Minute, script)
}
-// alertWithSecret shows a credential and puts it on the clipboard.
-//
-// The copying happens before the window opens, not on a button, and the message
-// says so. A button was tried and was worse: AppleScript cannot make a run of
-// text clickable — `display dialog` is modal and returns only when it closes —
-// so "copy" meant closing the window and opening it again, which on screen is
-// a flash. Copying up front removes the flash and the click at once, and there
-// is nothing to be coy about: this is a password the app generated seconds ago
-// and is showing on purpose, and reaching for the clipboard is the next thing
-// anyone does with it.
-func alertWithSecret(title, message, secret, secretName string) error {
- note := fmt.Sprintf("\n\nThe %s is on your clipboard.", secretName)
- if err := copyToClipboard(secret); err != nil {
- logf("could not copy the %s to the clipboard: %v", secretName, err)
- note = fmt.Sprintf("\n\nThe %s could not be copied to your clipboard — select it above.", secretName)
- }
- return alert(title, message+note)
-}
-
// copyToClipboard pipes a value to pbcopy.
//
// Not AppleScript's `set the clipboard to`: that would put the secret into a
@@ -111,8 +92,8 @@ 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) {
+// osaPrompt asks for one line of text. Returns errCancelled on cancel.
+func osaPrompt(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(),
@@ -131,20 +112,9 @@ func prompt(title, message, defaultAnswer string) (string, error) {
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) {
- 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.
-//
+// osaAsk shows a two-button question with both labels spelled out.
// yesLabel is the default button. Dismissing the dialog counts as no.
-func ask(title, message, yesLabel, noLabel string) (bool, error) {
+func osaAsk(title, message, yesLabel, noLabel string) (bool, error) {
script := fmt.Sprintf(
`display dialog %s with title %s %s buttons {%s, %s} default button %s cancel button %s`,
quoteAS(message), quoteAS(title), iconClause(),
diff --git a/cli/launcher/dots_darwin.go b/cli/launcher/dots_darwin.go
deleted file mode 100644
index 9918125..0000000
--- a/cli/launcher/dots_darwin.go
+++ /dev/null
@@ -1,107 +0,0 @@
-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/firstrun_darwin.go b/cli/launcher/firstrun_darwin.go
index dcb41e8..734a085 100644
--- a/cli/launcher/firstrun_darwin.go
+++ b/cli/launcher/firstrun_darwin.go
@@ -40,11 +40,13 @@ const bootstrapServerName = "local"
// configuration this app should quietly repair; it is an installation that
// needs setting up again, and saying so is the whole point.
//
-// Not covered: a database file that exists but holds no users — a truncated or
-// hand-emptied one. Answering that needs to open SQLite, which would drag the
-// driver into the launcher for a case far rarer than "I deleted my data
-// directory". The server refuses to start in that state and says why in
-// ~/.cix/logs/cix-server.err.
+// Not covered here: a database file that exists but holds no users. That case
+// is not rare at all — it is what a deleted data directory turns into on the
+// very next start, because the server creates the file and runs migrations
+// BEFORE it checks for an admin account, then refuses. Answering it from here
+// would need the SQLite driver in the launcher; instead the refusal itself is
+// recognised after the fact — see isBootstrapRefusal, and the Start handler
+// that routes it back to setup.
func needsFirstRun() bool {
path, err := serverEnvPath()
if err != nil {
@@ -73,6 +75,23 @@ func needsFirstRun() bool {
return false
}
+// isBootstrapRefusal recognises the server's no-admin-account refusal in a log
+// tail.
+//
+// This closes the gap needsFirstRun leaves open. Delete ~/.cix/data and the
+// first start attempt — a login-time autostart as easily as a click — recreates
+// an empty cix.db before bootstrapAuth refuses, so from then on the file exists
+// and needsFirstRun answers false. The one thing that still knows the database
+// has no accounts is the server itself, and it says so in words this matches:
+// "incomplete bootstrap configuration" (an email left in server.env after the
+// password was retired) and "no users in database" (neither var set). Both mean
+// exactly one thing — there is no admin account and the server will not invent
+// one — and for both, running setup again is the fix.
+func isBootstrapRefusal(logTail string) bool {
+ return strings.Contains(logTail, "incomplete bootstrap configuration") ||
+ strings.Contains(logTail, "no users in database")
+}
+
// retireBootstrapPassword drops CIX_BOOTSTRAP_ADMIN_PASSWORD from server.env
// once there is a running server, and therefore an account, that no longer
// needs it.
@@ -118,7 +137,14 @@ func runFirstRun(u *updater) error {
"It is the login for the cix dashboard on this Mac — nothing is sent anywhere. " +
"A password is generated for you, and setup then downloads the server (about 40 MB)."
- email, err := prompt("Set up cix", intro, "")
+ // On a re-run the previous admin's address is still in server.env, and the
+ // most likely answer is the same one — so offer it, editable.
+ priorEmail := ""
+ if prior, err := readServerEnv(); err == nil {
+ priorEmail = strings.TrimSpace(prior["CIX_BOOTSTRAP_ADMIN_EMAIL"])
+ }
+
+ email, err := prompt("Set up cix", intro, priorEmail)
if err != nil {
return err
}
@@ -138,9 +164,12 @@ func runFirstRun(u *updater) error {
// 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.
+ showPanelBusy("Downloading the cix server…")
if err := ensureRuntime(u, logProgress); err != nil {
+ clearPanelBusy()
return fmt.Errorf("could not install the cix server: %w", err)
}
+ clearPanelBusy()
password, err := generatePassword()
if err != nil {
diff --git a/cli/launcher/firstrun_darwin_test.go b/cli/launcher/firstrun_darwin_test.go
index 0caa5c9..4be7ded 100644
--- a/cli/launcher/firstrun_darwin_test.go
+++ b/cli/launcher/firstrun_darwin_test.go
@@ -91,6 +91,36 @@ func TestNeedsFirstRun(t *testing.T) {
})
}
+// The two refusals bootstrapAuth emits on a user-less database must route to
+// setup, and other startup failures must not — a port clash is not a reason to
+// offer wiping anyone's configuration. The strings are copied from
+// server/cmd/cix-server/bootstrap.go; if the server rewords them, this test is
+// the tripwire.
+func TestIsBootstrapRefusal(t *testing.T) {
+ refusals := []string{
+ "cix-server: bootstrap auth: incomplete bootstrap configuration: " +
+ "CIX_BOOTSTRAP_ADMIN_EMAIL is set but CIX_BOOTSTRAP_ADMIN_PASSWORD is empty.",
+ "cix-server: bootstrap auth: no users in database and the bootstrap admin " +
+ "env vars are not set: refuse to start.",
+ }
+ for _, tail := range refusals {
+ if !isBootstrapRefusal(tail) {
+ t.Errorf("isBootstrapRefusal(%q) = false, want true", tail)
+ }
+ }
+
+ others := []string{
+ "listen tcp 127.0.0.1:21847: bind: address already in use",
+ "open database: unable to open database file",
+ "", // no log at all
+ }
+ for _, tail := range others {
+ if isBootstrapRefusal(tail) {
+ t.Errorf("isBootstrapRefusal(%q) = true, want false", tail)
+ }
+ }
+}
+
func TestRetireBootstrapPassword(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
diff --git a/cli/launcher/logging_darwin.go b/cli/launcher/logging_darwin.go
index 578d94a..a91a7c3 100644
--- a/cli/launcher/logging_darwin.go
+++ b/cli/launcher/logging_darwin.go
@@ -58,3 +58,8 @@ func logf(format string, args ...any) {
fmt.Fprintf(logFile, "%s %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...))
}
+
+// logProgress is the progress sink for slow work with no better surface —
+// paths that run before the menu exists, or that never got a menu reference.
+// The log is where anyone investigating a slow first launch looks anyway.
+func logProgress(msg string) { logf("%s", msg) }
diff --git a/cli/launcher/main_darwin.go b/cli/launcher/main_darwin.go
index d8b2c88..b1692fc 100644
--- a/cli/launcher/main_darwin.go
+++ b/cli/launcher/main_darwin.go
@@ -1,7 +1,6 @@
package main
import (
- "errors"
"flag"
"fmt"
"os"
@@ -63,78 +62,13 @@ func main() {
stripQuarantine(b)
- // 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.
- //
- // 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(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.
- _ = 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 {
- logf("first-run setup failed: %v", err)
- _ = alert("Setup failed", fmt.Sprintf("cix could not complete first-time setup.\n\n%v", err))
- }
- }
-
- default:
- // 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)
- }
- }
-
+ // Setup — the foreign-agent question, the first-run wizard, the runtime
+ // download — happens AFTER the panel is up, from menu.startupFlow: its
+ // dialogs render inside the panel, and the menu bar icon appears
+ // immediately instead of after a download.
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
diff --git a/cli/launcher/menu_darwin.go b/cli/launcher/menu_darwin.go
index cb3deed..cdcf4b1 100644
--- a/cli/launcher/menu_darwin.go
+++ b/cli/launcher/menu_darwin.go
@@ -10,45 +10,30 @@ import (
"sync"
"sync/atomic"
"time"
-
- "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.
+// The menu bar UI — a custom panel, not an NSMenu.
//
-// 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.
+// The AppKit half lives in panel_darwin.m and the look in panel.html; this
+// file is the behaviour. It reacts to poller changes by pushing a fresh
+// panelState into the webview, and to panel actions by running the same
+// operations the old menu ran. The design brief that replaced the NSMenu is
+// under the repo's design notes: status as a header instead of disabled rows,
+// actions weighted by importance, toggles that read as toggles.
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
- autostartItem *systray.MenuItem
- networkItem *systray.MenuItem
- resetPWItem *systray.MenuItem
- updateItem *systray.MenuItem
-
updater *updater
// busy is held while something is restarting the server, and it exists
- // because two of these menu items are not independent.
+ // because several of these actions are not independent.
//
- // Start at Login and Allow Network Access both restart the server, each in
- // its own goroutine, and each drives the same launchd label: one boots the
- // job out while the other is waiting for its pid to disappear and then
+ // Launch at Login and Allow Network Access both restart the server, each
+ // in its own goroutine, and each drives the same launchd label: one boots
+ // the job out while the other is waiting for its pid to disappear and then
// bootstraps it itself. Two bootstraps of one label leave either a failure
// or two processes racing for the port — from the outside, a server that
// went away and did not come back.
@@ -58,23 +43,26 @@ type menu struct {
// the control that would produce it, says what is actually happening.
busy atomic.Bool
+ // busyLabel names the operation that holds busy, in the words the panel's
+ // loader shows ("Stopping the server…"). Written only by the goroutine
+ // that won the busy CAS, read by render on any goroutine.
+ busyLabel atomic.Value
+
// retireOnce drops the bootstrap password from server.env the first time
// this process sees a running server. See retireBootstrapPassword.
retireOnce sync.Once
-
- // 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
}
-// beginBusy claims the right to restart the server. False means something else
-// already has it, and the caller must do nothing.
-func (m *menu) beginBusy() bool {
+// beginBusy claims the right to restart the server, and names the operation —
+// every server operation renders the same loader, and the label is the only
+// thing telling the user WHICH slow thing is happening. False means something
+// else already has the claim, and the caller must do nothing.
+func (m *menu) beginBusy(label string) bool {
if !m.busy.CompareAndSwap(false, true) {
- logf("ignored a menu action: another server operation is already running")
+ logf("ignored a panel action: another server operation is already running")
return false
}
+ m.busyLabel.Store(label)
m.render(m.poll.snapshotNow())
return true
}
@@ -84,121 +72,176 @@ func (m *menu) endBusy() {
m.poll.refresh()
}
-// detailRows is the fixed number of submenu slots. Rows with nothing to say are
-// hidden rather than left blank.
-const detailRows = 7
+func (m *menu) currentBusyLabel() string {
+ if s, ok := m.busyLabel.Load().(string); ok {
+ return s
+ }
+ return ""
+}
func runMenu(b bundle, u *updater) {
m := &menu{bundle: b, poll: newPoller(), stop: make(chan struct{}), updater: u}
- systray.Run(m.onReady, m.onExit)
+
+ panelHooks.onReady = m.onReady
+ panelHooks.onExit = m.onExit
+ panelHooks.onAction = m.onAction
+
+ // Never returns until quit; must own the main thread (see main_darwin.go).
+ panelRun(filepath.Join(b.Resources, "cixTemplate.png"))
}
// 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.
+// 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)
+ panelSetTitle(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
- // 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")
- }
- // 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
- // 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: …", "")
- 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("", "")
- m.modelItem.Disable()
- m.modelItem.Hide()
-
- systray.AddSeparator()
- m.startStopItem = systray.AddMenuItem("Start Server", "")
- 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()
- 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
- // 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()
- // 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 {
- case <-m.startStopItem.ClickedCh:
- 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 <-m.updateItem.ClickedCh:
- go m.checkForUpdates(true)
- case <-quitItem.ClickedCh:
- systray.Quit()
- return
+ m.startupFlow()
+ // A quiet background check, strictly after setup — its "update
+ // available" question must not interleave with the wizard's. 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.
+ m.checkForUpdates(false)
+ }()
+}
+
+// startupFlow is first-launch setup, run once the panel exists so its dialogs
+// render inside it (Docker-Desktop-style) rather than as separate windows.
+//
+// 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.
+//
+// The whole flow holds the busy claim: it downloads and starts things, and a
+// Start click landing in the middle of setup is exactly what busy exists to
+// refuse.
+func (m *menu) startupFlow() {
+ if !m.beginBusy("Setting up…") {
+ return // cannot happen at startup; nothing sane to do if it did
+ }
+ defer m.endBusy()
+
+ 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.
+ //
+ // 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(m.updater)
+
+ case needsFirstRun():
+ if err := runFirstRun(m.updater); 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 {
+ logf("first-run setup failed: %v", err)
+ _ = alert("Setup failed", fmt.Sprintf("cix could not complete first-time setup.\n\n%v", err))
}
}
- }()
+
+ default:
+ // 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.")
+ }
+ showPanelBusy("Downloading the cix server…")
+ if err := ensureRuntime(m.updater, m.setProgress); 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))
+ return
+ }
+ m.setProgress("")
+ clearPanelBusy()
+ // 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)
+ }
+ }
}
func (m *menu) onExit() {
close(m.stop)
}
-// watch redraws the menu whenever the poller reports a change.
+// onAction is the panel's dispatcher. Each handler blocks (dialogs, server
+// restarts) and arrives on its own goroutine — see goPanelAction.
+func (m *menu) onAction(a panelAction) {
+ switch a.Action {
+ case "opened":
+ // The panel just became visible; answer with a fresh poll so the
+ // uptime and status shown are seconds old, not up to a tick old. The
+ // log line doubles as the proof-of-life for the whole ObjC bridge —
+ // it only appears if the status item, the panel and the webview all
+ // actually came up.
+ logf("panel opened")
+ m.poll.refresh()
+ case "dialog-result":
+ resolvePanelDialog(panelDialogResult{OK: a.OK, Text: a.Text})
+ case "panel-closed":
+ // A dialog whose panel disappeared is a dialog nobody can answer any
+ // more; count it as declined so the waiting goroutine moves on.
+ resolvePanelDialog(panelDialogResult{OK: false})
+ case "toggle-server":
+ m.toggleServer()
+ case "dashboard":
+ m.openDashboard()
+ case "toggle-network":
+ m.toggleNetworkAccess()
+ case "toggle-autostart":
+ m.toggleAutostart()
+ case "reset-password":
+ m.resetPasswordFlow()
+ case "check-updates":
+ m.checkForUpdates(true)
+ case "quit":
+ panelQuit()
+ default:
+ logf("panel sent an unknown action %q", a.Action)
+ }
+}
+
+// watch redraws the panel whenever the poller reports a change.
func (m *menu) watch() {
for {
select {
@@ -211,97 +254,13 @@ 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()
- }
-
- // Anything that would restart the server is off the table while one restart
- // is already under way, and while the server is on its way up: a cold start
- // loads an embedding model and takes minutes, and a second restart landing
- // in the middle of it is precisely what leaves the job wedged.
- settling := m.busy.Load() || s.State == stateStarting
-
if s.State == stateRunning {
// Bootstrap runs before the listener opens, so a server that answers is
// a server whose admin account exists — and the password that seeded it
// has no further use.
m.retireOnce.Do(retireBootstrapPassword)
}
-
- 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")
- m.startStopItem.Disable()
- case m.busy.Load():
- m.startStopItem.SetTitle("Working…")
- 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()
- }
-
- if s.LocalOnly {
- m.networkItem.Uncheck()
- } else {
- m.networkItem.Check()
- }
- if s.Autostart {
- m.autostartItem.Check()
- } else {
- m.autostartItem.Uncheck()
- }
-
- if s.Managed && !settling {
- m.networkItem.Enable()
- m.autostartItem.Enable()
- } else {
- // Not managed: these live in files this app does not own. Settling: both
- // of them restart the server, and that is the click this guard exists to
- // refuse.
- m.networkItem.Disable()
- m.autostartItem.Disable()
- }
-
- // The password reset needs neither a running server nor a restart — it opens
- // the database directly — so it stays available in both cases, including
- // against an install-server.sh deployment.
- m.resetPWItem.Enable()
-
- // An update restarts the server too, and downloads before it does.
- if settling {
- m.updateItem.Disable()
- } else {
- m.updateItem.Enable()
- }
+ panelSetState(buildPanelState(s, m.busy.Load(), m.currentBusyLabel()))
}
// toggleAutostart flips RunAtLoad on the launchd agent.
@@ -315,8 +274,8 @@ func (m *menu) toggleAutostart() {
if !s.Managed {
return
}
- if !m.beginBusy() {
- // The checkbox already flipped visually on the click; put it back.
+ if !m.beginBusy("Applying the setting…") {
+ // The switch already flipped visually on the click; put it back.
m.render(s)
return
}
@@ -339,15 +298,38 @@ func (m *menu) toggleAutostart() {
m.poll.refresh()
}
+// upToDateMessage reports what is installed when there is nothing to offer.
+//
+// Both halves, named and versioned. The wording this replaced folded them into
+// one sentence and never said "app" at all, which read as a server-only check
+// and made the launcher's own self-update look like a feature nobody had
+// written — it is written, it just has nothing to compare against on a build
+// that came from source.
+func upToDateMessage() string {
+ msg := fmt.Sprintf("cix app\n%s\n\ncix server\n%s",
+ displayVersion(), strings.TrimPrefix(runtimeSummary(), "Server "))
+ if isDevBuild() {
+ msg += "\n\nThis app was built from source, so it does not replace " +
+ "itself — only released builds update the app. The server updates either way."
+ }
+ return msg
+}
+
// checkForUpdates looks for a newer release and, if the user agrees, installs
-// it. `explicit` distinguishes the menu item from the background check: a
+// it. `explicit` distinguishes the footer link from the background check: a
// background check that finds nothing says nothing.
func (m *menu) checkForUpdates(explicit bool) {
+ if explicit {
+ // The check takes a network round-trip; the card with a loader is what
+ // says the click worked. Every path out of here ends in a dialog that
+ // replaces it — except a decline, where the card is already gone with
+ // the question — so it never needs an explicit clear.
+ showPanelBusy("Checking for updates…")
+ }
av := m.updater.check(explicit)
if !av.any() {
if explicit {
- _ = alert("cix is up to date", fmt.Sprintf(
- "You are running cix %s with %s.", displayVersion(), strings.ToLower(runtimeSummary())))
+ _ = alert("cix is up to date", upToDateMessage())
}
return
}
@@ -363,10 +345,10 @@ func (m *menu) checkForUpdates(explicit bool) {
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())
+ what = fmt.Sprintf("cix app %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)
+ what = fmt.Sprintf("cix app %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."
}
@@ -385,7 +367,7 @@ func (m *menu) checkForUpdates(explicit bool) {
// Claimed only now, not around the check: the check is HTTP and touches
// nothing, while installing stops and starts the server like the toggles do.
- if !m.beginBusy() {
+ if !m.beginBusy("Installing the update…") {
_ = alert("cix is busy", "Another server operation is still running. Try the update again once it has finished.")
return
}
@@ -409,32 +391,7 @@ func (m *menu) checkForUpdates(explicit bool) {
// 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()
-}
-
-// 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(),
- // 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 {
- if line == "" {
- m.detail[i].Hide()
- continue
- }
- m.detail[i].SetTitle(line)
- m.detail[i].Show()
- }
+ panelQuit()
}
func (m *menu) toggleServer() {
@@ -442,7 +399,14 @@ func (m *menu) toggleServer() {
if !s.Managed {
return
}
- if !m.beginBusy() {
+ label := "Starting the server…"
+ if s.State == stateRunning {
+ label = "Stopping the server…"
+ }
+ if !m.beginBusy(label) {
+ // The panel showed a loader optimistically on the click; a refused
+ // claim must put the real state back rather than leave it spinning.
+ m.render(m.poll.snapshotNow())
return
}
defer m.endBusy()
@@ -460,19 +424,11 @@ func (m *menu) toggleServer() {
// log nobody has open, so the button appears to do nothing at all. Offer
// the thing that would actually help.
if needsFirstRun() {
- ok, err := confirm("Set cix up again?",
- "There is no cix database. If you deleted it, the server cannot start until an "+
- "administrator account is created again.\n\n"+
- "Setting up again creates a new account and a new, empty index. Anything that "+
- "was indexed before is already gone with the database.",
- "Set Up")
- if err != nil || !ok {
- return
- }
- if err := runFirstRun(m.updater); err != nil && !errors.Is(err, errCancelled) {
- logf("re-running setup failed: %v", err)
- _ = alert("Setup failed", fmt.Sprintf("cix could not set itself up again.\n\n%v", err))
- }
+ m.offerSetupAgain(
+ "There is no cix database. If you deleted it, the server cannot start until an " +
+ "administrator account is created again.\n\n" +
+ "Setting up again creates a new account and a new, empty index. Anything that " +
+ "was indexed before is already gone with the database.")
m.poll.refresh()
return
}
@@ -490,21 +446,51 @@ func (m *menu) toggleServer() {
// launchctl reports success for having spawned the process, not for the
// process surviving. A server that exits on a configuration it cannot
- // accept leaves the menu saying "Stopped" and nothing else — which is how
+ // accept leaves the panel saying "Stopped" and nothing else — which is how
// a deleted database looked like a broken Start button.
if detail := serverDiedOnStart(); detail != "" {
logf("the server exited immediately after Start: %s", detail)
+ // One refusal deserves better than its log text: no admin account. The
+ // needsFirstRun check above cannot see this case, because the failed
+ // start itself recreated an empty cix.db — the file exists, the
+ // accounts do not. The server's own words are the reliable signal, so
+ // route them to setup instead of printing them.
+ if isBootstrapRefusal(detail) {
+ m.offerSetupAgain(
+ "The cix database exists but has no accounts — this is what deleting the data " +
+ "directory looks like after a start attempt recreates an empty database.\n\n" +
+ "The server will not start until an administrator account is created again. " +
+ "Setting up again creates a new account and a new, empty index.")
+ m.poll.refresh()
+ return
+ }
_ = alert("The server stopped straight away", detail+
"\n\nThe full log is in ~/.cix/logs/cix-server.err.")
}
m.poll.refresh()
}
+// offerSetupAgain proposes re-running the setup wizard and runs it on consent.
+//
+// Shared by the two ways a gutted installation shows itself: the database file
+// is missing outright (needsFirstRun), or it exists, freshly recreated and
+// empty, and the server refused to start against it (isBootstrapRefusal).
+func (m *menu) offerSetupAgain(message string) {
+ ok, err := confirm("Set cix up again?", message, "Set Up")
+ if err != nil || !ok {
+ return
+ }
+ if err := runFirstRun(m.updater); err != nil && !errors.Is(err, errCancelled) {
+ logf("re-running setup failed: %v", err)
+ _ = alert("Setup failed", fmt.Sprintf("cix could not set itself up again.\n\n%v", err))
+ }
+}
+
// 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
+// Widening access asks first. Nothing else in this panel changes what the
+// machine exposes to the network, and a switch 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()
@@ -512,7 +498,7 @@ func (m *menu) toggleNetworkAccess() {
_ = alert("cix", "cix is not set up yet.")
return
}
- if !m.beginBusy() {
+ if !m.beginBusy("Applying the setting…") {
m.render(m.poll.snapshotNow())
return
}
@@ -530,7 +516,7 @@ func (m *menu) toggleNetworkAccess() {
"The server will restart.", serverPort(vars)),
"Allow")
if err != nil || !ok {
- // Put the checkbox back: the click already toggled it visually.
+ // Put the switch back: the click already toggled it visually.
m.render(m.poll.snapshotNow())
return
}
diff --git a/cli/launcher/panel.html b/cli/launcher/panel.html
new file mode 100644
index 0000000..99d7562
--- /dev/null
+++ b/cli/launcher/panel.html
@@ -0,0 +1,635 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cli/launcher/panel_darwin.go b/cli/launcher/panel_darwin.go
new file mode 100644
index 0000000..f982087
--- /dev/null
+++ b/cli/launcher/panel_darwin.go
@@ -0,0 +1,122 @@
+package main
+
+/*
+#cgo LDFLAGS: -framework Cocoa -framework WebKit
+#include
+
+void panel_run(const char *iconPath, const char *html);
+void panel_set_state(const char *json);
+void panel_set_title(const char *title);
+void panel_quit(void);
+*/
+import "C"
+
+import (
+ _ "embed"
+ "encoding/json"
+ "runtime"
+ "unsafe"
+)
+
+// AppKit is main-thread-only, and [NSApp run] must be started from the thread
+// the process was born on. Locking in init — before main() runs — is the
+// documented way to guarantee the main goroutine still owns that thread by the
+// time panelRun is called.
+func init() {
+ runtime.LockOSThread()
+}
+
+// The cgo bridge to panel_darwin.m. Three exported callbacks come back from
+// the Objective-C side; all of them arrive on the AppKit main thread, so each
+// hands off to Go-land immediately and returns.
+
+//go:embed panel.html
+var panelHTML string
+
+// panelHooks is set once, before panelRun, by the menu layer. Not guarded by a
+// lock: writes happen strictly before [NSApp run] starts delivering callbacks.
+var panelHooks struct {
+ onReady func()
+ onExit func()
+ onAction func(action panelAction)
+}
+
+// panelAction is one user gesture inside the panel, as posted by panel.html.
+// OK and Text carry a dialog's answer and are meaningless for other actions.
+type panelAction struct {
+ Action string `json:"action"`
+ OK bool `json:"ok"`
+ Text string `json:"text"`
+}
+
+//export goPanelReady
+func goPanelReady() {
+ // From here on, dialogs render inside the panel instead of via osascript —
+ // the webview may still be loading, but requests pushed before it finishes
+ // are replayed on load (see pendingDialog in panel_darwin.m).
+ panelUIUp.Store(true)
+ if panelHooks.onReady != nil {
+ // Off the main thread: onReady starts pollers and may do I/O.
+ go panelHooks.onReady()
+ }
+}
+
+//export goPanelExit
+func goPanelExit() {
+ if panelHooks.onExit != nil {
+ panelHooks.onExit()
+ }
+}
+
+//export goPanelAction
+func goPanelAction(cjson *C.char) {
+ raw := C.GoString(cjson)
+ var a panelAction
+ if err := json.Unmarshal([]byte(raw), &a); err != nil {
+ logf("panel sent unparseable action %q: %v", raw, err)
+ return
+ }
+ if panelHooks.onAction != nil {
+ // Every action handler blocks (dialogs, restarts), and this callback
+ // is on the AppKit main thread.
+ go panelHooks.onAction(a)
+ }
+}
+
+// panelRun starts the AppKit application and never returns until quit.
+// Must be called on the main goroutine — AppKit demands the main thread, and
+// main_darwin.go's runtime.LockOSThread guarantee comes from Go putting main()
+// there.
+func panelRun(iconPath string) {
+ cIcon := C.CString(iconPath)
+ cHTML := C.CString(panelHTML)
+ defer C.free(unsafe.Pointer(cIcon))
+ defer C.free(unsafe.Pointer(cHTML))
+ C.panel_run(cIcon, cHTML)
+}
+
+// panelSetState pushes the render-state JSON to the webview. Safe from any
+// goroutine.
+func panelSetState(state any) {
+ b, err := json.Marshal(state)
+ if err != nil {
+ logf("could not marshal panel state: %v", err)
+ return
+ }
+ cs := C.CString(string(b))
+ defer C.free(unsafe.Pointer(cs))
+ C.panel_set_state(cs)
+}
+
+// panelSetTitle puts text beside the menu bar icon (progress messages), or
+// clears it with "".
+func panelSetTitle(title string) {
+ cs := C.CString(title)
+ defer C.free(unsafe.Pointer(cs))
+ C.panel_set_title(cs)
+}
+
+// panelQuit terminates the application; goPanelExit fires on the way out.
+func panelQuit() {
+ C.panel_quit()
+}
diff --git a/cli/launcher/panel_darwin.m b/cli/launcher/panel_darwin.m
new file mode 100644
index 0000000..6d95b26
--- /dev/null
+++ b/cli/launcher/panel_darwin.m
@@ -0,0 +1,356 @@
+// panel_darwin.m — the AppKit half of the menu bar panel.
+//
+// This file owns exactly three things: the NSStatusItem in the menu bar, a
+// borderless NSPanel that drops from it, and the WKWebView inside that panel.
+// Everything the panel SHOWS comes from Go as one JSON state object
+// (panel_set_state); everything the user DOES goes back to Go as one JSON
+// action (goPanelAction). No decisions are made here — this is a projector.
+//
+// Why a webview and not native views: the design (mac/design spec) is a dense,
+// bordered, token-driven layout — square toggles, a segmented progress bar,
+// full-bleed 1.5pt dividers — that AppKit controls cannot be styled into.
+// Drawing it as custom NSViews means reimplementing layout, hover states and
+// dark mode by hand in a language this project otherwise does not use; HTML
+// does all of that natively, follows the system appearance for free
+// (prefers-color-scheme), and keeps the entire look in one reviewable file.
+// The webview loads a single embedded HTML string. No network access, no
+// remote content, no JavaScript beyond our own.
+//
+// Memory: compiled without ARC (cgo passes CFLAGS to the generated C too, and
+// -fobjc-arc does not belong there). Every object stored in a static below is
+// created with alloc/init or explicitly retained, and lives for the process —
+// this app has exactly one panel and never tears it down.
+
+#import
+#import
+
+extern void goPanelAction(const char *json);
+extern void goPanelReady(void);
+extern void goPanelExit(void);
+
+// The panel width is fixed by the design; height follows the content, reported
+// by JavaScript after every render.
+static const CGFloat kPanelWidth = 392;
+// Gap between the menu bar and the panel's top edge.
+static const CGFloat kPanelGap = 6;
+
+@interface CixPanel : NSPanel
+@end
+
+@implementation CixPanel
+// A borderless window refuses key status by default, and without it there are
+// no keyboard events — no Esc to close, no Tab through controls.
+- (BOOL)canBecomeKeyWindow {
+ return YES;
+}
+@end
+
+@interface CixController
+ : NSObject
+@end
+
+static CixController *controller;
+static NSStatusItem *statusItem;
+static CixPanel *panel;
+static WKWebView *webView;
+static NSString *pendingHTML;
+static NSString *pendingIconPath;
+static NSString *pendingState; // last state JSON, replayed on load
+static NSString *pendingDialog; // last dialog JSON, replayed on load
+static BOOL webLoaded = NO;
+static id clickMonitor;
+
+@implementation CixController
+
+- (void)applicationDidFinishLaunching:(NSNotification *)note {
+ // LSUIElement in Info.plist already makes this an accessory app; set it
+ // explicitly too so `go run` outside a bundle behaves the same.
+ [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
+
+ statusItem = [[[NSStatusBar systemStatusBar]
+ statusItemWithLength:NSVariableStatusItemLength] retain];
+ // imageNamed finds cixTemplate.png + cixTemplate@2x.png in the bundle and
+ // pairs them into one multi-representation image; the path is the fallback
+ // for running outside a bundle during development.
+ NSImage *icon = [[NSImage imageNamed:@"cixTemplate"] retain];
+ if (icon == nil) {
+ icon = [[NSImage alloc] initWithContentsOfFile:pendingIconPath];
+ }
+ if (icon != nil) {
+ // Template: macOS recolours it for dark mode and the pressed state
+ // from the alpha channel alone. Never tint it manually.
+ [icon setTemplate:YES];
+ [icon setSize:NSMakeSize(18, 18)];
+ statusItem.button.image = icon;
+ statusItem.button.imagePosition = NSImageLeft;
+ } else {
+ statusItem.button.title = @"cix";
+ }
+ statusItem.button.target = self;
+ statusItem.button.action = @selector(togglePanel:);
+
+ [self buildPanel];
+
+ // Dismiss on any click outside the app. A global monitor never sees our
+ // own events, so clicks inside the panel are unaffected.
+ clickMonitor = [[NSEvent
+ addGlobalMonitorForEventsMatchingMask:(NSEventMaskLeftMouseDown |
+ NSEventMaskRightMouseDown)
+ handler:^(NSEvent *e) {
+ [self closePanel];
+ }] retain];
+
+ goPanelReady();
+}
+
+- (void)applicationWillTerminate:(NSNotification *)note {
+ goPanelExit();
+}
+
+- (void)buildPanel {
+ panel = [[CixPanel alloc]
+ initWithContentRect:NSMakeRect(0, 0, kPanelWidth, 200)
+ styleMask:(NSWindowStyleMaskBorderless |
+ NSWindowStyleMaskNonactivatingPanel)
+ backing:NSBackingStoreBuffered
+ defer:NO];
+ panel.opaque = NO;
+ panel.backgroundColor = [NSColor clearColor];
+ // The system shadow, not a CSS one: it wraps the webview's opaque rounded
+ // rectangle exactly, and CSS shadows would need dead margins around the
+ // window to bleed into.
+ panel.hasShadow = YES;
+ panel.level = NSPopUpMenuWindowLevel;
+ panel.collectionBehavior = (NSWindowCollectionBehaviorCanJoinAllSpaces |
+ NSWindowCollectionBehaviorFullScreenAuxiliary);
+ panel.hidesOnDeactivate = NO;
+ panel.animationBehavior = NSWindowAnimationBehaviorNone;
+ panel.delegate = self; // for windowDidResignKey below
+
+ WKWebViewConfiguration *cfg =
+ [[[WKWebViewConfiguration alloc] init] autorelease];
+ [cfg.userContentController addScriptMessageHandler:self name:@"cix"];
+ webView = [[WKWebView alloc] initWithFrame:panel.contentView.bounds
+ configuration:cfg];
+ // Transparent chrome: the HTML draws the panel's rounded border itself, so
+ // the window must not paint white behind its corners. Private-ish but
+ // stable KVC key, the standard way to do this from outside WebKit.
+ [webView setValue:@NO forKey:@"drawsBackground"];
+ webView.navigationDelegate = self;
+ webView.autoresizingMask = (NSViewWidthSizable | NSViewHeightSizable);
+ // No back/forward, no context menu content worth keeping — but the default
+ // menu on a right-click exposes "Reload", which would blank the panel
+ // until the next state push. Harmless, so not worth suppressing further.
+ panel.contentView = webView;
+
+ [webView loadHTMLString:pendingHTML baseURL:nil];
+}
+
+- (void)togglePanel:(id)sender {
+ if (panel.visible) {
+ [self closePanel];
+ } else {
+ [self openPanel];
+ }
+}
+
+- (void)openPanel {
+ if (panel.visible) {
+ return;
+ }
+ NSWindow *bar = statusItem.button.window;
+ if (bar == nil) {
+ return;
+ }
+ NSRect anchor = bar.frame; // already in screen coordinates
+ NSScreen *screen = bar.screen ?: [NSScreen mainScreen];
+
+ CGFloat x = NSMidX(anchor) - kPanelWidth / 2;
+ // Keep the panel on the screen it opened from, with the same 8pt breathing
+ // room the system gives its own menus.
+ CGFloat maxX = NSMaxX(screen.visibleFrame) - kPanelWidth - 8;
+ if (x > maxX) {
+ x = maxX;
+ }
+ if (x < NSMinX(screen.visibleFrame) + 8) {
+ x = NSMinX(screen.visibleFrame) + 8;
+ }
+ [panel setFrameTopLeftPoint:NSMakePoint(x, NSMinY(anchor) - kPanelGap)];
+
+ statusItem.button.highlighted = YES;
+ [panel makeKeyAndOrderFront:nil];
+ [panel invalidateShadow];
+
+ // Tell Go the panel is being looked at: it answers with a fresh poll, so
+ // the uptime and status shown are seconds old, not up to a tick old.
+ goPanelAction("{\"action\":\"opened\"}");
+}
+
+- (void)closePanel {
+ if (!panel.visible) {
+ return;
+ }
+ [panel orderOut:nil];
+ statusItem.button.highlighted = NO;
+ // Go must hear about this: an in-panel dialog whose panel disappears is a
+ // dialog nobody can answer, and the Go side is blocked waiting on it.
+ goPanelAction("{\"action\":\"panel-closed\"}");
+}
+
+// windowDidResignKey — clicking anything that takes key status away (another
+// app, a dialog this app opens) closes the panel, matching menu behaviour.
+- (void)windowDidResignKey:(NSNotification *)note {
+ if (note.object == panel) {
+ [self closePanel];
+ }
+}
+
+- (void)setStateJSON:(NSString *)json {
+ [pendingState release];
+ pendingState = [json retain];
+ if (!webLoaded) {
+ return; // replayed from didFinishNavigation
+ }
+ [self pushState];
+}
+
+- (void)pushState {
+ if (pendingState == nil) {
+ return;
+ }
+ NSString *js =
+ [NSString stringWithFormat:@"window.cixRender && window.cixRender(%@)",
+ pendingState];
+ [webView evaluateJavaScript:js completionHandler:nil];
+}
+
+- (void)setDialogJSON:(NSString *)json {
+ [pendingDialog release];
+ pendingDialog = [json retain];
+ if (!webLoaded) {
+ return; // replayed from didFinishNavigation
+ }
+ [self pushDialog];
+}
+
+- (void)pushDialog {
+ if (pendingDialog == nil) {
+ return;
+ }
+ NSString *js =
+ [NSString stringWithFormat:@"window.cixDialog && window.cixDialog(%@)",
+ pendingDialog];
+ [webView evaluateJavaScript:js completionHandler:nil];
+}
+
+- (void)setTitle:(NSString *)title {
+ statusItem.button.title = title;
+}
+
+- (void)webView:(WKWebView *)wv
+ didFinishNavigation:(WKNavigation *)nav {
+ webLoaded = YES;
+ [self pushState];
+ [self pushDialog];
+}
+
+- (void)userContentController:(WKUserContentController *)ucc
+ didReceiveScriptMessage:(WKScriptMessage *)message {
+ if (![message.body isKindOfClass:[NSDictionary class]]) {
+ return;
+ }
+ NSDictionary *body = message.body;
+ NSString *action = body[@"action"];
+
+ // Layout messages are handled here — they are about this window, not about
+ // the server, and Go has no business resizing NSWindows.
+ if ([action isEqualToString:@"height"]) {
+ CGFloat h = [body[@"value"] doubleValue];
+ if (h < 40 || h > 1200) {
+ return;
+ }
+ NSRect f = panel.frame;
+ CGFloat top = NSMaxY(f);
+ f.size.height = h;
+ f.origin.y = top - h;
+ // No animation: the height changes either on a state flip (where the
+ // design calls for a crossfade, done in CSS) or before the panel is
+ // even visible.
+ [panel setFrame:f display:YES];
+ [panel invalidateShadow];
+ return;
+ }
+ if ([action isEqualToString:@"close"]) {
+ [self closePanel];
+ return;
+ }
+
+ NSData *data = [NSJSONSerialization dataWithJSONObject:body
+ options:0
+ error:nil];
+ if (data == nil) {
+ return;
+ }
+ NSString *json = [[[NSString alloc] initWithData:data
+ encoding:NSUTF8StringEncoding]
+ autorelease];
+
+ // Button-shaped actions dismiss the panel like a menu item would; toggle
+ // rows keep it open so the switch is seen doing its work. The distinction
+ // lives in the HTML (dismiss:true), not in a hardcoded list here.
+ if ([body[@"dismiss"] boolValue]) {
+ [self closePanel];
+ }
+ goPanelAction(json.UTF8String);
+}
+
+@end
+
+// --- C API, called from Go. Every entry point hops to the main thread: AppKit
+// is main-thread-only and Go calls these from arbitrary goroutines.
+
+void panel_run(const char *iconPath, const char *html) {
+ @autoreleasepool {
+ pendingIconPath = [[NSString stringWithUTF8String:iconPath] retain];
+ pendingHTML = [[NSString stringWithUTF8String:html] retain];
+ NSApplication *app = [NSApplication sharedApplication];
+ controller = [[CixController alloc] init];
+ app.delegate = controller;
+ [app run];
+ }
+}
+
+void panel_set_state(const char *json) {
+ NSString *s = [NSString stringWithUTF8String:json];
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [controller setStateJSON:s];
+ });
+}
+
+void panel_set_dialog(const char *json) {
+ NSString *s = [NSString stringWithUTF8String:json];
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [controller setDialogJSON:s];
+ });
+}
+
+// panel_open fronts the panel — dialogs must surface even when it is closed.
+void panel_open(void) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [controller openPanel];
+ });
+}
+
+void panel_set_title(const char *title) {
+ NSString *s = [NSString stringWithUTF8String:title];
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [controller setTitle:s];
+ });
+}
+
+void panel_quit(void) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [NSApp terminate:nil];
+ });
+}
diff --git a/cli/launcher/paneldialog_darwin.go b/cli/launcher/paneldialog_darwin.go
new file mode 100644
index 0000000..62ba3c0
--- /dev/null
+++ b/cli/launcher/paneldialog_darwin.go
@@ -0,0 +1,225 @@
+package main
+
+/*
+#include
+void panel_set_dialog(const char *json);
+void panel_open(void);
+*/
+import "C"
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "sync"
+ "sync/atomic"
+ "time"
+ "unsafe"
+)
+
+// In-panel dialogs.
+//
+// Every window this app used to open through osascript — alerts, questions,
+// the email prompt, the password display — renders inside the panel instead,
+// as an area that takes over its content (see panel.html's #dialog). One
+// window, one place to look, one visual language.
+//
+// The mechanics: a dialog request is pushed to the webview as JSON, the panel
+// is fronted so the request is actually visible, and the calling goroutine
+// blocks on a channel until the user answers, closes the panel (a dialog
+// whose window went away is answered "no"), or a generous timeout expires.
+// Dialogs serialise on a mutex — two questions at once is a UI bug, not a
+// feature.
+//
+// The osascript versions in dialog_darwin.go remain as the fallback for the
+// narrow window before the webview has loaded, and for anything that must be
+// said when the panel cannot exist (a translocated bundle refusing to run).
+
+// panelUIUp flips once the AppKit side has called goPanelReady; before that
+// there is no webview to draw a dialog in.
+var panelUIUp atomic.Bool
+
+type panelDialogSpec struct {
+ Kind string `json:"kind"` // "alert" | "confirm" | "prompt" | "secret"
+ Title string `json:"title"`
+ Message string `json:"message,omitempty"`
+ OKLabel string `json:"okLabel,omitempty"`
+ CancelLabel string `json:"cancelLabel,omitempty"`
+ DefaultAnswer string `json:"defaultAnswer,omitempty"`
+ Secret string `json:"secret,omitempty"`
+ Note string `json:"note,omitempty"`
+}
+
+type panelDialogResult struct {
+ OK bool
+ Text string
+}
+
+var dialogState struct {
+ mu sync.Mutex // serialises dialogs
+ pending chan panelDialogResult
+ pmu sync.Mutex // guards pending
+}
+
+// showPanelDialog runs one dialog to completion and returns the answer.
+func showPanelDialog(spec panelDialogSpec) (panelDialogResult, error) {
+ dialogState.mu.Lock()
+ defer dialogState.mu.Unlock()
+
+ ch := make(chan panelDialogResult, 1)
+ dialogState.pmu.Lock()
+ dialogState.pending = ch
+ dialogState.pmu.Unlock()
+
+ b, err := json.Marshal(spec)
+ if err != nil {
+ return panelDialogResult{}, err
+ }
+ pushDialogJSON(string(b))
+ panelOpen()
+
+ defer func() {
+ dialogState.pmu.Lock()
+ dialogState.pending = nil
+ dialogState.pmu.Unlock()
+ // Clear the dialog whichever way this ends — a timeout must not leave
+ // a zombie question on screen.
+ pushDialogJSON("null")
+ }()
+
+ // The same order of patience osascript got: long enough to walk away and
+ // come back, bounded so an abandoned question cannot wedge a goroutine
+ // holding the busy claim forever.
+ select {
+ case r := <-ch:
+ return r, nil
+ case <-time.After(10 * time.Minute):
+ return panelDialogResult{}, errors.New("the dialog was not answered")
+ }
+}
+
+// showPanelBusy puts up a modal card with a loader and no buttons — the
+// placeholder for a result that is on its way (an update check, most of all).
+// It answers nothing and blocks nobody; the next real dialog replaces it, and
+// clearPanelBusy removes it on paths that end without one. No-op before the
+// panel exists.
+func showPanelBusy(title string) {
+ if !panelUIUp.Load() {
+ return
+ }
+ b, err := json.Marshal(panelDialogSpec{Kind: "busy", Title: title})
+ if err != nil {
+ return
+ }
+ pushDialogJSON(string(b))
+ panelOpen()
+}
+
+func clearPanelBusy() {
+ if !panelUIUp.Load() {
+ return
+ }
+ pushDialogJSON("null")
+}
+
+// resolvePanelDialog delivers an answer from the webview (or a panel-closed
+// event). Safe to call when nothing is pending.
+func resolvePanelDialog(r panelDialogResult) {
+ dialogState.pmu.Lock()
+ defer dialogState.pmu.Unlock()
+ if dialogState.pending == nil {
+ return
+ }
+ select {
+ case dialogState.pending <- r:
+ default:
+ }
+ dialogState.pending = nil
+}
+
+func pushDialogJSON(s string) {
+ cs := C.CString(s)
+ defer C.free(unsafe.Pointer(cs))
+ C.panel_set_dialog(cs)
+}
+
+// panelOpen fronts the panel so a dialog is actually seen.
+func panelOpen() {
+ C.panel_open()
+}
+
+// --- The user-facing dialog API. Same names and contracts as the osascript
+// layer so every caller works unchanged; each routes to the panel once it is
+// up and falls back to osascript before that.
+
+func alert(title, message string) error {
+ if !panelUIUp.Load() {
+ return osaAlert(title, message)
+ }
+ _, err := showPanelDialog(panelDialogSpec{
+ Kind: "alert", Title: title, Message: message,
+ })
+ return err
+}
+
+// alertWithSecret shows a credential and puts it on the clipboard.
+//
+// The copying happens when the dialog opens, not on a button, and the note
+// says so — reaching for the clipboard is the next thing anyone does with a
+// password the app just generated on purpose. The value itself renders in a
+// selectable mono block, unlike the rest of the panel.
+func alertWithSecret(title, message, secret, secretName string) error {
+ note := fmt.Sprintf("The %s is on your clipboard.", secretName)
+ if err := copyToClipboard(secret); err != nil {
+ logf("could not copy the %s to the clipboard: %v", secretName, err)
+ note = fmt.Sprintf("The %s could not be copied to your clipboard — select it above.", secretName)
+ }
+ if !panelUIUp.Load() {
+ return osaAlert(title, message+"\n\n"+secret+"\n\n"+note)
+ }
+ _, err := showPanelDialog(panelDialogSpec{
+ Kind: "secret", Title: title, Message: message,
+ Secret: secret, Note: note,
+ })
+ return err
+}
+
+// prompt asks for one line of text. Returns errCancelled if the user cancels
+// or closes the panel.
+func prompt(title, message, defaultAnswer string) (string, error) {
+ if !panelUIUp.Load() {
+ return osaPrompt(title, message, defaultAnswer)
+ }
+ r, err := showPanelDialog(panelDialogSpec{
+ Kind: "prompt", Title: title, Message: message,
+ DefaultAnswer: defaultAnswer,
+ })
+ if err != nil {
+ return "", err
+ }
+ if !r.OK {
+ return "", errCancelled
+ }
+ return r.Text, nil
+}
+
+// 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. yesLabel is
+// the highlighted button; dismissing the dialog counts as no.
+func ask(title, message, yesLabel, noLabel string) (bool, error) {
+ if !panelUIUp.Load() {
+ return osaAsk(title, message, yesLabel, noLabel)
+ }
+ r, err := showPanelDialog(panelDialogSpec{
+ Kind: "confirm", Title: title, Message: message,
+ OKLabel: yesLabel, CancelLabel: noLabel,
+ })
+ if err != nil {
+ return false, err
+ }
+ return r.OK, nil
+}
diff --git a/cli/launcher/panelstate_darwin.go b/cli/launcher/panelstate_darwin.go
new file mode 100644
index 0000000..8a6abd5
--- /dev/null
+++ b/cli/launcher/panelstate_darwin.go
@@ -0,0 +1,159 @@
+package main
+
+import (
+ "fmt"
+ "net"
+ "os/exec"
+ "strings"
+)
+
+// panelState is the one object panel.html renders from. Field names are the
+// contract with the JavaScript side; change them in both places or not at all.
+type panelState struct {
+ State string `json:"state"` // "running" | "starting" | "stopped"
+ Busy bool `json:"busy"`
+ // BusyLabel names the operation holding busy, for the loader button —
+ // "Stopping the server…" and "Applying the setting…" are the same wait
+ // with very different explanations.
+ BusyLabel string `json:"busyLabel,omitempty"`
+ Managed bool `json:"managed"`
+ Port int `json:"port"`
+ PID int `json:"pid,omitempty"`
+ Uptime string `json:"uptime,omitempty"`
+ LocalOnly bool `json:"localOnly"`
+ LanAddr string `json:"lanAddr,omitempty"`
+ Autostart bool `json:"autostart"`
+
+ Engine string `json:"engine,omitempty"`
+ EngineReady bool `json:"engineReady"`
+ Model string `json:"model,omitempty"`
+ ServerVersion string `json:"serverVersion,omitempty"`
+
+ // What is on disk, shown when nothing is answering.
+ Runtime string `json:"runtime,omitempty"`
+ AppVersion string `json:"appVersion,omitempty"`
+
+ IndexingJobs int `json:"indexingJobs"`
+ Projects int `json:"projects"`
+}
+
+// buildPanelState folds a poller snapshot and the menu's own busy flag into
+// what the panel shows.
+func buildPanelState(s snapshot, busy bool, busyLabel string) panelState {
+ ps := panelState{
+ Busy: busy,
+ BusyLabel: busyLabel,
+ Managed: s.Managed,
+ Port: s.Port,
+ PID: s.PID,
+ LocalOnly: s.LocalOnly,
+ Autostart: s.Autostart,
+ Runtime: currentRuntimeVersion(),
+ AppVersion: displayVersion(),
+ }
+
+ switch s.State {
+ case stateRunning:
+ ps.State = "running"
+ ps.Uptime = processUptime(s.PID)
+ case stateStarting:
+ ps.State = "starting"
+ default:
+ ps.State = "stopped"
+ }
+
+ if !s.LocalOnly {
+ ps.LanAddr = lanAddr(s.Port)
+ }
+
+ if s.Status != nil {
+ ps.Engine = providerLabel(s.Status.EmbeddingProvider)
+ ps.EngineReady = s.EmbeddingsOK
+ ps.Model = s.ModelName()
+ ps.ServerVersion = s.Status.ServerVersion
+ ps.IndexingJobs = s.Status.ActiveIndexingJobs
+ ps.Projects = s.Status.Projects
+ }
+
+ return ps
+}
+
+// processUptime asks ps for the elapsed time of a pid and renders it the way
+// the design's header expects ("4h 12m"). Empty when anything goes wrong —
+// an uptime is decoration, never worth an error dialog.
+func processUptime(pid int) string {
+ if pid == 0 {
+ return ""
+ }
+ out, err := exec.Command("ps", "-o", "etime=", "-p", fmt.Sprint(pid)).Output()
+ if err != nil {
+ return ""
+ }
+ return formatEtime(strings.TrimSpace(string(out)))
+}
+
+// formatEtime converts ps's [[dd-]hh:]mm:ss into a two-unit human string.
+// Split out from processUptime so the parsing is testable without a process.
+func formatEtime(etime string) string {
+ if etime == "" {
+ return ""
+ }
+ days := 0
+ if d, rest, ok := strings.Cut(etime, "-"); ok {
+ if _, err := fmt.Sscanf(d, "%d", &days); err != nil {
+ return ""
+ }
+ etime = rest
+ }
+ parts := strings.Split(etime, ":")
+ nums := make([]int, 0, 3)
+ for _, p := range parts {
+ var n int
+ if _, err := fmt.Sscanf(p, "%d", &n); err != nil {
+ return ""
+ }
+ nums = append(nums, n)
+ }
+
+ var h, m int
+ switch len(nums) {
+ case 3:
+ h, m = nums[0], nums[1]
+ case 2:
+ m = nums[0]
+ default:
+ return ""
+ }
+ h += days * 24
+
+ // Two units at most: "2d 3h", "4h 12m", "7m". Seconds are noise on an
+ // uptime and the panel repolls anyway.
+ switch {
+ case h >= 24:
+ return fmt.Sprintf("%dd %dh", h/24, h%24)
+ case h > 0:
+ return fmt.Sprintf("%dh %dm", h, m)
+ default:
+ return fmt.Sprintf("%dm", m)
+ }
+}
+
+// lanAddr names the address the server is reachable at from other machines —
+// the consequence the network toggle's hint line exists to state. Best-effort:
+// the first non-loopback IPv4 is the address a home network knows this Mac by.
+func lanAddr(port int) string {
+ addrs, err := net.InterfaceAddrs()
+ if err != nil {
+ return ""
+ }
+ for _, a := range addrs {
+ ipnet, ok := a.(*net.IPNet)
+ if !ok || ipnet.IP.IsLoopback() {
+ continue
+ }
+ if ip4 := ipnet.IP.To4(); ip4 != nil {
+ return fmt.Sprintf("%s:%d", ip4, port)
+ }
+ }
+ return ""
+}
diff --git a/cli/launcher/resetpw_darwin.go b/cli/launcher/resetpw_darwin.go
index 578d558..3685c36 100644
--- a/cli/launcher/resetpw_darwin.go
+++ b/cli/launcher/resetpw_darwin.go
@@ -51,11 +51,13 @@ func (m *menu) resetPasswordFlow() {
if err != nil || !ok {
return
}
+ showPanelBusy("Downloading the cix server…")
if err := ensureRuntime(m.updater, m.setProgress); err != nil {
_ = alert("Could not install the cix server", err.Error())
return
}
m.setProgress("")
+ clearPanelBusy()
}
server, err := runtimeServerPath()
diff --git a/cli/launcher/selfupdate_darwin_test.go b/cli/launcher/selfupdate_darwin_test.go
new file mode 100644
index 0000000..1df36ae
--- /dev/null
+++ b/cli/launcher/selfupdate_darwin_test.go
@@ -0,0 +1,349 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// Self-update, end to end, against a fake releases API.
+//
+// This path had never been executed: no mac/v* release exists, so the app half
+// of the updater has only ever seen an empty stream. Everything it does before
+// the swap is reachable here — the version comparison that decides to offer,
+// the download, the checksum, mounting the image and validating the signature —
+// and the swap script itself is driven with a real process to wait for and a
+// stub `open` on PATH.
+
+// fakeRelease builds a DMG containing a minimal signed cix.app and serves it,
+// with checksums.txt, from a fake GitHub releases API. Returns the base URL.
+func fakeRelease(t *testing.T, version string) string {
+ t.Helper()
+ dir := t.TempDir()
+
+ // A bundle, not a stub directory: stageFromDMG runs `codesign --verify
+ // --strict` on what it copies out, and that is a check worth exercising
+ // rather than working around.
+ app := filepath.Join(dir, "payload", "cix.app")
+ macOS := filepath.Join(app, "Contents", "MacOS")
+ if err := os.MkdirAll(macOS, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ plist := fmt.Sprintf(`
+
+
+CFBundleIdentifiercom.dvcdsys.cix.test
+CFBundleNamecix
+CFBundleExecutablecix-launcher
+CFBundleShortVersionString%s
+`, version)
+ if err := os.WriteFile(filepath.Join(app, "Contents", "Info.plist"), []byte(plist), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // Any Mach-O will do; the point is that a signature seals it. `true` is
+ // present on every macOS and is tiny.
+ if out, err := exec.Command("cp", "/usr/bin/true", filepath.Join(macOS, "cix-launcher")).CombinedOutput(); err != nil {
+ t.Fatalf("cp: %v: %s", err, out)
+ }
+ if out, err := exec.Command("codesign", "--force", "--sign", "-", app).CombinedOutput(); err != nil {
+ t.Skipf("codesign unavailable in this environment: %v: %s", err, out)
+ }
+
+ dmgName := "cix-" + version + "-arm64.dmg"
+ dmgPath := filepath.Join(dir, dmgName)
+ if out, err := exec.Command("hdiutil", "create", "-quiet", "-srcfolder",
+ filepath.Join(dir, "payload"), "-volname", "cix", "-format", "UDZO", dmgPath).CombinedOutput(); err != nil {
+ t.Skipf("hdiutil unavailable in this environment: %v: %s", err, out)
+ }
+
+ sums, err := exec.Command("shasum", "-a", "256", dmgPath).Output()
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The workflow writes "./name.dmg"; checksumFor matches on the base name,
+ // so keep the awkward shape rather than a tidied one.
+ sumLine := strings.Fields(string(sums))[0] + " ./" + dmgName + "\n"
+ sumsPath := filepath.Join(dir, "checksums.txt")
+ if err := os.WriteFile(sumsPath, []byte(sumLine), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ mux := http.NewServeMux()
+ var base string
+ mux.HandleFunc("/repos/dvcdsys/code-index/releases", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("ETag", `"fake-etag"`)
+ _ = json.NewEncoder(w).Encode([]map[string]any{{
+ "tag_name": "mac/v" + version,
+ "html_url": "https://example.invalid",
+ "assets": []map[string]any{
+ {"name": dmgName, "browser_download_url": base + "/dl/" + dmgName},
+ {"name": "checksums.txt", "browser_download_url": base + "/dl/checksums.txt"},
+ },
+ }})
+ })
+ mux.Handle("/dl/", http.StripPrefix("/dl/", http.FileServer(http.Dir(dir))))
+
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ base = srv.URL
+ return srv.URL
+}
+
+func TestSelfUpdateOffersAndStages(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home) // prefs and the update cache live under it
+
+ base := fakeRelease(t, "9.9.9")
+ t.Setenv("CIX_UPDATE_BASE_URL", base)
+
+ // The installed app, in a directory this test owns — the staged copy lands
+ // beside it, which is what the real swap needs.
+ apps := filepath.Join(home, "Applications")
+ root := filepath.Join(apps, "cix.app")
+ if err := os.MkdirAll(root, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ // A stamped build: "dev" deliberately refuses to update itself, and that
+ // refusal is why nothing was ever offered on a locally built app.
+ restore := version
+ version = "0.1.0"
+ t.Cleanup(func() { version = restore })
+
+ u := newUpdater(bundle{Root: root})
+ av := u.check(true)
+ if av.App.Version != "9.9.9" {
+ t.Fatalf("check() offered app %q, want 9.9.9", av.App.Version)
+ }
+ // Only the mac stream is published in the fake API, so the runtime half
+ // must stay quiet rather than treating "no releases" as an update.
+ if av.Runtime.Version != "" {
+ t.Errorf("check() offered runtime %q from a stream with no releases", av.Runtime.Version)
+ }
+
+ staged, err := u.stageUpdate(av.App, func(string) {})
+ if err != nil {
+ t.Fatalf("stageUpdate: %v", err)
+ }
+ if want := filepath.Join(apps, ".cix.app.new"); staged != want {
+ t.Errorf("staged at %q, want %q", staged, want)
+ }
+ // Validated, not merely copied: the signature survived the DMG round trip.
+ if out, err := exec.Command("codesign", "--verify", "--strict", staged).CombinedOutput(); err != nil {
+ t.Errorf("staged bundle fails verification: %v: %s", err, out)
+ }
+ if b, err := os.ReadFile(filepath.Join(staged, "Contents", "Info.plist")); err != nil {
+ t.Error(err)
+ } else if !strings.Contains(string(b), "9.9.9") {
+ t.Error("the staged bundle is not the version that was offered")
+ }
+
+ // A dev build must not be offered the app half, whatever is published.
+ version = "dev"
+ if got := u.check(true); got.App.Version != "" {
+ t.Errorf("a development build was offered app %q", got.App.Version)
+ }
+}
+
+// A corrupted download must be discarded rather than installed: with no
+// Developer ID signature, this checksum is the whole integrity story.
+func TestSelfUpdateRejectsATamperedImage(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ base := fakeRelease(t, "9.9.9")
+ t.Setenv("CIX_UPDATE_BASE_URL", base)
+
+ apps := filepath.Join(home, "Applications")
+ root := filepath.Join(apps, "cix.app")
+ if err := os.MkdirAll(root, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ restore := version
+ version = "0.1.0"
+ t.Cleanup(func() { version = restore })
+
+ u := newUpdater(bundle{Root: root})
+ av := u.check(true)
+
+ // Point the DMG asset at the checksums file: the bytes fetched are then not
+ // the bytes the release lists, which is exactly what a truncated or swapped
+ // download looks like.
+ for i, a := range av.App.Assets {
+ if strings.HasSuffix(a.Name, ".dmg") {
+ av.App.Assets[i].URL = base + "/dl/checksums.txt"
+ }
+ }
+
+ staged, err := u.stageUpdate(av.App, func(string) {})
+ if err == nil {
+ t.Fatal("stageUpdate accepted an image that does not match its checksum")
+ }
+ if !strings.Contains(err.Error(), "checksum") {
+ t.Errorf("error = %v, want it to name the checksum", err)
+ }
+ if staged != "" {
+ t.Errorf("staged = %q, want nothing staged", staged)
+ }
+ if _, err := os.Stat(filepath.Join(apps, ".cix.app.new")); !os.IsNotExist(err) {
+ t.Error("a rejected update left a staged bundle behind")
+ }
+}
+
+// The up-to-date dialog is the only place most people ever read the app's own
+// version, so what it says on a released build — as opposed to one built from
+// source — is worth pinning.
+func TestUpToDateMessage(t *testing.T) {
+ restore := version
+ t.Cleanup(func() { version = restore })
+
+ t.Run("released build", func(t *testing.T) {
+ version = "0.1.0"
+ msg := upToDateMessage()
+ if !strings.Contains(msg, "cix app\nv0.1.0") {
+ t.Errorf("message does not report the app version:\n%s", msg)
+ }
+ // The caveat belongs only to builds that cannot update themselves;
+ // on a release it would be a lie.
+ if strings.Contains(msg, "built from source") {
+ t.Errorf("a released build claims it cannot update itself:\n%s", msg)
+ }
+ })
+
+ t.Run("development build", func(t *testing.T) {
+ version = "dev"
+ msg := upToDateMessage()
+ if !strings.Contains(msg, "development build") {
+ t.Errorf("message does not say it is a development build:\n%s", msg)
+ }
+ // Why nothing is ever offered — without it the check looks broken.
+ if !strings.Contains(msg, "built from source") {
+ t.Errorf("message does not explain why it never updates:\n%s", msg)
+ }
+ })
+}
+
+// swap.sh is the one part that runs after the app is gone, so its failure modes
+// are the ones nobody is around to see.
+func TestSwapScript(t *testing.T) {
+ dir := t.TempDir()
+
+ // Stub `open`, so the successful path can be exercised without handing a
+ // fake bundle to LaunchServices.
+ bin := filepath.Join(dir, "bin")
+ if err := os.MkdirAll(bin, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ openLog := filepath.Join(dir, "open.log")
+ if err := os.WriteFile(filepath.Join(bin, "open"),
+ []byte("#!/bin/bash\necho \"$@\" >> "+openLog+"\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", bin+":"+os.Getenv("PATH"))
+
+ script := filepath.Join(dir, "swap.sh")
+ if err := os.WriteFile(script, swapScript, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ newBundle := func(t *testing.T, path, marker string) {
+ t.Helper()
+ if err := os.MkdirAll(path, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(path, "marker"), []byte(marker), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ t.Run("replaces the bundle once the launcher exits", func(t *testing.T) {
+ live := filepath.Join(dir, "live", "cix.app")
+ staged := filepath.Join(dir, "live", ".cix.app.new")
+ newBundle(t, live, "old")
+ newBundle(t, staged, "new")
+
+ // A real process to wait for, which exits shortly after the script
+ // starts — the sequence the update actually produces.
+ victim := exec.Command("/bin/sleep", "1")
+ if err := victim.Start(); err != nil {
+ t.Fatal(err)
+ }
+ // Reaped concurrently, and that detail is not incidental: an exited
+ // child whose parent has not waited on it keeps its pid in the process
+ // table, where `kill -0` still succeeds — so an unreaped victim makes
+ // swap.sh wait out its full patience and abort. In the real sequence
+ // the launcher is a child of launchd, which reaps it immediately.
+ reaped := make(chan struct{})
+ go func() { _ = victim.Wait(); close(reaped) }()
+
+ out, err := exec.Command("/bin/bash", script, fmt.Sprint(victim.Process.Pid),
+ live, staged, filepath.Join(dir, "swap.log")).CombinedOutput()
+ <-reaped
+ if err != nil {
+ t.Fatalf("swap.sh: %v: %s", err, out)
+ }
+
+ if b, err := os.ReadFile(filepath.Join(live, "marker")); err != nil || string(b) != "new" {
+ t.Errorf("live bundle = %q (%v), want the staged one", b, err)
+ }
+ if _, err := os.Stat(staged); !os.IsNotExist(err) {
+ t.Error("the staged bundle was left behind")
+ }
+ // Nothing half-updated: the moved-aside copy is cleaned up.
+ if _, err := os.Stat(live + ".old"); !os.IsNotExist(err) {
+ t.Error("the previous bundle was left at .old")
+ }
+ if b, err := os.ReadFile(openLog); err != nil || !strings.Contains(string(b), live) {
+ t.Errorf("swap.sh did not reopen the app: %q (%v)", b, err)
+ }
+ })
+
+ t.Run("refuses to swap under a live launcher", func(t *testing.T) {
+ if testing.Short() {
+ // The guard is a 60s timeout, and there is no way to observe it
+ // firing without spending the 60s.
+ t.Skip("takes a minute: swap.sh's patience is the thing under test")
+ }
+ live := filepath.Join(dir, "alive", "cix.app")
+ staged := filepath.Join(dir, "alive", ".cix.app.new")
+ newBundle(t, live, "old")
+ newBundle(t, staged, "new")
+
+ // Outlives the script's 60s patience, so the guard is what ends it.
+ victim := exec.Command("/bin/sleep", "120")
+ if err := victim.Start(); err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ _ = victim.Process.Kill()
+ _ = victim.Wait()
+ }()
+
+ done := make(chan error, 1)
+ cmd := exec.Command("/bin/bash", script, fmt.Sprint(victim.Process.Pid),
+ live, staged, filepath.Join(dir, "swap-alive.log"))
+ go func() { done <- cmd.Run() }()
+
+ select {
+ case err := <-done:
+ if err == nil {
+ t.Error("swap.sh replaced a bundle under a running launcher")
+ }
+ case <-time.After(75 * time.Second):
+ _ = cmd.Process.Kill()
+ t.Fatal("swap.sh did not give up waiting for a launcher that never exits")
+ }
+
+ if b, err := os.ReadFile(filepath.Join(live, "marker")); err != nil || string(b) != "old" {
+ t.Errorf("live bundle = %q (%v), want it untouched", b, err)
+ }
+ })
+}
diff --git a/cli/launcher/status_darwin.go b/cli/launcher/status_darwin.go
index 846f05d..b14aac2 100644
--- a/cli/launcher/status_darwin.go
+++ b/cli/launcher/status_darwin.go
@@ -1,8 +1,6 @@
package main
import (
- "fmt"
- "image/color"
"strings"
"sync"
"time"
@@ -91,168 +89,15 @@ 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 details
-// submenu.
-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"
- }
- 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
- }
-}
-
-// 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 {
- // "(managed externally)" spelled out is 40 characters and would set
- // the width of the whole menu on its own. The details submenu 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 {
- 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.
- return row("Model: ", s.ModelName())
-}
-
-// ModelName is the untruncated model, for the tooltip.
+// ModelName strips the provider prefix off the reported model id.
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.
+ // the provider kind ("ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF"). The panel
+ // already names the provider on its engine row, 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
diff --git a/cli/launcher/status_darwin_test.go b/cli/launcher/status_darwin_test.go
index af51550..9b9d038 100644
--- a/cli/launcher/status_darwin_test.go
+++ b/cli/launcher/status_darwin_test.go
@@ -5,9 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
- "strings"
"testing"
- "unicode/utf8"
"github.com/dvcdsys/code-index/cli/internal/client"
)
@@ -29,114 +27,80 @@ func TestProviderLabel(t *testing.T) {
}
}
-func TestSnapshotLines(t *testing.T) {
+// buildPanelState is the whole contract between the poller and panel.html —
+// every field the JavaScript renders comes through it.
+func TestBuildPanelState(t *testing.T) {
running := snapshot{
- State: stateRunning,
- Port: 21847,
- Managed: true,
+ State: stateRunning, PID: 4242, Port: 21847,
+ Managed: true, LocalOnly: true, Autostart: true,
Status: &client.StatusResponse{
- EmbeddingProvider: "ollama",
- EmbeddingModel: "ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF",
+ ServerVersion: "0.12.9",
+ EmbeddingProvider: "ollama",
+ EmbeddingModel: "ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF",
+ ActiveIndexingJobs: 2,
+ Projects: 14,
},
EmbeddingsOK: true,
}
-
- if got, want := running.ServerLine(), "cix-server: Running (:21847)"; got != want {
- t.Errorf("ServerLine() = %q, want %q", 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)
+ ps := buildPanelState(running, false, "")
+ if ps.State != "running" || ps.PID != 4242 || ps.Port != 21847 {
+ t.Errorf("state/pid/port = %q/%d/%d, want running/4242/21847", ps.State, ps.PID, ps.Port)
}
- // 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)
+ // The provider kind is translated, and the model loses the misleading
+ // provider prefix — same reasoning as providerLabel.
+ if ps.Engine != "llama.cpp (bundled)" || !ps.EngineReady {
+ t.Errorf("engine = %q ready=%v, want the bundled label and ready", ps.Engine, ps.EngineReady)
}
- if got, want := running.ModelName(), "awhiteside/CodeRankEmbed-Q8_0-GGUF"; got != want {
- t.Errorf("ModelName() = %q, want %q (the details submenu keeps the full id)", got, want)
+ if ps.Model != "awhiteside/CodeRankEmbed-Q8_0-GGUF" {
+ t.Errorf("model = %q, want the prefix stripped", ps.Model)
}
-}
-
-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)
+ if ps.ServerVersion != "0.12.9" || ps.IndexingJobs != 2 || ps.Projects != 14 {
+ t.Errorf("version/jobs/projects = %q/%d/%d", ps.ServerVersion, ps.IndexingJobs, ps.Projects)
}
- // 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 !ps.LocalOnly || ps.LanAddr != "" {
+ t.Errorf("a loopback-bound server must not advertise a LAN address, got %q", ps.LanAddr)
}
- if got, want := starting.EmbeddingsLine(), "Embeddings: unknown"; got != want {
- t.Errorf("EmbeddingsLine() = %q, want %q", got, want)
+ if !ps.Autostart {
+ t.Error("autostart lost on the way through")
}
- // 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 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)
- }
- // 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())
+ // Provider details from a server that is not answering are stale by
+ // definition, so none are claimed.
+ starting := buildPanelState(snapshot{State: stateStarting, Managed: true}, false, "")
+ if starting.State != "starting" || starting.Engine != "" || starting.Model != "" {
+ t.Errorf("starting = %+v, want no provider details", starting)
}
-}
-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)
+ // The busy flag is the menu's, not the poller's; it must pass through with
+ // its label — the flag is what the panel disables its controls on, and the
+ // label is the only thing telling the user which slow operation is running.
+ busy := buildPanelState(running, true, "Stopping the server…")
+ if !busy.Busy || busy.BusyLabel != "Stopping the server…" {
+ t.Errorf("busy/label = %v/%q, want them passed through", busy.Busy, busy.BusyLabel)
}
- // 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)
+ stopped := buildPanelState(snapshot{State: stateStopped, Managed: false}, false, "")
+ if stopped.State != "stopped" || stopped.Managed {
+ t.Errorf("stopped external = %+v", stopped)
}
- 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)
+}
+
+// formatEtime parses everything ps -o etime is documented to print.
+func TestFormatEtime(t *testing.T) {
+ tests := map[string]string{
+ "04:12": "4m",
+ "00:42": "0m",
+ "4:12:33": "4h 12m",
+ "04:12:33": "4h 12m",
+ "2-03:04:05": "2d 3h",
+ "12-00:00:01": "12d 0h",
+ "": "",
+ "garbage": "",
+ }
+ for in, want := range tests {
+ if got := formatEtime(in); got != want {
+ t.Errorf("formatEtime(%q) = %q, want %q", in, got, want)
+ }
}
}
@@ -196,80 +160,17 @@ func TestPollerTrustsHTTPProviders(t *testing.T) {
}
}
-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 details submenu.
+// ModelName keeps the untruncated id — panel.html shows it whole and relies on
+// CSS to fit it, so the only transformation allowed here is the prefix strip.
+func TestModelName(t *testing.T) {
+ s := snapshot{Status: &client.StatusResponse{
+ EmbeddingModel: "ollama:some-extremely-long-organisation/an-even-longer-model-name-v2",
+ }}
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")
+ if got := (snapshot{}).ModelName(); got != "" {
+ t.Errorf("ModelName() with no status = %q, want empty", got)
}
}
diff --git a/cli/launcher/update_darwin.go b/cli/launcher/update_darwin.go
index 0722a59..7d994d7 100644
--- a/cli/launcher/update_darwin.go
+++ b/cli/launcher/update_darwin.go
@@ -301,16 +301,28 @@ func serverSurvivedRestart(baseURL string, timeout time.Duration) bool {
// 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 {
+ staged, err := u.stageUpdate(rel, progress)
+ if err != nil {
+ return err
+ }
+ return u.launchSwap(staged)
+}
+
+// stageUpdate downloads and validates the new .app, leaving it beside the
+// installed one ready to be moved into place. Split from updateLauncher so
+// everything that can fail is reachable from a test: the swap itself ends the
+// process, and a test cannot wait for its own exit.
+func (u *updater) stageUpdate(rel release.Release, progress func(string)) (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)
+ 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)
+ return "", fmt.Errorf("release %s has no checksums.txt attached", rel.TagName)
}
// Preflight before spending a download. Writing into the bundle's parent is
@@ -318,43 +330,42 @@ func (u *updater) updateLauncher(rel release.Release, progress func(string)) err
// 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"+
+ 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
+ return "", err
}
if err := os.MkdirAll(cacheDir, 0o700); err != nil {
- return err
+ 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)
+ 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)
+ 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)
+ 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
+ return "", err
}
-
- return u.launchSwap(staged)
+ return staged, nil
}
// launchSwap writes the swap script to a temp file and starts it detached.