Skip to content

Release: macOS menu bar app (mac/v0.1.0) - #238

Merged
dvcdsys merged 18 commits into
mainfrom
develop
Aug 10, 2026
Merged

Release: macOS menu bar app (mac/v0.1.0)#238
dvcdsys merged 18 commits into
mainfrom
develop

Conversation

@dvcdsys

@dvcdsys dvcdsys commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Promotes the macOS launcher work to main so the first mac/v0.1.0 tag 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

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.sh is 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.yml builds the app, verifies the bundle holds exactly one executable, and publishes the DMG with checksums.txt. That release is what makes self-update work from then on.

🤖 Generated with Claude Code

dvcdsys and others added 18 commits August 10, 2026 18:14
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
@dvcdsys
dvcdsys merged commit af5c69c into main Aug 10, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant