From c09cf40796c0631682fde403a77b2fb1d443c69d Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 18:14:44 +0100 Subject: [PATCH 01/12] fix(mac): do not style the wrong disk when a volume named cix is mounted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hdiutil mounts an image at "/Volumes/cix 1" when /Volumes/cix is taken, but make-dmg's Finder layout addresses the disk by name. With two volumes called cix, Finder styles the other one — and says nothing: osascript exits 0, the script prints "layout applied", and the image ships with no .DS_Store, so no background and no icon positions. Leaving the previous DMG open in Finder is enough to cause it, which makes it a near-certainty when iterating locally and rare on a CI runner. Bisected: same script and inputs, occupied /Volumes/cix produces no .DS_Store, clean /Volumes produces one. Detaches a leftover disk image of that name before creating, and asserts the mount point afterwards. A real volume that happens to be called cix is left alone and stops the build instead — ejecting somebody's disk to make a DMG is not a trade worth making. Co-Authored-By: Claude Opus 5 --- mac/README.md | 14 ++++++++++++-- mac/scripts/make-dmg.sh | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/mac/README.md b/mac/README.md index 74e3446..cafa846 100644 --- a/mac/README.md +++ b/mac/README.md @@ -179,8 +179,18 @@ means: Once a release has proved the runner can do it, `require` is the right setting. -Three details that cost real debugging time: - +Four details that cost real debugging time: + +- **A volume named `cix` already mounted makes the whole step a no-op — and a + silent one.** hdiutil then mounts the build's image at `/Volumes/cix 1`, the + AppleScript addresses the disk *by name*, and Finder styles the other one. + `osascript` still exits 0, so the build prints "layout applied" and ships an + image with no `.DS_Store`: no background, no icon positions. Leaving the last + DMG open in Finder is enough to trigger it, which makes it a near-certainty on + a development machine and rare on CI. The script now detaches a leftover + *disk image* of that name before creating (a real volume so named stops the + build instead), and refuses to continue if the mount point still comes back + suffixed. - **The volume icon must be installed after the Finder pass**, not staged up front. Staging it looks like it works — `hdiutil` copies the file, `SetFile` sets the flag — and then Finder removes both while laying out the window, and diff --git a/mac/scripts/make-dmg.sh b/mac/scripts/make-dmg.sh index 80f75a9..cec61d6 100755 --- a/mac/scripts/make-dmg.sh +++ b/mac/scripts/make-dmg.sh @@ -110,6 +110,29 @@ hdiutil create \ "$RW_DMG" echo "make-dmg: mounting" +# A volume called cix already mounted — a previous build, or simply the last +# DMG still open in Finder — makes hdiutil mount this one at "/Volumes/cix 1". +# The Finder layout below addresses the disk BY NAME, and with two volumes of +# that name it styles the wrong one. Silently: osascript still exits 0, so the +# build reports "layout applied" and ships an image with no .DS_Store, hence no +# background and no icon positions. Bisected: same script and inputs, occupied +# /Volumes/cix produces no .DS_Store, clean /Volumes produces one. +# +# Only a disk image is detached here. A real volume that happens to be called +# cix is somebody's disk, and ejecting it to build a DMG would be outrageous — +# so that case stops the build instead. +stale_mount="/Volumes/$VOLNAME" +if [[ -d "$stale_mount" ]]; then + if hdiutil info | sed -n 's|.*\(/Volumes/.*\)$|\1|p' | grep -qxF "$stale_mount"; then + echo "make-dmg: detaching a leftover disk image at $stale_mount" + hdiutil detach "$stale_mount" -force -quiet || true + else + echo "make-dmg: $stale_mount exists and is not a disk image — refusing to touch it." >&2 + echo "make-dmg: eject or rename that volume and run again." >&2 + exit 1 + fi +fi + ATTACH_OUT="$(hdiutil attach -readwrite -noverify -noautoopen "$RW_DMG")" ATTACHED_DEV="$(printf '%s\n' "$ATTACH_OUT" | awk '/^\/dev\// { print $1; exit }')" MOUNT_POINT="$(printf '%s\n' "$ATTACH_OUT" | sed -n 's|.*\(/Volumes/.*\)$|\1|p' | tail -1)" @@ -120,6 +143,16 @@ if [[ -z "$ATTACHED_DEV" || -z "$MOUNT_POINT" ]]; then fi echo "make-dmg: mounted $ATTACHED_DEV at $MOUNT_POINT" +# Belt and braces to the detach above. If the image still landed on a suffixed +# path, another volume of this name appeared between then and now, and the +# Finder step would style it instead of this one — producing an unstyled image +# and reporting success. Stop rather than ship that. +if [[ "$MOUNT_POINT" != "/Volumes/$VOLNAME" ]]; then + echo "make-dmg: mounted at $MOUNT_POINT, expected /Volumes/$VOLNAME." >&2 + echo "make-dmg: another volume named $VOLNAME is in the way; the window layout would be applied to it." >&2 + exit 1 +fi + # --- Window layout ---------------------------------------------------------- # This is the one step that needs Finder, and Finder needs a real GUI session. # On a CI runner that may be unavailable, or blocked by an automation-consent From d9aa0c91ae5d8920ab301bd56d1864134796eae0 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 18:24:46 +0100 Subject: [PATCH 02/12] feat(mac): copy generated passwords from the dialog, and say less to ask more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to what the first-run wizard and the password reset put on screen. **The password is now one click away from the clipboard.** Both dialogs show a credential that has to be typed into a browser, and a value like Ab3-xY_9kQ is exactly the thing people mistype. AppleScript cannot make a run of text clickable — `display dialog` draws one static string — so the click target is a button, and the dialog re-shows after copying rather than dismissing: a password displayed once is what someone reaches for twice, and a window that vanishes on the first attempt is how credentials end up being read off a screenshot. The value goes to pbcopy through a pipe rather than through AppleScript's `set the clipboard to`, which would put a secret inside a script string, one quoting mistake from being interpreted. **The wizard leads with the instruction.** It opened with why an account is needed and buried "enter the email address" in the second paragraph, which reads like a sign-up form — and left unanswered the question everybody actually asks: where is my address going. Nowhere. Saying so is worth more than the explanation it replaced. Also corrects the Gatekeeper instructions, in doc/MACOS_APP.md and in the release body. They promised one trip to System Settings. There are two: the downloaded disk image is refused when it is opened, and the app is refused again on first launch, because it inherits the quarantine flag from the image it was dragged out of. Confirmed with a genuinely quarantined build — `spctl -a -t open` rejects the image whether or not it carries the flag; the flag only decides whether that verdict is enforced. Co-Authored-By: Claude Opus 5 --- .github/workflows/release-mac.yml | 17 ++++++----- cli/launcher/dialog_darwin.go | 50 +++++++++++++++++++++++++++++++ cli/launcher/firstrun_darwin.go | 17 +++++++---- cli/launcher/resetpw_darwin.go | 5 ++-- doc/MACOS_APP.md | 27 ++++++++++------- 5 files changed, 91 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release-mac.yml b/.github/workflows/release-mac.yml index d9f1557..aad2270 100644 --- a/.github/workflows/release-mac.yml +++ b/.github/workflows/release-mac.yml @@ -141,18 +141,21 @@ jobs: That is the same build the Docker images are cut from, and it updates on its own schedule: a new server does not need a new app. - ### First launch will be blocked — this is expected + ### macOS will block it twice — this is expected cix is open source and is **not** signed with a paid Apple Developer - certificate, so macOS refuses the first launch with "cix cannot be - verified". + certificate, so it is not notarized. macOS refuses both the disk + image and the app inside it with "Apple could not verify…". Nothing + is wrong with the download — choose **Done**, never *Move to Bin*. - To allow it: **System Settings → Privacy & Security**, scroll to - **Security**, then click **Open Anyway** next to the message about cix. + Clear each one the same way: **System Settings → Privacy & Security**, + scroll to **Security**, then **Open Anyway** next to the message. + Once for the `.dmg` when you open it, and once for **cix.app** the + first time you launch it — the app inherits the quarantine flag from + the image, so clearing the first does not clear the second. On macOS 15 and later the old right-click → Open shortcut no longer - works — you have to use System Settings. You only need to do this - once per installed version. + works for either. You do this once per installed version. ### Verify the download diff --git a/cli/launcher/dialog_darwin.go b/cli/launcher/dialog_darwin.go index 0509d68..ea680c5 100644 --- a/cli/launcher/dialog_darwin.go +++ b/cli/launcher/dialog_darwin.go @@ -65,6 +65,56 @@ func alert(title, message string) error { return runOsascript(2*time.Minute, script) } +// alertWithCopy is alert() plus a button that puts secret on the clipboard. +// +// AppleScript cannot make a run of text clickable — `display dialog` draws one +// static string — so the click target has to be a button. That is the whole +// reason this is not simply "click the password". +// +// It re-shows the dialog after copying rather than dismissing. A password +// displayed once is exactly the thing someone reaches for a second time, and a +// window that disappears on the first attempt is how people end up reading +// credentials off a screenshot. The loop always terminates: every turn of it +// waits for a click. +func alertWithCopy(title, message, secret, copyLabel string) error { + body := message + for { + script := fmt.Sprintf( + `display dialog %s with title %s %s buttons {%s, "Done"} default button "Done"`, + quoteAS(body), quoteAS(title), iconClause(), quoteAS(copyLabel), + ) + out, err := outputOsascript(5*time.Minute, script) + if err != nil { + // Dismissing the window is a perfectly good way to say "I have it". + if errors.Is(err, errCancelled) { + return nil + } + return err + } + if !strings.Contains(out, copyLabel) { + return nil + } + + if err := copyToClipboard(secret); err != nil { + logf("could not copy to the clipboard: %v", err) + body = message + "\n\nCould not copy to the clipboard." + continue + } + body = message + "\n\nCopied to the clipboard." + } +} + +// copyToClipboard pipes a value to pbcopy. +// +// Not AppleScript's `set the clipboard to`: that would put the secret into a +// script string, where it is one quoting mistake away from being interpreted. +// A pipe carries bytes and nothing else. +func copyToClipboard(s string) error { + cmd := exec.Command("pbcopy") + cmd.Stdin = strings.NewReader(s) + return cmd.Run() +} + // errCancelled is returned when the user dismissed a dialog instead of // answering it. osascript reports this as exit status 1 — the same status as a // real failure — so it has to be told apart from the error text. diff --git a/cli/launcher/firstrun_darwin.go b/cli/launcher/firstrun_darwin.go index 1879567..11f2f88 100644 --- a/cli/launcher/firstrun_darwin.go +++ b/cli/launcher/firstrun_darwin.go @@ -43,10 +43,14 @@ func needsFirstRun() bool { // Returns errCancelled if the user backs out, which is not an error condition — // the app stays running with Start disabled until they complete setup. func runFirstRun(u *updater) error { - intro := "cix needs an administrator account before it can start.\n\n" + - "Enter the email address to sign in with. A password will be generated for you, " + - "and you will be asked to change it the first time you log in.\n\n" + - "Setup then downloads the cix server itself — around 40 MB — which takes a moment." + // Short, and leading with the thing to type. The first version of this + // opened with why an account is needed and buried the instruction in the + // second paragraph — which reads like a sign-up form, and the one question + // it left unanswered was the one everybody asks: where is my address going. + // Nowhere. Saying so is worth more than the explanation it replaced. + intro := "Enter an email address for the administrator account.\n\n" + + "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, "") if err != nil { @@ -138,10 +142,11 @@ func runFirstRun(u *updater) error { "model, which can take a few minutes; the menu bar will show it as running when it is ready." } - return alert("cix is set up", fmt.Sprintf( + return alertWithCopy("cix is set up", fmt.Sprintf( "Sign in at %s\n\nEmail:\n%s\n\nTemporary password:\n%s\n\n"+ "You will be asked to change this password on first login.%s%s", - dashboardURL(vars), email, password, waitNote, cliNote)) + dashboardURL(vars), email, password, waitNote, cliNote), + password, "Copy Password") } // registerWithCLI adds (or updates) the local server in ~/.cix/config.yaml. diff --git a/cli/launcher/resetpw_darwin.go b/cli/launcher/resetpw_darwin.go index df00412..f37ce3c 100644 --- a/cli/launcher/resetpw_darwin.go +++ b/cli/launcher/resetpw_darwin.go @@ -70,10 +70,11 @@ func (m *menu) resetPasswordFlow() { return } - _ = alert("Password reset", fmt.Sprintf( + _ = alertWithCopy("Password reset", fmt.Sprintf( "Account:\n%s\n\nTemporary password:\n%s\n\n"+ "You will be asked to change it at the next sign-in. Other sessions for this "+ - "account have been signed out.", email, password)) + "account have been signed out.", email, password), + password, "Copy Password") } // runResetPassword executes the reset and returns the generated password. diff --git a/doc/MACOS_APP.md b/doc/MACOS_APP.md index 7f92f36..2181d13 100644 --- a/doc/MACOS_APP.md +++ b/doc/MACOS_APP.md @@ -43,23 +43,30 @@ Verify the download first if you like: shasum -a 256 -c checksums.txt ``` -### The first launch is blocked, and that is expected +### macOS will block it twice, and that is expected -macOS will refuse to open the app the first time, reporting that it "cannot be -verified" or "is damaged". Neither is true. cix is open source and is signed -**ad-hoc** rather than with a paid Apple Developer certificate, so it has no -Gatekeeper trust. +cix is open source and is signed **ad-hoc** rather than with a paid Apple +Developer certificate, so it is not notarized and has no Gatekeeper trust. +Anything downloaded carries a quarantine flag, and macOS refuses both the disk +image and the app inside it, reporting that Apple "could not verify" them. It +is not damaged and there is nothing wrong with the download. -To allow it: +Each block is cleared the same way: 1. **System Settings → Privacy & Security** -2. Scroll down to **Security**. There is a message about cix being blocked. +2. Scroll down to **Security**. There is a message naming what was blocked. 3. Click **Open Anyway** and confirm. -This is once per installed version. +You will do this twice: once for `cix--arm64.dmg` when you open it, +and once for **cix.app** the first time you launch it. The app inherits the +quarantine flag from the image it was dragged out of, so clearing the first +does not clear the second. After that the app opens normally, until the next +version. -On macOS 15 and later, right-clicking the app and choosing **Open** no longer -works as a shortcut for this — the System Settings route is the only one. +Choose **Done**, never **Move to Bin**, when the dialog appears. + +On macOS 15 and later, right-clicking and choosing **Open** no longer works as +a shortcut for either — the System Settings route is the only one. ### Why not Homebrew? From f9f6fa32ea5faaeba69874b710045ef30adfcfe6 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 18:40:09 +0100 Subject: [PATCH 03/12] fix(mac): notice a deleted database, retire the bootstrap password, refuse concurrent restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects found by walking the install path by hand. **A deleted database went unnoticed.** needsFirstRun looked only for ~/.cix/server.env. Delete ~/.cix/data and the app carried on as a configured install: the server recreated the directory itself, found it empty, and minted a fresh admin from the bootstrap credentials still sitting in server.env — using the original generated password rather than whichever one the user had set since. Observed live at 18:31, "bootstrap admin created from CIX_BOOTSTRAP_ADMIN_EMAIL + CIX_BOOTSTRAP_ADMIN_PASSWORD" in the server log, with nothing on screen to say a database had gone. Setup is now needed when either the config file or the database it names is missing. **The bootstrap password never left.** It seeds the first admin and has no use afterwards, but it stayed on disk and stayed authoritative — a credential outliving the account it created, and quietly overriding a password rotated in the dashboard the next time the database was recreated. It is now removed once a running server proves the account exists (bootstrap runs before the listener opens, so an answering /health is that proof). The email stays: it is not a credential, and the reset-password dialog offers it as a default. Because the wizard can now run a second time, it merges onto the existing server.env instead of rebuilding it from defaults. Reverting someone's port or, worse, their network-access choice would be a bigger surprise than the one this removes. **Two menu items could restart the server at once.** Start at Login and Allow Network Access each restart it, each in its own goroutine, and each drives the same launchd label: one boots the job out while the other waits 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. Reported from use: click one, then the other during the restart, and the server hangs. A busy flag now gates every action that restarts the server, and render() respects it — without that the poller would re-enable the controls five seconds into the operation. The same guard covers a server that is still starting: a cold start loads an embedding model and takes minutes, and a restart landing in the middle of that is exactly the wedge. Serialising instead of refusing would only queue a second restart nobody asked for. Reset Password stays available throughout: it opens the database directly and neither needs a running server nor restarts one. Co-Authored-By: Claude Opus 5 --- cli/launcher/env_darwin.go | 8 ++ cli/launcher/firstrun_darwin.go | 128 +++++++++++++++++---- cli/launcher/firstrun_darwin_test.go | 160 +++++++++++++++++++++++++++ cli/launcher/menu_darwin.go | 98 +++++++++++++++- 4 files changed, 366 insertions(+), 28 deletions(-) create mode 100644 cli/launcher/firstrun_darwin_test.go diff --git a/cli/launcher/env_darwin.go b/cli/launcher/env_darwin.go index 84cf63e..cabab3b 100644 --- a/cli/launcher/env_darwin.go +++ b/cli/launcher/env_darwin.go @@ -185,3 +185,11 @@ func dashboardURL(vars map[string]string) string { func localBaseURL(vars map[string]string) string { return fmt.Sprintf("http://localhost:%d", serverPort(vars)) } + +// setDefault fills a key only when it has no value yet, so re-running setup +// keeps whatever the user has chosen since the first time. +func setDefault(vars map[string]string, key, value string) { + if strings.TrimSpace(vars[key]) == "" { + vars[key] = value + } +} diff --git a/cli/launcher/firstrun_darwin.go b/cli/launcher/firstrun_darwin.go index 11f2f88..c9f403a 100644 --- a/cli/launcher/firstrun_darwin.go +++ b/cli/launcher/firstrun_darwin.go @@ -27,14 +27,80 @@ import ( const bootstrapServerName = "local" -// needsFirstRun reports whether ~/.cix/server.env is missing. +// needsFirstRun reports whether this installation still has to be set up. +// +// Two conditions, and the second one matters more than it looks: no +// ~/.cix/server.env, or no database at the path that file names. +// +// Keying on the config file alone was wrong. Delete ~/.cix/data and the app +// carried on as a configured install — the server then recreated the directory +// itself and silently minted a fresh admin from the bootstrap credentials still +// sitting in server.env, with the original generated password rather than +// whichever one the user had since set. A missing database is not a +// 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. func needsFirstRun() bool { path, err := serverEnvPath() if err != nil { return false } - _, err = os.Stat(path) - return errors.Is(err, os.ErrNotExist) + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return true + } + + vars, err := readServerEnv() + if err != nil { + // Present but unreadable. Running the wizard would overwrite a file we + // cannot even parse, so leave it alone and let the failure surface. + logf("could not read %s: %v", path, err) + return false + } + db := strings.TrimSpace(vars["CIX_SQLITE_PATH"]) + if db == "" { + // Hand-edited beyond what we wrote. Not ours to second-guess. + return false + } + if _, err := os.Stat(db); errors.Is(err, os.ErrNotExist) { + logf("configured database %s is missing — treating this as an unconfigured install", db) + return true + } + return false +} + +// retireBootstrapPassword drops CIX_BOOTSTRAP_ADMIN_PASSWORD from server.env +// once there is a running server, and therefore an account, that no longer +// needs it. +// +// The password seeds the very first admin and has no purpose afterwards, but it +// used to stay on disk forever — and stay authoritative. Wipe the database and +// the account came back with THAT password, not the one the user had set in the +// dashboard; a credential outliving the account it created, silently. +// +// The email stays: it is harmless, and the password-reset dialog offers it as +// the address to reset. +// +// Called only when the server is confirmed up. Bootstrap runs before the HTTP +// listener opens, so an answering /health is proof the account exists. +func retireBootstrapPassword() { + vars, err := readServerEnv() + if err != nil { + return + } + if _, ok := vars["CIX_BOOTSTRAP_ADMIN_PASSWORD"]; !ok { + return + } + delete(vars, "CIX_BOOTSTRAP_ADMIN_PASSWORD") + if err := writeServerEnv(vars); err != nil { + logf("could not remove the bootstrap password from server.env: %v", err) + return + } + logf("removed CIX_BOOTSTRAP_ADMIN_PASSWORD from server.env — the admin account exists") } // runFirstRun walks the user through creating the admin account, writes @@ -94,26 +160,40 @@ func runFirstRun(u *updater) error { return err } - vars := map[string]string{ - "CIX_BOOTSTRAP_ADMIN_EMAIL": email, - "CIX_BOOTSTRAP_ADMIN_PASSWORD": password, - // Imported as an "env-bootstrap" legacy key when the admin is created - // on a fresh DB (bootstrap.go), so the CLI works from the first boot - // without anyone visiting the dashboard to mint one. - "CIX_API_KEY": apiKey, - "CIX_PORT": strconv.Itoa(defaultServerPort), - "CIX_DATA_DIR": dataDir, - "CIX_SQLITE_PATH": filepath.Join(dataDir, "cix.db"), - // Loopback by default, unlike the server's own all-interfaces default. - // A container has to be reachable from outside itself; a desktop app - // does not, and exposing a code index to the local network is a choice - // someone should make on purpose. The menu has a toggle for it. - "CIX_BIND_ADDR": bindLocalOnly, - // The .app owns updating itself. Leaving the server's own check on - // would mean two different components offering the user two different - // "update available" prompts for two different tag streams. - "CIX_VERSION_CHECK_ENABLED": "false", + // Start from whatever is already configured. The wizard runs a second time + // when the database has gone missing, and on that path server.env exists and + // holds decisions the user made since — the port, and whether the server is + // reachable from the network. Rebuilding the file from defaults would revert + // them without saying so, which is a worse surprise than the one this whole + // change exists to remove. + vars, err := readServerEnv() + if err != nil { + vars = map[string]string{} } + + // Always fresh: the account and its key are being created now. On a repeat + // run the previous key died with the database it was imported into. + vars["CIX_BOOTSTRAP_ADMIN_EMAIL"] = email + vars["CIX_BOOTSTRAP_ADMIN_PASSWORD"] = password + // Imported as an "env-bootstrap" legacy key when the admin is created on a + // fresh DB (bootstrap.go), so the CLI works from the first boot without + // anyone visiting the dashboard to mint one. + vars["CIX_API_KEY"] = apiKey + + // Defaults only where nothing has been chosen. + setDefault(vars, "CIX_PORT", strconv.Itoa(defaultServerPort)) + setDefault(vars, "CIX_DATA_DIR", dataDir) + setDefault(vars, "CIX_SQLITE_PATH", filepath.Join(dataDir, "cix.db")) + // Loopback by default, unlike the server's own all-interfaces default. A + // container has to be reachable from outside itself; a desktop app does not, + // and exposing a code index to the local network is a choice someone should + // make on purpose. The menu has a toggle for it. + setDefault(vars, "CIX_BIND_ADDR", bindLocalOnly) + // The .app owns updating itself. Leaving the server's own check on would + // mean two different components offering the user two different "update + // available" prompts for two different tag streams. + setDefault(vars, "CIX_VERSION_CHECK_ENABLED", "false") + if err := writeServerEnv(vars); err != nil { return fmt.Errorf("write server.env: %w", err) } @@ -140,6 +220,10 @@ func runFirstRun(u *updater) error { // minutes, in silence. Saying so beats a spinner that gives up. waitNote = "\n\nThe server is still starting. First boot downloads and loads the embedding " + "model, which can take a few minutes; the menu bar will show it as running when it is ready." + } else { + // The account exists — bootstrap runs before the listener opens — so the + // password that seeded it has done its job and stops being kept. + retireBootstrapPassword() } return alertWithCopy("cix is set up", fmt.Sprintf( diff --git a/cli/launcher/firstrun_darwin_test.go b/cli/launcher/firstrun_darwin_test.go new file mode 100644 index 0000000..f78753d --- /dev/null +++ b/cli/launcher/firstrun_darwin_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// writeTestEnv lays down a server.env like the wizard's, pointing at dbPath. +func writeTestEnv(t *testing.T, home, dbPath string, extra map[string]string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(home, ".cix"), 0o700); err != nil { + t.Fatal(err) + } + vars := map[string]string{ + "CIX_BOOTSTRAP_ADMIN_EMAIL": "someone@example.com", + "CIX_BOOTSTRAP_ADMIN_PASSWORD": "generated-once", + "CIX_API_KEY": "cix_testkey", + "CIX_PORT": "21847", + "CIX_SQLITE_PATH": dbPath, + "CIX_BIND_ADDR": bindLocalOnly, + } + for k, v := range extra { + if v == "" { + delete(vars, k) + continue + } + vars[k] = v + } + if err := writeServerEnv(vars); err != nil { + t.Fatal(err) + } +} + +func touch(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("not really sqlite"), 0o600); err != nil { + t.Fatal(err) + } +} + +// Setup is needed when the database is gone, not only when the config is. +// +// Keying on server.env alone meant that deleting ~/.cix/data left the app +// believing it was configured — and the server then minted a fresh admin from +// the bootstrap credentials still in that file, using the original generated +// password rather than whatever the user had set since. +func TestNeedsFirstRun(t *testing.T) { + t.Run("no server.env", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if !needsFirstRun() { + t.Error("needsFirstRun() = false with no server.env") + } + }) + + t.Run("server.env but no database", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + writeTestEnv(t, home, filepath.Join(home, ".cix", "data", "cix.db"), nil) + if !needsFirstRun() { + t.Error("needsFirstRun() = false with a configured database that does not exist") + } + }) + + t.Run("both present", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + db := filepath.Join(home, ".cix", "data", "cix.db") + writeTestEnv(t, home, db, nil) + touch(t, db) + if needsFirstRun() { + t.Error("needsFirstRun() = true on a complete installation") + } + }) + + t.Run("hand-edited env with no database path", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + writeTestEnv(t, home, "", map[string]string{"CIX_SQLITE_PATH": ""}) + // Nothing to check against, so nothing to conclude. Running the wizard + // here would overwrite a file somebody edited on purpose. + if needsFirstRun() { + t.Error("needsFirstRun() = true on an env file with no CIX_SQLITE_PATH") + } + }) +} + +func TestRetireBootstrapPassword(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + db := filepath.Join(home, ".cix", "data", "cix.db") + writeTestEnv(t, home, db, map[string]string{"CIX_BIND_ADDR": bindAllInterfaces}) + touch(t, db) + + retireBootstrapPassword() + + vars, err := readServerEnv() + if err != nil { + t.Fatal(err) + } + if _, ok := vars["CIX_BOOTSTRAP_ADMIN_PASSWORD"]; ok { + t.Error("the bootstrap password survived") + } + // The email is what the reset-password dialog offers as a default, and it + // is not a credential. + if vars["CIX_BOOTSTRAP_ADMIN_EMAIL"] != "someone@example.com" { + t.Errorf("email = %q, want it kept", vars["CIX_BOOTSTRAP_ADMIN_EMAIL"]) + } + // Nothing else may be lost on the way through: the API key is what the CLI + // authenticates with, and the bind address is a choice the user made. + if vars["CIX_API_KEY"] != "cix_testkey" { + t.Errorf("api key = %q, want it kept", vars["CIX_API_KEY"]) + } + if vars["CIX_BIND_ADDR"] != bindAllInterfaces { + t.Errorf("bind addr = %q, want it kept", vars["CIX_BIND_ADDR"]) + } + + // The file still holds an API key, so the mode still matters. + path, _ := serverEnvPath() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != envFileMode { + t.Errorf("server.env mode = %v, want %v", perm, os.FileMode(envFileMode)) + } + + // Idempotent: the menu calls this whenever it first sees a running server. + retireBootstrapPassword() + if vars, err := readServerEnv(); err != nil || vars["CIX_API_KEY"] != "cix_testkey" { + t.Errorf("second call damaged the file: %v %v", vars, err) + } +} + +// The wizard runs again after a database is deleted, and on that path it must +// not quietly revert settings chosen since the first run — the network toggle +// most of all, because reverting it changes what the machine exposes. +func TestSetDefaultKeepsExistingChoices(t *testing.T) { + vars := map[string]string{ + "CIX_BIND_ADDR": bindAllInterfaces, + "CIX_PORT": " ", // whitespace is not a choice + } + setDefault(vars, "CIX_BIND_ADDR", bindLocalOnly) + setDefault(vars, "CIX_PORT", "21847") + setDefault(vars, "CIX_VERSION_CHECK_ENABLED", "false") + + if vars["CIX_BIND_ADDR"] != bindAllInterfaces { + t.Errorf("bind addr = %q, want the existing choice kept", vars["CIX_BIND_ADDR"]) + } + if vars["CIX_PORT"] != "21847" { + t.Errorf("port = %q, want the blank value replaced", vars["CIX_PORT"]) + } + if vars["CIX_VERSION_CHECK_ENABLED"] != "false" { + t.Errorf("version check = %q, want the default filled in", vars["CIX_VERSION_CHECK_ENABLED"]) + } +} diff --git a/cli/launcher/menu_darwin.go b/cli/launcher/menu_darwin.go index 4ab0df1..76e92ee 100644 --- a/cli/launcher/menu_darwin.go +++ b/cli/launcher/menu_darwin.go @@ -6,6 +6,8 @@ import ( "os/exec" "path/filepath" "strings" + "sync" + "sync/atomic" "time" "fyne.io/systray" @@ -40,12 +42,47 @@ type menu struct { updater *updater + // busy is held while something is restarting the server, and it exists + // because two of these menu items 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 + // 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. + // + // Serialising them would only queue the second restart behind the first, + // which is not what anyone clicking meant. Refusing the click, and greying + // the control that would produce it, says what is actually happening. + busy atomic.Bool + + // 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 { + if !m.busy.CompareAndSwap(false, true) { + logf("ignored a menu action: another server operation is already running") + return false + } + m.render(m.poll.snapshotNow()) + return true +} + +func (m *menu) endBusy() { + m.busy.Store(false) + 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 @@ -191,6 +228,19 @@ func (m *menu) render(s snapshot) { 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 @@ -198,6 +248,9 @@ func (m *menu) render(s snapshot) { // 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() @@ -226,17 +279,27 @@ func (m *menu) render(s snapshot) { m.autostartItem.Uncheck() } - if s.Managed { + if s.Managed && !settling { m.networkItem.Enable() m.autostartItem.Enable() - m.resetPWItem.Enable() } else { - // These live in files this app does not own — except the password - // reset, which only needs the database and is therefore still useful - // against an install-server.sh deployment. + // 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() - m.resetPWItem.Enable() + } + + // 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() } } @@ -251,6 +314,12 @@ func (m *menu) toggleAutostart() { if !s.Managed { return } + if !m.beginBusy() { + // The checkbox already flipped visually on the click; put it back. + m.render(s) + return + } + defer m.endBusy() enable := !s.Autostart if err := setAutostart(enable); err != nil { @@ -313,6 +382,14 @@ func (m *menu) checkForUpdates(explicit bool) { // leaving it with a llama sidecar from a different version. wasRunning := m.poll.snapshotNow().PID != 0 + // 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() { + _ = alert("cix is busy", "Another server operation is still running. Try the update again once it has finished.") + return + } + defer m.endBusy() + quit, err := m.updater.install(av, wasRunning, m.setProgress) if err != nil { logf("update failed: %v", err) @@ -364,6 +441,10 @@ func (m *menu) toggleServer() { if !s.Managed { return } + if !m.beginBusy() { + return + } + defer m.endBusy() var err error if s.State == stateRunning { @@ -397,6 +478,11 @@ func (m *menu) toggleNetworkAccess() { _ = alert("cix", "cix is not set up yet.") return } + if !m.beginBusy() { + m.render(m.poll.snapshotNow()) + return + } + defer m.endBusy() wasRunning := m.poll.snapshotNow().State == stateRunning local := isLocalOnly(vars) From 3bee6edc0cdecfcc07d4c3adee03a23c0ab94b4a Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 18:51:07 +0100 Subject: [PATCH 04/12] fix(mac): make Start say why it failed, and copy the password without a flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems from testing the previous change. **Start did nothing after the database was deleted.** Removing the bootstrap password was right, but it turned a silent recreation into a silent refusal: with the email still present and the password gone, an empty database is the half-configured case bootstrap.go rejects outright ("CIX_BOOTSTRAP_ADMIN_EMAIL is set but CIX_BOOTSTRAP_ADMIN_PASSWORD is empty"). The server exited in milliseconds and the menu went back to "Stopped" — indistinguishable from a button that does nothing. needsFirstRun already knew, but only at launch; a running app never asked again. Start now checks before starting, and offers to set up again rather than spawning a process that cannot survive. It also verifies the server outlived the start: launchctl reports having spawned it, not having kept it, so a process that rejects its own configuration used to disappear without a word. When that happens the server's own message is shown, trimmed to the last few lines with a pointer to the log. Worth stating for the record: the refusal is guarded by `count == 0`, so retiring the password never affects a start against an intact database. **Copy Password flashed the window.** AppleScript's `display dialog` is modal and returns only when it closes, so a copy button meant closing the window and opening it again — on screen, a blink. The password is now on the clipboard before the window appears and the message says so, which removes the flash and the click together. There is nothing to be coy about: this is a password the app generated seconds ago and is showing on purpose. Co-Authored-By: Claude Opus 5 --- cli/launcher/dialog_darwin.go | 50 +++++++---------------- cli/launcher/firstrun_darwin.go | 4 +- cli/launcher/firstrun_darwin_test.go | 56 +++++++++++++++++++++++++ cli/launcher/launchd_darwin.go | 61 ++++++++++++++++++++++++++++ cli/launcher/menu_darwin.go | 58 ++++++++++++++++++++------ cli/launcher/resetpw_darwin.go | 4 +- 6 files changed, 182 insertions(+), 51 deletions(-) diff --git a/cli/launcher/dialog_darwin.go b/cli/launcher/dialog_darwin.go index ea680c5..6f9d682 100644 --- a/cli/launcher/dialog_darwin.go +++ b/cli/launcher/dialog_darwin.go @@ -65,43 +65,23 @@ func alert(title, message string) error { return runOsascript(2*time.Minute, script) } -// alertWithCopy is alert() plus a button that puts secret on the clipboard. +// alertWithSecret shows a credential and puts it on the clipboard. // -// AppleScript cannot make a run of text clickable — `display dialog` draws one -// static string — so the click target has to be a button. That is the whole -// reason this is not simply "click the password". -// -// It re-shows the dialog after copying rather than dismissing. A password -// displayed once is exactly the thing someone reaches for a second time, and a -// window that disappears on the first attempt is how people end up reading -// credentials off a screenshot. The loop always terminates: every turn of it -// waits for a click. -func alertWithCopy(title, message, secret, copyLabel string) error { - body := message - for { - script := fmt.Sprintf( - `display dialog %s with title %s %s buttons {%s, "Done"} default button "Done"`, - quoteAS(body), quoteAS(title), iconClause(), quoteAS(copyLabel), - ) - out, err := outputOsascript(5*time.Minute, script) - if err != nil { - // Dismissing the window is a perfectly good way to say "I have it". - if errors.Is(err, errCancelled) { - return nil - } - return err - } - if !strings.Contains(out, copyLabel) { - return nil - } - - if err := copyToClipboard(secret); err != nil { - logf("could not copy to the clipboard: %v", err) - body = message + "\n\nCould not copy to the clipboard." - continue - } - body = message + "\n\nCopied to 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. diff --git a/cli/launcher/firstrun_darwin.go b/cli/launcher/firstrun_darwin.go index c9f403a..dcb41e8 100644 --- a/cli/launcher/firstrun_darwin.go +++ b/cli/launcher/firstrun_darwin.go @@ -226,11 +226,11 @@ func runFirstRun(u *updater) error { retireBootstrapPassword() } - return alertWithCopy("cix is set up", fmt.Sprintf( + return alertWithSecret("cix is set up", fmt.Sprintf( "Sign in at %s\n\nEmail:\n%s\n\nTemporary password:\n%s\n\n"+ "You will be asked to change this password on first login.%s%s", dashboardURL(vars), email, password, waitNote, cliNote), - password, "Copy Password") + password, "password") } // registerWithCLI adds (or updates) the local server in ~/.cix/config.yaml. diff --git a/cli/launcher/firstrun_darwin_test.go b/cli/launcher/firstrun_darwin_test.go index f78753d..0caa5c9 100644 --- a/cli/launcher/firstrun_darwin_test.go +++ b/cli/launcher/firstrun_darwin_test.go @@ -1,8 +1,10 @@ package main import ( + "fmt" "os" "path/filepath" + "strings" "testing" ) @@ -158,3 +160,57 @@ func TestSetDefaultKeepsExistingChoices(t *testing.T) { t.Errorf("version check = %q, want the default filled in", vars["CIX_VERSION_CHECK_ENABLED"]) } } + +// When the server exits on a configuration it will not accept, its own words +// are the only useful thing to show — the menu otherwise says "Stopped" and +// nothing more, which is how a deleted database looked like a broken button. +func TestLastServerError(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + logs := filepath.Join(home, ".cix", "logs") + if err := os.MkdirAll(logs, 0o700); err != nil { + t.Fatal(err) + } + + t.Run("reports the tail", func(t *testing.T) { + body := "cix-server is ready\n\n\nbootstrap auth: incomplete bootstrap configuration:\n" + + " CIX_BOOTSTRAP_ADMIN_EMAIL is set but CIX_BOOTSTRAP_ADMIN_PASSWORD is empty.\n" + if err := os.WriteFile(filepath.Join(logs, "cix-server.err"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + got := lastServerError() + if !strings.Contains(got, "incomplete bootstrap configuration") { + t.Errorf("lastServerError() = %q, want the refusal in it", got) + } + // Blank lines carry nothing into a dialog. + if strings.Contains(got, "\n\n") { + t.Errorf("lastServerError() kept blank lines: %q", got) + } + }) + + t.Run("bounded", func(t *testing.T) { + var sb strings.Builder + for i := range 500 { + fmt.Fprintf(&sb, "line %d with a good deal of text after it so the cap is reached\n", i) + } + if err := os.WriteFile(filepath.Join(logs, "cix-server.err"), []byte(sb.String()), 0o644); err != nil { + t.Fatal(err) + } + got := lastServerError() + if n := len([]rune(got)); n > 901 { + t.Errorf("lastServerError() returned %d runes, want it capped", n) + } + if !strings.Contains(got, "line 499") { + t.Error("lastServerError() dropped the most recent line") + } + }) + + t.Run("no log at all", func(t *testing.T) { + if err := os.Remove(filepath.Join(logs, "cix-server.err")); err != nil { + t.Fatal(err) + } + if got := lastServerError(); got == "" { + t.Error("lastServerError() = \"\", want something to show the user") + } + }) +} diff --git a/cli/launcher/launchd_darwin.go b/cli/launcher/launchd_darwin.go index 7d92be5..ec79e06 100644 --- a/cli/launcher/launchd_darwin.go +++ b/cli/launcher/launchd_darwin.go @@ -331,3 +331,64 @@ func foreignAgent() bool { } return !strings.Contains(string(data), managedByMarker) } + +// serverDiedOnStart reports why the server is already gone after a Start we +// asked for, or "" when it is still alive. +// +// launchctl answers for having spawned the process, not for the process +// surviving it. A server that rejects its own configuration exits in +// milliseconds and leaves the menu reading "Stopped" with no explanation — +// indistinguishable from a Start button that does nothing. +// +// The wait is two-sided on purpose: a pid takes a moment to appear, and a +// process that is going to die does it almost at once. Neither half is a +// deadline for anything the user waits on — a healthy server returns from here +// as soon as it has a pid. +func serverDiedOnStart() string { + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) && launchdPID() == 0 { + time.Sleep(200 * time.Millisecond) + } + time.Sleep(2 * time.Second) + if launchdPID() != 0 { + return "" + } + return lastServerError() +} + +// lastServerError pulls the tail of cix-server.err, for a dialog. +// +// The server writes its refusals there as plain prose across several lines, so +// the last few non-empty ones are the message. Capped: an unbounded log tail in +// a modal dialog is its own failure. +func lastServerError() string { + dir, err := logDir() + if err != nil { + return "The server exited immediately." + } + data, err := os.ReadFile(filepath.Join(dir, "cix-server.err")) + if err != nil { + return "The server exited immediately." + } + + var lines []string + for line := range strings.SplitSeq(string(data), "\n") { + if strings.TrimSpace(line) != "" { + lines = append(lines, strings.TrimRight(line, " \t")) + } + } + if len(lines) == 0 { + return "The server exited immediately, without logging anything." + } + const keep = 8 + if len(lines) > keep { + lines = lines[len(lines)-keep:] + } + + out := strings.Join(lines, "\n") + const maxRunes = 900 + if r := []rune(out); len(r) > maxRunes { + out = "…" + string(r[len(r)-maxRunes:]) + } + return out +} diff --git a/cli/launcher/menu_darwin.go b/cli/launcher/menu_darwin.go index 76e92ee..cb3deed 100644 --- a/cli/launcher/menu_darwin.go +++ b/cli/launcher/menu_darwin.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "os" "os/exec" @@ -446,22 +447,55 @@ func (m *menu) toggleServer() { } defer m.endBusy() - var err error if s.State == stateRunning { - err = stopServer() - } else { - // Rewrite the wrapper before starting. It is generated, not edited, and - // something else may have replaced it — install-server.sh most obviously. - if err = writeLaunchdFiles(autostartEnabled()); err == nil { - err = startServer() + if err := stopServer(); err != nil { + _ = alert("cix", fmt.Sprintf("Could not stop the server.\n\n%v", err)) } + m.poll.refresh() + return } - if err != nil { - verb := "start" - if s.State == stateRunning { - verb = "stop" + + // A missing database is not something Start can fix. The server refuses to + // boot without an admin account to create — correctly — and says so in a + // 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 } - _ = alert("cix", fmt.Sprintf("Could not %s the server.\n\n%v", verb, err)) + 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.poll.refresh() + return + } + + // Rewrite the wrapper before starting. It is generated, not edited, and + // something else may have replaced it — install-server.sh most obviously. + err := writeLaunchdFiles(autostartEnabled()) + if err == nil { + err = startServer() + } + if err != nil { + _ = alert("cix", fmt.Sprintf("Could not start the server.\n\n%v", err)) + return + } + + // 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 + // a deleted database looked like a broken Start button. + if detail := serverDiedOnStart(); detail != "" { + logf("the server exited immediately after Start: %s", detail) + _ = alert("The server stopped straight away", detail+ + "\n\nThe full log is in ~/.cix/logs/cix-server.err.") } m.poll.refresh() } diff --git a/cli/launcher/resetpw_darwin.go b/cli/launcher/resetpw_darwin.go index f37ce3c..578d558 100644 --- a/cli/launcher/resetpw_darwin.go +++ b/cli/launcher/resetpw_darwin.go @@ -70,11 +70,11 @@ func (m *menu) resetPasswordFlow() { return } - _ = alertWithCopy("Password reset", fmt.Sprintf( + _ = alertWithSecret("Password reset", fmt.Sprintf( "Account:\n%s\n\nTemporary password:\n%s\n\n"+ "You will be asked to change it at the next sign-in. Other sessions for this "+ "account have been signed out.", email, password), - password, "Copy Password") + password, "password") } // runResetPassword executes the reset and returns the generated password. From 1c03ed31b6810b1f91c931f59eee9d3c43c03c74 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 19:04:10 +0100 Subject: [PATCH 05/12] fix(mac): route the server's no-admin refusal back to the setup wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting ~/.cix/data does not stay a "missing database" for long: the very next start attempt recreates an empty cix.db (the server runs migrations before bootstrapAuth refuses), so needsFirstRun's file-existence check answers false from then on, and Start showed the raw refusal from cix-server.err instead of offering setup. The reliable signal for "no accounts" is the server's own refusal text, so recognise it: isBootstrapRefusal matches the two bootstrapAuth messages a user-less database produces, and toggleServer routes a matching died-on-start log to the same set-up-again offer as a missing database. Other startup failures still show the log tail — a port clash is not a reason to offer re-setup. Also prefill the wizard's email prompt with the address already in server.env, since a re-run almost always wants the same one. Co-Authored-By: Claude Opus 5 --- cli/launcher/firstrun_darwin.go | 38 ++++++++++++++++++---- cli/launcher/firstrun_darwin_test.go | 30 +++++++++++++++++ cli/launcher/menu_darwin.go | 48 ++++++++++++++++++++-------- 3 files changed, 97 insertions(+), 19 deletions(-) diff --git a/cli/launcher/firstrun_darwin.go b/cli/launcher/firstrun_darwin.go index dcb41e8..a3eab1d 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 } 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/menu_darwin.go b/cli/launcher/menu_darwin.go index cb3deed..da82bc9 100644 --- a/cli/launcher/menu_darwin.go +++ b/cli/launcher/menu_darwin.go @@ -460,19 +460,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 } @@ -494,12 +486,42 @@ func (m *menu) toggleServer() { // 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. // From 75f5977404ca87a8a1c9c5e5b048f1d8e3e29331 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 19:27:51 +0100 Subject: [PATCH 06/12] feat(mac): replace the NSMenu with a custom menu bar panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status menu becomes a popover-style panel following the menubar-panel design spec: status as a coloured header with the port as the hero line, technical data in a right-aligned mono table instead of disabled menu rows, one weighted action button per state, square toggles with consequence hints, and a footer that keeps Quit next to its "server keeps running" reassurance. Implementation is a thin Objective-C layer (panel_darwin.m) owning the NSStatusItem, a borderless nonactivating NSPanel and a WKWebView that renders one embedded HTML file (panel.html) — the entire look in one reviewable file, dark mode via prefers-color-scheme for free. Go pushes a panelState JSON after every poll; the panel posts {action} messages back. All behaviour (busy gating, first-run routing, updates, network/autostart toggles, password reset) is unchanged and stays in Go. Design deviations, deliberate: a STARTING state exists (cold starts load an embedding model for minutes) and renders with an indeterminate progress sweep, as does INDEXING — the server reports a job count, not a percentage, so nothing pretends to be one; the stopped state keeps a small table (installed runtime + app version) and the toggles, because both work without a running server. fyne.io/systray is gone, along with the dot-PNG renderer and the NSMenu row-width machinery it existed for. Co-Authored-By: Claude Opus 5 --- cli/go.mod | 2 - cli/go.sum | 4 - cli/launcher/dots_darwin.go | 107 ------- cli/launcher/menu_darwin.go | 301 ++++--------------- cli/launcher/panel.html | 453 +++++++++++++++++++++++++++++ cli/launcher/panel_darwin.go | 115 ++++++++ cli/launcher/panel_darwin.m | 315 ++++++++++++++++++++ cli/launcher/panelstate_darwin.go | 154 ++++++++++ cli/launcher/status_darwin.go | 163 +---------- cli/launcher/status_darwin_test.go | 227 ++++----------- 10 files changed, 1168 insertions(+), 673 deletions(-) delete mode 100644 cli/launcher/dots_darwin.go create mode 100644 cli/launcher/panel.html create mode 100644 cli/launcher/panel_darwin.go create mode 100644 cli/launcher/panel_darwin.m create mode 100644 cli/launcher/panelstate_darwin.go 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/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/menu_darwin.go b/cli/launcher/menu_darwin.go index da82bc9..7bd0b52 100644 --- a/cli/launcher/menu_darwin.go +++ b/cli/launcher/menu_darwin.go @@ -3,52 +3,36 @@ package main import ( "errors" "fmt" - "os" "os/exec" "path/filepath" "strings" "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. @@ -61,18 +45,13 @@ type menu struct { // 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 { 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.render(m.poll.snapshotNow()) @@ -84,85 +63,33 @@ 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 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() @@ -170,35 +97,44 @@ func (m *menu) onReady() { // 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 - } - } - }() } 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 "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 +147,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())) } // toggleAutostart flips RunAtLoad on the launchd agent. @@ -316,7 +168,7 @@ func (m *menu) toggleAutostart() { return } if !m.beginBusy() { - // The checkbox already flipped visually on the click; put it back. + // The switch already flipped visually on the click; put it back. m.render(s) return } @@ -340,7 +192,7 @@ func (m *menu) toggleAutostart() { } // 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) { av := m.updater.check(explicit) @@ -409,32 +261,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() { @@ -482,7 +309,7 @@ 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) @@ -525,8 +352,8 @@ func (m *menu) offerSetupAgain(message string) { // 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() @@ -552,7 +379,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..c74fcbd --- /dev/null +++ b/cli/launcher/panel.html @@ -0,0 +1,453 @@ + + + + + + + + +
+
+ + + +
+
+ + + + diff --git a/cli/launcher/panel_darwin.go b/cli/launcher/panel_darwin.go new file mode 100644 index 0000000..e34df47 --- /dev/null +++ b/cli/launcher/panel_darwin.go @@ -0,0 +1,115 @@ +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. +type panelAction struct { + Action string `json:"action"` +} + +//export goPanelReady +func goPanelReady() { + 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..b46b543 --- /dev/null +++ b/cli/launcher/panel_darwin.m @@ -0,0 +1,315 @@ +// 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 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 { + 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; +} + +// 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)setTitle:(NSString *)title { + statusItem.button.title = title; +} + +- (void)webView:(WKWebView *)wv + didFinishNavigation:(WKNavigation *)nav { + webLoaded = YES; + [self pushState]; +} + +- (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_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/panelstate_darwin.go b/cli/launcher/panelstate_darwin.go new file mode 100644 index 0000000..efb95f1 --- /dev/null +++ b/cli/launcher/panelstate_darwin.go @@ -0,0 +1,154 @@ +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"` + 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) panelState { + ps := panelState{ + Busy: busy, + 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/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..bfcf09b 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,78 @@ 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, and + // it is what the panel disables its controls on. + if !buildPanelState(running, true).Busy { + t.Error("busy flag lost on the way through") } - // 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 +158,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) } } From 5acda90352e62072078a1373f5d1d73feb973ce3 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 21:44:58 +0100 Subject: [PATCH 07/12] fix(mac): keep the panel open on Start/Stop and unify the busy loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reported paper cuts in the new panel: Start/Stop dismissed the panel like a menu item, hiding exactly the feedback the click was owed. Server operations (start, stop, both toggles) now keep the panel open; only actions that hand off to another surface — dashboard, dialogs, quit — dismiss. The wait itself looked different per operation: Start/Stop showed nothing (the panel was gone), the toggles a static "Working…". Every operation that claims the busy lock now renders the same loader — three pulsing square cells in the design's blocky language — and beginBusy takes a label naming the operation ("Stopping the server…", "Applying the setting…", "Installing the update…"), because the flag says only that something is slow and the label is what says what. The Start/Stop click swaps the button for the loader optimistically, on the click itself rather than a state-push round-trip later; a refused busy claim renders the real state back. Co-Authored-By: Claude Opus 5 --- cli/launcher/menu_darwin.go | 38 +++++++++++++++++++----- cli/launcher/panel.html | 47 +++++++++++++++++++++++++----- cli/launcher/panelstate_darwin.go | 11 +++++-- cli/launcher/status_darwin_test.go | 16 +++++----- 4 files changed, 87 insertions(+), 25 deletions(-) diff --git a/cli/launcher/menu_darwin.go b/cli/launcher/menu_darwin.go index 7bd0b52..29fb62f 100644 --- a/cli/launcher/menu_darwin.go +++ b/cli/launcher/menu_darwin.go @@ -42,18 +42,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 } -// 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 panel action: another server operation is already running") return false } + m.busyLabel.Store(label) m.render(m.poll.snapshotNow()) return true } @@ -63,6 +71,13 @@ func (m *menu) endBusy() { m.poll.refresh() } +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} @@ -153,7 +168,7 @@ func (m *menu) render(s snapshot) { // has no further use. m.retireOnce.Do(retireBootstrapPassword) } - panelSetState(buildPanelState(s, m.busy.Load())) + panelSetState(buildPanelState(s, m.busy.Load(), m.currentBusyLabel())) } // toggleAutostart flips RunAtLoad on the launchd agent. @@ -167,7 +182,7 @@ func (m *menu) toggleAutostart() { if !s.Managed { return } - if !m.beginBusy() { + if !m.beginBusy("Applying the setting…") { // The switch already flipped visually on the click; put it back. m.render(s) return @@ -237,7 +252,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 } @@ -269,7 +284,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() @@ -361,7 +383,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 } diff --git a/cli/launcher/panel.html b/cli/launcher/panel.html index c74fcbd..18b12aa 100644 --- a/cli/launcher/panel.html +++ b/cli/launcher/panel.html @@ -155,6 +155,17 @@ } .btn.destructive .ext { color: #F7EEDC; } + /* Loader: three pulsing square cells — the same blocky language as the + progress bar, never a rounded spinner. */ + .loadcells { display: inline-flex; gap: 3px; margin-right: 4px; } + .loadcells span { + width: 8px; height: 8px; background: currentColor; + opacity: 0.25; animation: pulse 900ms linear infinite; + } + .loadcells span:nth-child(2) { animation-delay: 150ms; } + .loadcells span:nth-child(3) { animation-delay: 300ms; } + @keyframes pulse { 0%, 60%, 100% { opacity: 0.25; } 30% { opacity: 1; } } + /* -- toggles ------------------------------------------------------------ */ #toggles { padding: 12px 18px; display: flex; flex-direction: column; gap: 10px; } .toggle { @@ -360,7 +371,9 @@ var html = ''; if (s.busy) { - html += btn('Working…', '', 'primary disabled', null); + // Every slow server operation — start, stop, a toggle's restart, an + // update — renders this same loader; the label says which one it is. + html += loaderBtn(s.busyLabel || 'Working…'); } else if (!s.managed) { // Observe-only: someone else's server. Offer the dashboard, touch nothing. if (s.state === 'running') { @@ -370,7 +383,7 @@ html += btn('Stop Server', '', 'destructive', 'toggle-server'); html += btn('Open Dashboard', '↗', '', 'dashboard'); } else if (s.state === 'starting') { - html += btn('Starting…', '', 'primary disabled', null); + html += loaderBtn('Starting…'); } else { html += btn('Start Server', '', 'primary', 'toggle-server'); } @@ -386,6 +399,12 @@ (ext ? '' + ext + '' : '') + ''; } +function loaderBtn(label) { + return ''; +} + function renderToggles(s) { var t = document.getElementById('toggles'); if (!s.managed) { t.hidden = true; return; } @@ -427,11 +446,25 @@ var el = e.target.closest('[data-action]'); if (!el || el.classList.contains('disabled')) { return; } var action = el.getAttribute('data-action'); - // Toggles stay open so the switch is seen flipping; everything else - // dismisses like a menu item. - var dismiss = action.indexOf('toggle-network') !== 0 && - action.indexOf('toggle-autostart') !== 0; - post({ action: action, dismiss: dismiss }); + + // Server operations keep the panel open — their whole feedback is the + // loader and the state flip, and closing the window would hide both. Only + // actions that hand off to another surface (the dashboard, a dialog, quit) + // dismiss like a menu item. + var staysOpen = action === 'toggle-server' || + action === 'toggle-network' || + action === 'toggle-autostart'; + + if (action === 'toggle-server') { + // Optimistic: show the loader on the click itself, not a state-push + // round-trip later. The next render replaces this with the real thing + // (including the refused-because-busy case, which puts the button back). + var stopping = el.classList.contains('destructive'); + document.getElementById('actions').innerHTML = + loaderBtn(stopping ? 'Stopping the server…' : 'Starting the server…'); + } + + post({ action: action, dismiss: !staysOpen }); }); document.addEventListener('keydown', function (e) { diff --git a/cli/launcher/panelstate_darwin.go b/cli/launcher/panelstate_darwin.go index efb95f1..8a6abd5 100644 --- a/cli/launcher/panelstate_darwin.go +++ b/cli/launcher/panelstate_darwin.go @@ -10,8 +10,12 @@ import ( // 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"` + 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"` @@ -35,9 +39,10 @@ type panelState struct { // buildPanelState folds a poller snapshot and the menu's own busy flag into // what the panel shows. -func buildPanelState(s snapshot, busy bool) panelState { +func buildPanelState(s snapshot, busy bool, busyLabel string) panelState { ps := panelState{ Busy: busy, + BusyLabel: busyLabel, Managed: s.Managed, Port: s.Port, PID: s.PID, diff --git a/cli/launcher/status_darwin_test.go b/cli/launcher/status_darwin_test.go index bfcf09b..9b9d038 100644 --- a/cli/launcher/status_darwin_test.go +++ b/cli/launcher/status_darwin_test.go @@ -42,7 +42,7 @@ func TestBuildPanelState(t *testing.T) { }, EmbeddingsOK: true, } - ps := buildPanelState(running, false) + 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) } @@ -66,18 +66,20 @@ func TestBuildPanelState(t *testing.T) { // 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) + 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) } - // The busy flag is the menu's, not the poller's; it must pass through, and - // it is what the panel disables its controls on. - if !buildPanelState(running, true).Busy { - t.Error("busy flag lost on the way through") + // 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) } - stopped := buildPanelState(snapshot{State: stateStopped, Managed: false}, false) + stopped := buildPanelState(snapshot{State: stateStopped, Managed: false}, false, "") if stopped.State != "stopped" || stopped.Managed { t.Errorf("stopped external = %+v", stopped) } From 266b23c4ea593f2f31129cd122a6ba658c3687fc Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 21:54:06 +0100 Subject: [PATCH 08/12] feat(mac): move every dialog inside the panel, Docker-Desktop-style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app used to open separate osascript windows for everything — info popups, confirmations, the setup wizard's email prompt, the password display — none of which looked anything like the panel. All of them now render as an in-panel area that takes over the content (panel.html #dialog): same tokens, same square controls, selectable mono block for credentials, Enter/Esc answer the dialog, and closing the panel counts as declining. Mechanics: a dialog request is pushed to the webview as JSON and the panel is fronted so it is actually seen; the calling goroutine blocks on a channel until the answer comes back over the existing action bridge. Dialogs serialise on a mutex. The osascript layer remains only as the fallback for the window before the webview exists (and for a translocated bundle, which never gets a panel). First-launch setup — the foreign-agent question, the wizard, the runtime download — moves from main() to after the panel is up (menu.startupFlow, under the busy claim), so its dialogs render in-panel too and the menu bar icon appears immediately instead of after a download. Co-Authored-By: Claude Opus 5 --- cli/launcher/dialog_darwin.go | 62 +++------ cli/launcher/logging_darwin.go | 5 + cli/launcher/main_darwin.go | 74 +---------- cli/launcher/menu_darwin.go | 98 +++++++++++++- cli/launcher/panel.html | 100 ++++++++++++++ cli/launcher/panel_darwin.go | 7 + cli/launcher/panel_darwin.m | 43 +++++- cli/launcher/paneldialog_darwin.go | 201 +++++++++++++++++++++++++++++ 8 files changed, 469 insertions(+), 121 deletions(-) create mode 100644 cli/launcher/paneldialog_darwin.go 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/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 29fb62f..15952d8 100644 --- a/cli/launcher/menu_darwin.go +++ b/cli/launcher/menu_darwin.go @@ -3,6 +3,7 @@ package main import ( "errors" "fmt" + "os" "os/exec" "path/filepath" "strings" @@ -108,10 +109,93 @@ func (m *menu) onReady() { go m.poll.run(m.stop) go m.watch() - // A quiet background check. It only speaks up when there is something to - // offer, and it is throttled and ETag-cached, so an app left open all day - // costs a handful of 304s. - go m.checkForUpdates(false) + go func() { + 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.") + } + 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("") + // 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() { @@ -130,6 +214,12 @@ func (m *menu) onAction(a panelAction) { // 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": diff --git a/cli/launcher/panel.html b/cli/launcher/panel.html index 18b12aa..33a8c00 100644 --- a/cli/launcher/panel.html +++ b/cli/launcher/panel.html @@ -207,6 +207,37 @@ } #ft .spacer { flex: 1; min-width: 8px; } + /* -- in-panel dialog --------------------------------------------------- */ + /* Replaces every osascript window: alerts, confirmations, text prompts and + the password display all render as an area that takes over the panel, + Docker-Desktop-style, instead of opening a separate window. While open, + the normal sections are hidden and the panel's height follows the dialog. */ + #dialog { display: none; } + #panel.dlg > :not(#dialog) { display: none; } + #panel.dlg #dialog { display: flex; flex-direction: column; gap: 12px; padding: 16px 18px 14px; } + #dialog .dtitle { font-size: 15px; font-weight: 700; } + #dialog .dmsg { font-size: 13px; line-height: 1.5; color: var(--text2); white-space: pre-wrap; } + #dialog .dsecret { + font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, monospace; + font-size: 14px; padding: 10px 12px; + border: 1.5px solid var(--border); background: var(--sunken); + -webkit-user-select: text; user-select: text; word-break: break-all; + } + #dialog .dnote { + font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, monospace; + font-size: 11px; color: var(--muted); + } + #dialog input { + all: unset; box-sizing: border-box; width: 100%; + font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, monospace; + font-size: 13px; color: var(--text); + padding: 9px 12px; border: 1.5px solid var(--border); background: var(--surface); + -webkit-user-select: text; user-select: text; cursor: text; + } + #dialog input:focus { background: var(--sunken); } + #dialog .dbtns { display: flex; gap: 8px; margin-top: 2px; } + #dialog .dbtns .btn { flex: 1; } + /* state-flip crossfade — the spec's 120 ms */ #panel.fade { animation: fade 120ms ease-out; } @keyframes fade { from { opacity: 0.4; } to { opacity: 1; } } @@ -219,6 +250,7 @@
+