Conversation
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 <noreply@anthropic.com>
…ask more 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 <noreply@anthropic.com>
fix(mac): do not style the wrong disk when a volume named cix is mounted
feat(mac): copy generated passwords from the dialog, and say less to ask more
…efuse concurrent restarts 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 <noreply@anthropic.com>
fix(mac): notice a deleted database, retire the bootstrap password, refuse concurrent restarts
… a flash
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 <noreply@anthropic.com>
fix(mac): make Start say why it failed, and copy the password without a flash
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
The first in-panel dialog implementation hid the panel's sections and morphed the window into the question. What was asked for is the Docker-Desktop shape: the main window stays where it is, dimmed under a scrim, and the form floats over it as a modal card — visibly part of the same window. The card gets a 120ms rise-in, the scrim swallows clicks to the content behind (and a click on it declines, like Esc), and when the card is taller than the panel's own content the panel grows to hold it and shrinks back after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking Updates… dismissed the panel, ran the check in silence, and reopened the window with the verdict — a close-then-reopen blink with a dead gap in the middle. Now nothing except Open Dashboard and Quit dismisses the panel, and the check puts up a modal 'busy' card at once: title plus the pulsing-cells loader, no buttons, nothing to answer. The verdict dialog replaces the card in place. The same card covers the other silent network waits — the runtime download inside the wizard, the startup repair, and the reset-password path's on-demand download. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Self-update for the .app was fully implemented and completely invisible: the panel showed the server's version but never the app's, and the up-to-date dialog folded both into one sentence that never said "app" — so it read as a server-only check, and the launcher's own updater looked like something nobody had written. The running table now names both components, `server` and `app`, and the dialog reports them on separate lines. A development build says outright that it does not replace itself, which is the actual reason a locally built app is never offered an update. Also covers the path with tests, because it had never been executed — no mac/v* release exists, so the app half of the updater has only ever seen an empty stream. stageUpdate is split out of updateLauncher (the swap ends the process, so a test cannot reach past it) and driven against a fake releases API serving a real signed DMG: the version comparison, the download, the checksum, the mount and the signature check. A tampered image must be discarded and leave nothing staged. swap.sh gets its own test with a real process to wait for and a stub `open` on PATH, covering both the replacement and its refusal to swap under a live launcher. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extracted from checkForUpdates so both shapes are testable: a released build reports "cix app / vX.Y.Z" and drops the caveat, and a build from source keeps it. The caveat is the answer to "why is an update never offered", so it must appear on exactly one of the two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(mac): route the server's no-admin refusal back to the setup wizard
feat(mac): replace the NSMenu with a custom menu bar panel
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes the macOS launcher work to
mainso the firstmac/v0.1.0tag can be cut there, following the same rule as the server stream (release tags are cut on main).Everything here is the menu bar app; nothing touches the server, the CLI or the dashboard.
What lands
NSMenuwith a customNSPanel+WKWebViewimplementing the menubar-panel design: status header with the port as the hero line, right-aligned mono data table, one weighted action per state, square toggles, footer. All dialogs (alerts, confirmations, the setup wizard, the password display) now render as a modal card floating over the panel's own content instead of separateosascriptwindows. Unified busy loader across every server operation; the update check shows a busy card in place rather than closing and reopening the window.fyne.io/systrayremoved.cix.dbbefore it refuses to start without an admin, so the file-existence check answered "configured" and Start showed a raw log tail. The refusal itself is now recognised and offers the wizard.Self-update
The app's own updater existed but had never been exercised — no
mac/v*release has ever been published, so its half of the check always saw an empty stream. This adds the first tests for it: a fake releases API serving a real signed DMG, covering the version comparison, download, checksum, mount and signature check; a tampered image must be discarded leaving nothing staged;swap.shis driven with a real process to wait for, covering both the replacement and its refusal to swap under a live launcher. The panel now shows the app's own version beside the server's, and the up-to-date dialog names both.After merge
git tag -a mac/v0.1.0 <main>→release-mac.ymlbuilds the app, verifies the bundle holds exactly one executable, and publishes the DMG withchecksums.txt. That release is what makes self-update work from then on.🤖 Generated with Claude Code