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/go.mod b/cli/go.mod index 0af244a..effec5c 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -3,7 +3,6 @@ module github.com/dvcdsys/code-index/cli go 1.25.12 require ( - fyne.io/systray v1.12.2 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 @@ -34,7 +33,6 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect diff --git a/cli/go.sum b/cli/go.sum index 33a492b..1916907 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -1,5 +1,3 @@ -fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= -fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -43,8 +41,6 @@ github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJ github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= -github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= diff --git a/cli/launcher/dialog_darwin.go b/cli/launcher/dialog_darwin.go index 0509d68..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,6 +65,17 @@ func alert(title, message string) error { return runOsascript(2*time.Minute, script) } +// 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. @@ -81,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(), @@ -101,20 +112,9 @@ func prompt(title, message, defaultAnswer string) (string, error) { return strings.TrimSpace(answer), nil } -// confirm shows a two-button question. Returns false when the user declines. -func confirm(title, message, okLabel string) (bool, error) { - return ask(title, message, okLabel, "Cancel") -} - -// ask shows a two-button question with both labels spelled out. -// -// Separate from confirm because not every choice has a "cancel" side. "Take -// Over" versus "Leave It Alone" are two real options, and labelling the second -// one Cancel would imply it does nothing — when in fact it decides how the app -// behaves from then on. -// +// osaAsk shows a two-button question with both labels spelled out. // yesLabel is the default button. Dismissing the dialog counts as no. -func ask(title, message, yesLabel, noLabel string) (bool, error) { +func osaAsk(title, message, yesLabel, noLabel string) (bool, error) { script := fmt.Sprintf( `display dialog %s with title %s %s buttons {%s, %s} default button %s cancel button %s`, quoteAS(message), quoteAS(title), iconClause(), diff --git a/cli/launcher/dots_darwin.go b/cli/launcher/dots_darwin.go deleted file mode 100644 index 9918125..0000000 --- a/cli/launcher/dots_darwin.go +++ /dev/null @@ -1,107 +0,0 @@ -package main - -import ( - "bytes" - "image" - "image/color" - "image/png" - "math" - "sync" -) - -// Status dots for the menu rows. -// -// A coloured dot beside a row is how macOS status apps show liveness, and it -// carries the state without spending menu width on words like "— ready". That -// matters here: an NSMenu is as wide as its widest row, so every character in a -// status string is width the model name does not get. -// -// These are NOT template images. A template image is recoloured by macOS from -// its alpha channel alone, which would turn every dot the same colour and erase -// the only thing they say. The menu-bar glyph is a template image; these are -// not, deliberately. -// -// Generated rather than shipped as files: they are three flat circles, and a -// generator is smaller than the PNGs plus the build-script lines to copy them. - -// Colours picked to stay legible on both light and dark menu backgrounds, and -// to survive the most common form of colour blindness by differing in lightness -// as well as hue — a green/red pair alone would not. -var ( - dotGreen = color.NRGBA{R: 0x30, G: 0xB0, B: 0x50, A: 0xFF} - dotAmber = color.NRGBA{R: 0xE0, G: 0x94, B: 0x1B, A: 0xFF} - dotRed = color.NRGBA{R: 0xD7, G: 0x3E, B: 0x2C, A: 0xFF} // the CIX brand red - dotGrey = color.NRGBA{R: 0x8E, G: 0x8E, B: 0x93, A: 0xFF} - - // dotBlank is a fully transparent dot used purely as a spacer. AppKit - // indents a menu item's title by its image width, so a row without an image - // starts further left than its neighbours and the group reads as ragged. - // An invisible image of the same size keeps the titles on one edge. - dotBlank = color.NRGBA{} -) - -// dotSize is the rendered pixel size. 24 px at @2x renders as a 12 pt dot, -// which is the size AppKit gives a menu item image next to body text. -const dotSize = 24 - -var ( - dotOnce sync.Once - dotCache map[color.NRGBA][]byte -) - -// dotPNG returns a PNG of a filled circle in c, encoded once and reused. -func dotPNG(c color.NRGBA) []byte { - dotOnce.Do(func() { - dotCache = map[color.NRGBA][]byte{} - for _, col := range []color.NRGBA{dotGreen, dotAmber, dotRed, dotGrey, dotBlank} { - dotCache[col] = renderDot(col) - } - }) - if b, ok := dotCache[c]; ok { - return b - } - return renderDot(c) -} - -func renderDot(c color.NRGBA) []byte { - img := image.NewNRGBA(image.Rect(0, 0, dotSize, dotSize)) - centre := float64(dotSize-1) / 2 - // Leave a pixel of margin so the antialiased edge is not clipped by the - // image bounds, which reads as a flat-sided circle at this size. - radius := centre - 1 - - for y := range dotSize { - for x := range dotSize { - dx := float64(x) - centre - dy := float64(y) - centre - d := math.Hypot(dx, dy) - // Coverage over a one-pixel band at the edge: cheap antialiasing, - // and at 24 px the difference between this and a hard edge is the - // difference between a circle and a cog. - var alpha float64 - switch { - case d <= radius-0.5: - alpha = 1 - case d >= radius+0.5: - alpha = 0 - default: - alpha = radius + 0.5 - d - } - if alpha <= 0 { - continue - } - img.SetNRGBA(x, y, color.NRGBA{ - R: c.R, G: c.G, B: c.B, - A: uint8(math.Round(alpha * float64(c.A))), - }) - } - } - - var buf bytes.Buffer - if err := png.Encode(&buf, img); err != nil { - // Encoding a fixed-size in-memory NRGBA image cannot fail; a nil icon - // simply leaves the menu row without one. - return nil - } - return buf.Bytes() -} diff --git a/cli/launcher/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 1879567..734a085 100644 --- a/cli/launcher/firstrun_darwin.go +++ b/cli/launcher/firstrun_darwin.go @@ -27,14 +27,99 @@ 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 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 { 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 +} + +// 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. +// +// 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 @@ -43,12 +128,23 @@ 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)." + + // 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, "") + email, err := prompt("Set up cix", intro, priorEmail) if err != nil { return err } @@ -68,9 +164,12 @@ func runFirstRun(u *updater) error { // for reasons outside this machine, and a setup that wrote server.env and a // launchd agent pointing at a server that was never downloaded would look // complete and be broken. + showPanelBusy("Downloading the cix server…") if err := ensureRuntime(u, logProgress); err != nil { + clearPanelBusy() return fmt.Errorf("could not install the cix server: %w", err) } + clearPanelBusy() password, err := generatePassword() if err != nil { @@ -90,26 +189,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) } @@ -136,12 +249,17 @@ 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 alert("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)) + dashboardURL(vars), email, password, waitNote, cliNote), + 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 new file mode 100644 index 0000000..4be7ded --- /dev/null +++ b/cli/launcher/firstrun_darwin_test.go @@ -0,0 +1,246 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "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") + } + }) +} + +// 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) + 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"]) + } +} + +// 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/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 4ab0df1..cdcf4b1 100644 --- a/cli/launcher/menu_darwin.go +++ b/cli/launcher/menu_darwin.go @@ -1,166 +1,247 @@ 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 - // 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 + // busy is held while something is restarting the server, and it exists + // because several of these actions are not independent. + // + // 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. + // + // 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 + + // 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 } -// detailRows is the fixed number of submenu slots. Rows with nothing to say are -// hidden rather than left blank. -const detailRows = 7 +// 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 +} + +func (m *menu) endBusy() { + m.busy.Store(false) + 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} - systray.Run(m.onReady, m.onExit) + + panelHooks.onReady = m.onReady + panelHooks.onExit = m.onExit + panelHooks.onAction = m.onAction + + // Never returns until quit; must own the main thread (see main_darwin.go). + panelRun(filepath.Join(b.Resources, "cixTemplate.png")) } // setProgress puts a word next to the menu bar icon while something slow is // happening, and clears it when passed "". // // The alternative was a modal dialog, and it is the wrong shape: a runtime -// download takes tens of seconds, during which a menu bar app that shows nothing -// reads as hung and one that blocks with an alert cannot be dismissed. AppKit -// draws this text beside the template icon, so it is visible without being in -// the way. +// download takes tens of seconds, during which a menu bar app that shows +// nothing reads as hung and one that blocks with an alert cannot be dismissed. +// AppKit draws this text beside the template icon, so it is visible without +// being in the way. func (m *menu) setProgress(msg string) { - systray.SetTitle(msg) + panelSetTitle(msg) if msg != "" { logf("%s", msg) } } func (m *menu) onReady() { - if icon, err := os.ReadFile(filepath.Join(m.bundle.Resources, "cixTemplate@2x.png")); err == nil { - // SetTemplateIcon, not SetIcon: macOS recolours a template image for - // dark mode, for a tinted menu bar and for the pressed state, using - // only its alpha channel. A coloured icon here is a smudge at 18 px and - // unreadable in dark mode. - systray.SetTemplateIcon(icon, icon) - } else { - systray.SetTitle("cix") - } - // No tooltips anywhere, including on the status item itself. AppKit's are - // not usable here: once any one of them has appeared, every subsequent one - // shows with no delay at all, and they are positioned against the element - // rather than the pointer. Neither has an API — changing either means - // giving every row a custom NSView with its own tracking area, i.e. writing - // the menu in Objective-C instead of using systray. Everything a tooltip - // would have said is in the details submenu, where the timing and placement - // are the system's own and correct. - // - // The empty second argument to AddMenuItem is that tooltip. Leave it empty. - - // The server row is the one enabled row in the status group, because it - // carries the details submenu — a parent has to be enabled for macOS to - // open its submenu. The disclosure arrow reads as "there is more here" - // rather than "this does something", which is what it is. - m.statusItem = systray.AddMenuItem("cix-server: …", "") - for i := range m.detail { - m.detail[i] = m.statusItem.AddSubMenuItem("", "") - m.detail[i].Disable() - } - - m.embeddingsItem = systray.AddMenuItem("Embeddings: …", "") - m.embeddingsItem.Disable() - m.modelItem = systray.AddMenuItem("", "") - m.modelItem.Disable() - m.modelItem.Hide() - - systray.AddSeparator() - m.startStopItem = systray.AddMenuItem("Start Server", "") - m.dashboardItem = systray.AddMenuItem("Open Dashboard", "") - - systray.AddSeparator() - m.autostartItem = systray.AddMenuItemCheckbox("Start at Login", "", false) - m.networkItem = systray.AddMenuItemCheckbox("Allow Network Access", "", false) - m.resetPWItem = systray.AddMenuItem("Reset Password…", "") - - systray.AddSeparator() - m.updateItem = systray.AddMenuItem("Check for Updates…", "") - - systray.AddSeparator() - // Spelled out rather than left to a tooltip. Quit here closes the menu bar - // app and leaves the launchd agent running, which is the opposite of what - // Quit means in most menu bar apps — a surprise worth 22 characters. - quitItem := systray.AddMenuItem("Quit (server keeps running)", "") - go m.poll.run(m.stop) go m.watch() - // A quiet background check. It only speaks up when there is something to - // offer, and it is throttled and ETag-cached, so an app left open all day - // costs a handful of 304s. - go m.checkForUpdates(false) - go func() { - for { - select { - case <-m.startStopItem.ClickedCh: - go m.toggleServer() - case <-m.dashboardItem.ClickedCh: - go m.openDashboard() - case <-m.autostartItem.ClickedCh: - go m.toggleAutostart() - case <-m.networkItem.ClickedCh: - go m.toggleNetworkAccess() - case <-m.resetPWItem.ClickedCh: - go m.resetPasswordFlow() - case <-m.updateItem.ClickedCh: - go m.checkForUpdates(true) - case <-quitItem.ClickedCh: - systray.Quit() - return + m.startupFlow() + // A quiet background check, strictly after setup — its "update + // available" question must not interleave with the wizard's. It only + // speaks up when there is something to offer, and it is throttled and + // ETag-cached, so an app left open all day costs a handful of 304s. + m.checkForUpdates(false) + }() +} + +// startupFlow is first-launch setup, run once the panel exists so its dialogs +// render inside it (Docker-Desktop-style) rather than as separate windows. +// +// Order matters here. A machine that already runs cix from a checkout has a +// launchd agent under our label and a server holding our port, so the +// first-run wizard must never get a look at it — it would set up a second +// server that cannot bind, against a second, empty database. +// +// The whole flow holds the busy claim: it downloads and starts things, and a +// Start click landing in the middle of setup is exactly what busy exists to +// refuse. +func (m *menu) startupFlow() { + if !m.beginBusy("Setting up…") { + return // cannot happen at startup; nothing sane to do if it did + } + defer m.endBusy() + + switch { + case foreignAgent(): + // Asks once, remembers the answer, and defaults to leaving it alone. + // When the user declines, the app stays in observe-only mode: status + // and the dashboard work, Start/Stop do not. + // + // No runtime is installed on this path. Observing somebody else's server + // needs no binaries of our own, and downloading 90 MB to watch an + // install the user asked us not to touch would be presumptuous. The one + // feature that does need it — password reset — offers the download when + // it is used. + handleForeignAgent(m.updater) + + case needsFirstRun(): + if err := runFirstRun(m.updater); err != nil { + if errors.Is(err, errCancelled) { + // Setup is resumable: the app stays in the menu bar with Start + // disabled, and the next launch offers the wizard again. + _ = alert("Setup cancelled", + "cix has not been set up yet, so the server cannot start.\n\n"+ + "Quit and reopen cix when you want to finish setting it up.") + } else { + logf("first-run setup failed: %v", err) + _ = alert("Setup failed", fmt.Sprintf("cix could not complete first-time setup.\n\n%v", err)) } } - }() + + default: + // A configured install with no runtime is the first launch after + // upgrading from a version that carried its server inside the bundle: + // the old app is gone, and with it the binary the launchd wrapper was + // pointing at. Say so before spending a minute on a download, because + // otherwise this is a menu bar app that appears to do nothing at all. + // + // Not said when a local tarball is supplied: there is no download to + // warn about, and this is the path a developer takes on every build. + if !runtimeReady() && os.Getenv("CIX_RUNTIME_TARBALL") == "" { + _ = alert("cix needs to finish updating", + "cix now keeps its server outside the application, so it updates without restarting.\n\n"+ + "It will download that part now — around 40 MB, once.") + } + showPanelBusy("Downloading the cix server…") + if err := ensureRuntime(m.updater, m.setProgress); err != nil { + logf("could not install the runtime: %v", err) + _ = alert("cix could not install its server", + fmt.Sprintf("%v\n\nThe menu bar app still works, but the server cannot start until this succeeds.", err)) + return + } + m.setProgress("") + clearPanelBusy() + // Only after the runtime exists: pointing the launchd wrapper at a + // binary that is not there would break a working install rather than + // leave it alone. + // + // Nothing is started here. An app update no longer stops the server — + // the bundle holds none of what it runs — so there is no interrupted + // state to resume, and starting a server the user had deliberately + // stopped would be the app overriding them. + if err := writeLaunchdFiles(autostartEnabled()); err != nil { + logf("could not refresh launchd files: %v", err) + } + } } func (m *menu) onExit() { close(m.stop) } -// watch redraws the menu whenever the poller reports a change. +// onAction is the panel's dispatcher. Each handler blocks (dialogs, server +// restarts) and arrives on its own goroutine — see goPanelAction. +func (m *menu) onAction(a panelAction) { + switch a.Action { + case "opened": + // The panel just became visible; answer with a fresh poll so the + // uptime and status shown are seconds old, not up to a tick old. The + // log line doubles as the proof-of-life for the whole ObjC bridge — + // it only appears if the status item, the panel and the webview all + // actually came up. + logf("panel opened") + m.poll.refresh() + case "dialog-result": + resolvePanelDialog(panelDialogResult{OK: a.OK, Text: a.Text}) + case "panel-closed": + // A dialog whose panel disappeared is a dialog nobody can answer any + // more; count it as declined so the waiting goroutine moves on. + resolvePanelDialog(panelDialogResult{OK: false}) + case "toggle-server": + m.toggleServer() + case "dashboard": + m.openDashboard() + case "toggle-network": + m.toggleNetworkAccess() + case "toggle-autostart": + m.toggleAutostart() + case "reset-password": + m.resetPasswordFlow() + case "check-updates": + m.checkForUpdates(true) + case "quit": + panelQuit() + default: + logf("panel sent an unknown action %q", a.Action) + } +} + +// watch redraws the panel whenever the poller reports a change. func (m *menu) watch() { for { select { @@ -173,71 +254,13 @@ func (m *menu) watch() { } func (m *menu) render(s snapshot) { - m.statusItem.SetTitle(s.ServerLine()) - m.statusItem.SetIcon(dotPNG(s.ServerDot())) - m.renderDetail(s) - - m.embeddingsItem.SetTitle(s.EmbeddingsLine()) - m.embeddingsItem.SetIcon(dotPNG(s.EmbeddingsDot())) - - if line := s.ModelLine(); line != "" { - m.modelItem.SetTitle(line) - // A transparent spacer, not a missing icon: AppKit indents a title by - // its image width, so without one this row would start left of the two - // above it and the group would read as ragged. - m.modelItem.SetIcon(dotPNG(dotBlank)) - m.modelItem.Show() - } else { - m.modelItem.Hide() - } - - 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 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 { - 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. - m.networkItem.Disable() - m.autostartItem.Disable() - m.resetPWItem.Enable() + // 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) } + panelSetState(buildPanelState(s, m.busy.Load(), m.currentBusyLabel())) } // toggleAutostart flips RunAtLoad on the launchd agent. @@ -251,6 +274,12 @@ func (m *menu) toggleAutostart() { if !s.Managed { return } + if !m.beginBusy("Applying the setting…") { + // The switch 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 { @@ -269,15 +298,38 @@ func (m *menu) toggleAutostart() { m.poll.refresh() } +// upToDateMessage reports what is installed when there is nothing to offer. +// +// Both halves, named and versioned. The wording this replaced folded them into +// one sentence and never said "app" at all, which read as a server-only check +// and made the launcher's own self-update look like a feature nobody had +// written — it is written, it just has nothing to compare against on a build +// that came from source. +func upToDateMessage() string { + msg := fmt.Sprintf("cix app\n%s\n\ncix server\n%s", + displayVersion(), strings.TrimPrefix(runtimeSummary(), "Server ")) + if isDevBuild() { + msg += "\n\nThis app was built from source, so it does not replace " + + "itself — only released builds update the app. The server updates either way." + } + return msg +} + // checkForUpdates looks for a newer release and, if the user agrees, installs -// it. `explicit` distinguishes the menu item from the background check: a +// it. `explicit` distinguishes the footer link from the background check: a // background check that finds nothing says nothing. func (m *menu) checkForUpdates(explicit bool) { + if explicit { + // The check takes a network round-trip; the card with a loader is what + // says the click worked. Every path out of here ends in a dialog that + // replaces it — except a decline, where the card is already gone with + // the question — so it never needs an explicit clear. + showPanelBusy("Checking for updates…") + } av := m.updater.check(explicit) if !av.any() { if explicit { - _ = alert("cix is up to date", fmt.Sprintf( - "You are running cix %s with %s.", displayVersion(), strings.ToLower(runtimeSummary()))) + _ = alert("cix is up to date", upToDateMessage()) } return } @@ -293,10 +345,10 @@ func (m *menu) checkForUpdates(explicit bool) { what = fmt.Sprintf("cix server %s is available. You are running %s.", av.Runtime.Version, currentRuntimeVersion()) effect = "The server will be updated and restarted. This app stays open, and if the new server does not start, cix goes back to the current one." case av.Runtime.Version == "": - what = fmt.Sprintf("cix %s is available. You are running %s.", av.App.Version, displayVersion()) + what = fmt.Sprintf("cix app %s is available. You are running %s.", av.App.Version, displayVersion()) effect = "cix will close and reopen. The server keeps running throughout." default: - what = fmt.Sprintf("cix %s and cix server %s are available.", av.App.Version, av.Runtime.Version) + what = fmt.Sprintf("cix app %s and cix server %s are available.", av.App.Version, av.Runtime.Version) effect = "The server will be updated and restarted, then cix will close and reopen." } @@ -313,6 +365,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("Installing the update…") { + _ = 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) @@ -331,32 +391,7 @@ func (m *menu) checkForUpdates(explicit bool) { // From here the swap helper owns the outcome: it waits for this process to // exit before moving anything, so quitting is the last required step. - systray.Quit() -} - -// renderDetail fills the submenu under the server row. Slots with nothing to -// say are hidden, so the submenu never shows an empty line. -func (m *menu) renderDetail(s snapshot) { - lines := [detailRows]string{ - s.DetailProcess(), - s.DetailPort(), - s.DetailNetwork(), - s.DetailModel(), - s.DetailVersion(), - // What is installed, as opposed to what the running server reports. The - // row above is empty whenever the server is not answering — which is - // exactly when someone wants to know which server is on disk. - runtimeSummary(), - s.DetailManaged(), - } - for i, line := range lines { - if line == "" { - m.detail[i].Hide() - continue - } - m.detail[i].SetTitle(line) - m.detail[i].Show() - } + panelQuit() } func (m *menu) toggleServer() { @@ -364,32 +399,98 @@ func (m *menu) toggleServer() { if !s.Managed { return } + 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() - 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 + } + + // 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() { + 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 + } + + // 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 { - verb := "start" - if s.State == stateRunning { - verb = "stop" + _ = 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 panel saying "Stopped" and nothing else — which is how + // a deleted database looked like a broken Start button. + if detail := serverDiedOnStart(); detail != "" { + logf("the server exited immediately after Start: %s", detail) + // One refusal deserves better than its log text: no admin account. The + // needsFirstRun check above cannot see this case, because the failed + // start itself recreated an empty cix.db — the file exists, the + // accounts do not. The server's own words are the reliable signal, so + // route them to setup instead of printing them. + if isBootstrapRefusal(detail) { + m.offerSetupAgain( + "The cix database exists but has no accounts — this is what deleting the data " + + "directory looks like after a start attempt recreates an empty database.\n\n" + + "The server will not start until an administrator account is created again. " + + "Setting up again creates a new account and a new, empty index.") + m.poll.refresh() + return } - _ = alert("cix", fmt.Sprintf("Could not %s the server.\n\n%v", verb, err)) + _ = alert("The server stopped straight away", detail+ + "\n\nThe full log is in ~/.cix/logs/cix-server.err.") } m.poll.refresh() } +// offerSetupAgain proposes re-running the setup wizard and runs it on consent. +// +// Shared by the two ways a gutted installation shows itself: the database file +// is missing outright (needsFirstRun), or it exists, freshly recreated and +// empty, and the server refused to start against it (isBootstrapRefusal). +func (m *menu) offerSetupAgain(message string) { + ok, err := confirm("Set cix up again?", message, "Set Up") + if err != nil || !ok { + return + } + if err := runFirstRun(m.updater); err != nil && !errors.Is(err, errCancelled) { + logf("re-running setup failed: %v", err) + _ = alert("Setup failed", fmt.Sprintf("cix could not set itself up again.\n\n%v", err)) + } +} + // toggleNetworkAccess switches CIX_BIND_ADDR between loopback and all // interfaces, then restarts the server so the change takes effect. // -// Widening access asks first. Nothing else in this menu changes what the -// machine exposes to the network, and a checkbox is an easy thing to hit by +// Widening access asks first. Nothing else in this panel changes what the +// machine exposes to the network, and a switch is an easy thing to hit by // accident; narrowing access needs no confirmation because it can only be safe. func (m *menu) toggleNetworkAccess() { vars, err := readServerEnv() @@ -397,6 +498,11 @@ func (m *menu) toggleNetworkAccess() { _ = alert("cix", "cix is not set up yet.") return } + if !m.beginBusy("Applying the setting…") { + m.render(m.poll.snapshotNow()) + return + } + defer m.endBusy() wasRunning := m.poll.snapshotNow().State == stateRunning local := isLocalOnly(vars) @@ -410,7 +516,7 @@ func (m *menu) toggleNetworkAccess() { "The server will restart.", serverPort(vars)), "Allow") if err != nil || !ok { - // Put the checkbox back: the click already toggled it visually. + // Put the switch back: the click already toggled it visually. m.render(m.poll.snapshotNow()) return } diff --git a/cli/launcher/panel.html b/cli/launcher/panel.html new file mode 100644 index 0000000..99d7562 --- /dev/null +++ b/cli/launcher/panel.html @@ -0,0 +1,635 @@ + + + + + + + + +
+
+ + + + +
+
+ + + + diff --git a/cli/launcher/panel_darwin.go b/cli/launcher/panel_darwin.go new file mode 100644 index 0000000..f982087 --- /dev/null +++ b/cli/launcher/panel_darwin.go @@ -0,0 +1,122 @@ +package main + +/* +#cgo LDFLAGS: -framework Cocoa -framework WebKit +#include + +void panel_run(const char *iconPath, const char *html); +void panel_set_state(const char *json); +void panel_set_title(const char *title); +void panel_quit(void); +*/ +import "C" + +import ( + _ "embed" + "encoding/json" + "runtime" + "unsafe" +) + +// AppKit is main-thread-only, and [NSApp run] must be started from the thread +// the process was born on. Locking in init — before main() runs — is the +// documented way to guarantee the main goroutine still owns that thread by the +// time panelRun is called. +func init() { + runtime.LockOSThread() +} + +// The cgo bridge to panel_darwin.m. Three exported callbacks come back from +// the Objective-C side; all of them arrive on the AppKit main thread, so each +// hands off to Go-land immediately and returns. + +//go:embed panel.html +var panelHTML string + +// panelHooks is set once, before panelRun, by the menu layer. Not guarded by a +// lock: writes happen strictly before [NSApp run] starts delivering callbacks. +var panelHooks struct { + onReady func() + onExit func() + onAction func(action panelAction) +} + +// panelAction is one user gesture inside the panel, as posted by panel.html. +// OK and Text carry a dialog's answer and are meaningless for other actions. +type panelAction struct { + Action string `json:"action"` + OK bool `json:"ok"` + Text string `json:"text"` +} + +//export goPanelReady +func goPanelReady() { + // From here on, dialogs render inside the panel instead of via osascript — + // the webview may still be loading, but requests pushed before it finishes + // are replayed on load (see pendingDialog in panel_darwin.m). + panelUIUp.Store(true) + if panelHooks.onReady != nil { + // Off the main thread: onReady starts pollers and may do I/O. + go panelHooks.onReady() + } +} + +//export goPanelExit +func goPanelExit() { + if panelHooks.onExit != nil { + panelHooks.onExit() + } +} + +//export goPanelAction +func goPanelAction(cjson *C.char) { + raw := C.GoString(cjson) + var a panelAction + if err := json.Unmarshal([]byte(raw), &a); err != nil { + logf("panel sent unparseable action %q: %v", raw, err) + return + } + if panelHooks.onAction != nil { + // Every action handler blocks (dialogs, restarts), and this callback + // is on the AppKit main thread. + go panelHooks.onAction(a) + } +} + +// panelRun starts the AppKit application and never returns until quit. +// Must be called on the main goroutine — AppKit demands the main thread, and +// main_darwin.go's runtime.LockOSThread guarantee comes from Go putting main() +// there. +func panelRun(iconPath string) { + cIcon := C.CString(iconPath) + cHTML := C.CString(panelHTML) + defer C.free(unsafe.Pointer(cIcon)) + defer C.free(unsafe.Pointer(cHTML)) + C.panel_run(cIcon, cHTML) +} + +// panelSetState pushes the render-state JSON to the webview. Safe from any +// goroutine. +func panelSetState(state any) { + b, err := json.Marshal(state) + if err != nil { + logf("could not marshal panel state: %v", err) + return + } + cs := C.CString(string(b)) + defer C.free(unsafe.Pointer(cs)) + C.panel_set_state(cs) +} + +// panelSetTitle puts text beside the menu bar icon (progress messages), or +// clears it with "". +func panelSetTitle(title string) { + cs := C.CString(title) + defer C.free(unsafe.Pointer(cs)) + C.panel_set_title(cs) +} + +// panelQuit terminates the application; goPanelExit fires on the way out. +func panelQuit() { + C.panel_quit() +} diff --git a/cli/launcher/panel_darwin.m b/cli/launcher/panel_darwin.m new file mode 100644 index 0000000..6d95b26 --- /dev/null +++ b/cli/launcher/panel_darwin.m @@ -0,0 +1,356 @@ +// panel_darwin.m — the AppKit half of the menu bar panel. +// +// This file owns exactly three things: the NSStatusItem in the menu bar, a +// borderless NSPanel that drops from it, and the WKWebView inside that panel. +// Everything the panel SHOWS comes from Go as one JSON state object +// (panel_set_state); everything the user DOES goes back to Go as one JSON +// action (goPanelAction). No decisions are made here — this is a projector. +// +// Why a webview and not native views: the design (mac/design spec) is a dense, +// bordered, token-driven layout — square toggles, a segmented progress bar, +// full-bleed 1.5pt dividers — that AppKit controls cannot be styled into. +// Drawing it as custom NSViews means reimplementing layout, hover states and +// dark mode by hand in a language this project otherwise does not use; HTML +// does all of that natively, follows the system appearance for free +// (prefers-color-scheme), and keeps the entire look in one reviewable file. +// The webview loads a single embedded HTML string. No network access, no +// remote content, no JavaScript beyond our own. +// +// Memory: compiled without ARC (cgo passes CFLAGS to the generated C too, and +// -fobjc-arc does not belong there). Every object stored in a static below is +// created with alloc/init or explicitly retained, and lives for the process — +// this app has exactly one panel and never tears it down. + +#import +#import + +extern void goPanelAction(const char *json); +extern void goPanelReady(void); +extern void goPanelExit(void); + +// The panel width is fixed by the design; height follows the content, reported +// by JavaScript after every render. +static const CGFloat kPanelWidth = 392; +// Gap between the menu bar and the panel's top edge. +static const CGFloat kPanelGap = 6; + +@interface CixPanel : NSPanel +@end + +@implementation CixPanel +// A borderless window refuses key status by default, and without it there are +// no keyboard events — no Esc to close, no Tab through controls. +- (BOOL)canBecomeKeyWindow { + return YES; +} +@end + +@interface CixController + : NSObject +@end + +static CixController *controller; +static NSStatusItem *statusItem; +static CixPanel *panel; +static WKWebView *webView; +static NSString *pendingHTML; +static NSString *pendingIconPath; +static NSString *pendingState; // last state JSON, replayed on load +static NSString *pendingDialog; // last dialog JSON, replayed on load +static BOOL webLoaded = NO; +static id clickMonitor; + +@implementation CixController + +- (void)applicationDidFinishLaunching:(NSNotification *)note { + // LSUIElement in Info.plist already makes this an accessory app; set it + // explicitly too so `go run` outside a bundle behaves the same. + [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; + + statusItem = [[[NSStatusBar systemStatusBar] + statusItemWithLength:NSVariableStatusItemLength] retain]; + // imageNamed finds cixTemplate.png + cixTemplate@2x.png in the bundle and + // pairs them into one multi-representation image; the path is the fallback + // for running outside a bundle during development. + NSImage *icon = [[NSImage imageNamed:@"cixTemplate"] retain]; + if (icon == nil) { + icon = [[NSImage alloc] initWithContentsOfFile:pendingIconPath]; + } + if (icon != nil) { + // Template: macOS recolours it for dark mode and the pressed state + // from the alpha channel alone. Never tint it manually. + [icon setTemplate:YES]; + [icon setSize:NSMakeSize(18, 18)]; + statusItem.button.image = icon; + statusItem.button.imagePosition = NSImageLeft; + } else { + statusItem.button.title = @"cix"; + } + statusItem.button.target = self; + statusItem.button.action = @selector(togglePanel:); + + [self buildPanel]; + + // Dismiss on any click outside the app. A global monitor never sees our + // own events, so clicks inside the panel are unaffected. + clickMonitor = [[NSEvent + addGlobalMonitorForEventsMatchingMask:(NSEventMaskLeftMouseDown | + NSEventMaskRightMouseDown) + handler:^(NSEvent *e) { + [self closePanel]; + }] retain]; + + goPanelReady(); +} + +- (void)applicationWillTerminate:(NSNotification *)note { + goPanelExit(); +} + +- (void)buildPanel { + panel = [[CixPanel alloc] + initWithContentRect:NSMakeRect(0, 0, kPanelWidth, 200) + styleMask:(NSWindowStyleMaskBorderless | + NSWindowStyleMaskNonactivatingPanel) + backing:NSBackingStoreBuffered + defer:NO]; + panel.opaque = NO; + panel.backgroundColor = [NSColor clearColor]; + // The system shadow, not a CSS one: it wraps the webview's opaque rounded + // rectangle exactly, and CSS shadows would need dead margins around the + // window to bleed into. + panel.hasShadow = YES; + panel.level = NSPopUpMenuWindowLevel; + panel.collectionBehavior = (NSWindowCollectionBehaviorCanJoinAllSpaces | + NSWindowCollectionBehaviorFullScreenAuxiliary); + panel.hidesOnDeactivate = NO; + panel.animationBehavior = NSWindowAnimationBehaviorNone; + panel.delegate = self; // for windowDidResignKey below + + WKWebViewConfiguration *cfg = + [[[WKWebViewConfiguration alloc] init] autorelease]; + [cfg.userContentController addScriptMessageHandler:self name:@"cix"]; + webView = [[WKWebView alloc] initWithFrame:panel.contentView.bounds + configuration:cfg]; + // Transparent chrome: the HTML draws the panel's rounded border itself, so + // the window must not paint white behind its corners. Private-ish but + // stable KVC key, the standard way to do this from outside WebKit. + [webView setValue:@NO forKey:@"drawsBackground"]; + webView.navigationDelegate = self; + webView.autoresizingMask = (NSViewWidthSizable | NSViewHeightSizable); + // No back/forward, no context menu content worth keeping — but the default + // menu on a right-click exposes "Reload", which would blank the panel + // until the next state push. Harmless, so not worth suppressing further. + panel.contentView = webView; + + [webView loadHTMLString:pendingHTML baseURL:nil]; +} + +- (void)togglePanel:(id)sender { + if (panel.visible) { + [self closePanel]; + } else { + [self openPanel]; + } +} + +- (void)openPanel { + if (panel.visible) { + return; + } + NSWindow *bar = statusItem.button.window; + if (bar == nil) { + return; + } + NSRect anchor = bar.frame; // already in screen coordinates + NSScreen *screen = bar.screen ?: [NSScreen mainScreen]; + + CGFloat x = NSMidX(anchor) - kPanelWidth / 2; + // Keep the panel on the screen it opened from, with the same 8pt breathing + // room the system gives its own menus. + CGFloat maxX = NSMaxX(screen.visibleFrame) - kPanelWidth - 8; + if (x > maxX) { + x = maxX; + } + if (x < NSMinX(screen.visibleFrame) + 8) { + x = NSMinX(screen.visibleFrame) + 8; + } + [panel setFrameTopLeftPoint:NSMakePoint(x, NSMinY(anchor) - kPanelGap)]; + + statusItem.button.highlighted = YES; + [panel makeKeyAndOrderFront:nil]; + [panel invalidateShadow]; + + // Tell Go the panel is being looked at: it answers with a fresh poll, so + // the uptime and status shown are seconds old, not up to a tick old. + goPanelAction("{\"action\":\"opened\"}"); +} + +- (void)closePanel { + if (!panel.visible) { + return; + } + [panel orderOut:nil]; + statusItem.button.highlighted = NO; + // Go must hear about this: an in-panel dialog whose panel disappears is a + // dialog nobody can answer, and the Go side is blocked waiting on it. + goPanelAction("{\"action\":\"panel-closed\"}"); +} + +// windowDidResignKey — clicking anything that takes key status away (another +// app, a dialog this app opens) closes the panel, matching menu behaviour. +- (void)windowDidResignKey:(NSNotification *)note { + if (note.object == panel) { + [self closePanel]; + } +} + +- (void)setStateJSON:(NSString *)json { + [pendingState release]; + pendingState = [json retain]; + if (!webLoaded) { + return; // replayed from didFinishNavigation + } + [self pushState]; +} + +- (void)pushState { + if (pendingState == nil) { + return; + } + NSString *js = + [NSString stringWithFormat:@"window.cixRender && window.cixRender(%@)", + pendingState]; + [webView evaluateJavaScript:js completionHandler:nil]; +} + +- (void)setDialogJSON:(NSString *)json { + [pendingDialog release]; + pendingDialog = [json retain]; + if (!webLoaded) { + return; // replayed from didFinishNavigation + } + [self pushDialog]; +} + +- (void)pushDialog { + if (pendingDialog == nil) { + return; + } + NSString *js = + [NSString stringWithFormat:@"window.cixDialog && window.cixDialog(%@)", + pendingDialog]; + [webView evaluateJavaScript:js completionHandler:nil]; +} + +- (void)setTitle:(NSString *)title { + statusItem.button.title = title; +} + +- (void)webView:(WKWebView *)wv + didFinishNavigation:(WKNavigation *)nav { + webLoaded = YES; + [self pushState]; + [self pushDialog]; +} + +- (void)userContentController:(WKUserContentController *)ucc + didReceiveScriptMessage:(WKScriptMessage *)message { + if (![message.body isKindOfClass:[NSDictionary class]]) { + return; + } + NSDictionary *body = message.body; + NSString *action = body[@"action"]; + + // Layout messages are handled here — they are about this window, not about + // the server, and Go has no business resizing NSWindows. + if ([action isEqualToString:@"height"]) { + CGFloat h = [body[@"value"] doubleValue]; + if (h < 40 || h > 1200) { + return; + } + NSRect f = panel.frame; + CGFloat top = NSMaxY(f); + f.size.height = h; + f.origin.y = top - h; + // No animation: the height changes either on a state flip (where the + // design calls for a crossfade, done in CSS) or before the panel is + // even visible. + [panel setFrame:f display:YES]; + [panel invalidateShadow]; + return; + } + if ([action isEqualToString:@"close"]) { + [self closePanel]; + return; + } + + NSData *data = [NSJSONSerialization dataWithJSONObject:body + options:0 + error:nil]; + if (data == nil) { + return; + } + NSString *json = [[[NSString alloc] initWithData:data + encoding:NSUTF8StringEncoding] + autorelease]; + + // Button-shaped actions dismiss the panel like a menu item would; toggle + // rows keep it open so the switch is seen doing its work. The distinction + // lives in the HTML (dismiss:true), not in a hardcoded list here. + if ([body[@"dismiss"] boolValue]) { + [self closePanel]; + } + goPanelAction(json.UTF8String); +} + +@end + +// --- C API, called from Go. Every entry point hops to the main thread: AppKit +// is main-thread-only and Go calls these from arbitrary goroutines. + +void panel_run(const char *iconPath, const char *html) { + @autoreleasepool { + pendingIconPath = [[NSString stringWithUTF8String:iconPath] retain]; + pendingHTML = [[NSString stringWithUTF8String:html] retain]; + NSApplication *app = [NSApplication sharedApplication]; + controller = [[CixController alloc] init]; + app.delegate = controller; + [app run]; + } +} + +void panel_set_state(const char *json) { + NSString *s = [NSString stringWithUTF8String:json]; + dispatch_async(dispatch_get_main_queue(), ^{ + [controller setStateJSON:s]; + }); +} + +void panel_set_dialog(const char *json) { + NSString *s = [NSString stringWithUTF8String:json]; + dispatch_async(dispatch_get_main_queue(), ^{ + [controller setDialogJSON:s]; + }); +} + +// panel_open fronts the panel — dialogs must surface even when it is closed. +void panel_open(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + [controller openPanel]; + }); +} + +void panel_set_title(const char *title) { + NSString *s = [NSString stringWithUTF8String:title]; + dispatch_async(dispatch_get_main_queue(), ^{ + [controller setTitle:s]; + }); +} + +void panel_quit(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + [NSApp terminate:nil]; + }); +} diff --git a/cli/launcher/paneldialog_darwin.go b/cli/launcher/paneldialog_darwin.go new file mode 100644 index 0000000..62ba3c0 --- /dev/null +++ b/cli/launcher/paneldialog_darwin.go @@ -0,0 +1,225 @@ +package main + +/* +#include +void panel_set_dialog(const char *json); +void panel_open(void); +*/ +import "C" + +import ( + "encoding/json" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + "unsafe" +) + +// In-panel dialogs. +// +// Every window this app used to open through osascript — alerts, questions, +// the email prompt, the password display — renders inside the panel instead, +// as an area that takes over its content (see panel.html's #dialog). One +// window, one place to look, one visual language. +// +// The mechanics: a dialog request is pushed to the webview as JSON, the panel +// is fronted so the request is actually visible, and the calling goroutine +// blocks on a channel until the user answers, closes the panel (a dialog +// whose window went away is answered "no"), or a generous timeout expires. +// Dialogs serialise on a mutex — two questions at once is a UI bug, not a +// feature. +// +// The osascript versions in dialog_darwin.go remain as the fallback for the +// narrow window before the webview has loaded, and for anything that must be +// said when the panel cannot exist (a translocated bundle refusing to run). + +// panelUIUp flips once the AppKit side has called goPanelReady; before that +// there is no webview to draw a dialog in. +var panelUIUp atomic.Bool + +type panelDialogSpec struct { + Kind string `json:"kind"` // "alert" | "confirm" | "prompt" | "secret" + Title string `json:"title"` + Message string `json:"message,omitempty"` + OKLabel string `json:"okLabel,omitempty"` + CancelLabel string `json:"cancelLabel,omitempty"` + DefaultAnswer string `json:"defaultAnswer,omitempty"` + Secret string `json:"secret,omitempty"` + Note string `json:"note,omitempty"` +} + +type panelDialogResult struct { + OK bool + Text string +} + +var dialogState struct { + mu sync.Mutex // serialises dialogs + pending chan panelDialogResult + pmu sync.Mutex // guards pending +} + +// showPanelDialog runs one dialog to completion and returns the answer. +func showPanelDialog(spec panelDialogSpec) (panelDialogResult, error) { + dialogState.mu.Lock() + defer dialogState.mu.Unlock() + + ch := make(chan panelDialogResult, 1) + dialogState.pmu.Lock() + dialogState.pending = ch + dialogState.pmu.Unlock() + + b, err := json.Marshal(spec) + if err != nil { + return panelDialogResult{}, err + } + pushDialogJSON(string(b)) + panelOpen() + + defer func() { + dialogState.pmu.Lock() + dialogState.pending = nil + dialogState.pmu.Unlock() + // Clear the dialog whichever way this ends — a timeout must not leave + // a zombie question on screen. + pushDialogJSON("null") + }() + + // The same order of patience osascript got: long enough to walk away and + // come back, bounded so an abandoned question cannot wedge a goroutine + // holding the busy claim forever. + select { + case r := <-ch: + return r, nil + case <-time.After(10 * time.Minute): + return panelDialogResult{}, errors.New("the dialog was not answered") + } +} + +// showPanelBusy puts up a modal card with a loader and no buttons — the +// placeholder for a result that is on its way (an update check, most of all). +// It answers nothing and blocks nobody; the next real dialog replaces it, and +// clearPanelBusy removes it on paths that end without one. No-op before the +// panel exists. +func showPanelBusy(title string) { + if !panelUIUp.Load() { + return + } + b, err := json.Marshal(panelDialogSpec{Kind: "busy", Title: title}) + if err != nil { + return + } + pushDialogJSON(string(b)) + panelOpen() +} + +func clearPanelBusy() { + if !panelUIUp.Load() { + return + } + pushDialogJSON("null") +} + +// resolvePanelDialog delivers an answer from the webview (or a panel-closed +// event). Safe to call when nothing is pending. +func resolvePanelDialog(r panelDialogResult) { + dialogState.pmu.Lock() + defer dialogState.pmu.Unlock() + if dialogState.pending == nil { + return + } + select { + case dialogState.pending <- r: + default: + } + dialogState.pending = nil +} + +func pushDialogJSON(s string) { + cs := C.CString(s) + defer C.free(unsafe.Pointer(cs)) + C.panel_set_dialog(cs) +} + +// panelOpen fronts the panel so a dialog is actually seen. +func panelOpen() { + C.panel_open() +} + +// --- The user-facing dialog API. Same names and contracts as the osascript +// layer so every caller works unchanged; each routes to the panel once it is +// up and falls back to osascript before that. + +func alert(title, message string) error { + if !panelUIUp.Load() { + return osaAlert(title, message) + } + _, err := showPanelDialog(panelDialogSpec{ + Kind: "alert", Title: title, Message: message, + }) + return err +} + +// alertWithSecret shows a credential and puts it on the clipboard. +// +// The copying happens when the dialog opens, not on a button, and the note +// says so — reaching for the clipboard is the next thing anyone does with a +// password the app just generated on purpose. The value itself renders in a +// selectable mono block, unlike the rest of the panel. +func alertWithSecret(title, message, secret, secretName string) error { + note := fmt.Sprintf("The %s is on your clipboard.", secretName) + if err := copyToClipboard(secret); err != nil { + logf("could not copy the %s to the clipboard: %v", secretName, err) + note = fmt.Sprintf("The %s could not be copied to your clipboard — select it above.", secretName) + } + if !panelUIUp.Load() { + return osaAlert(title, message+"\n\n"+secret+"\n\n"+note) + } + _, err := showPanelDialog(panelDialogSpec{ + Kind: "secret", Title: title, Message: message, + Secret: secret, Note: note, + }) + return err +} + +// prompt asks for one line of text. Returns errCancelled if the user cancels +// or closes the panel. +func prompt(title, message, defaultAnswer string) (string, error) { + if !panelUIUp.Load() { + return osaPrompt(title, message, defaultAnswer) + } + r, err := showPanelDialog(panelDialogSpec{ + Kind: "prompt", Title: title, Message: message, + DefaultAnswer: defaultAnswer, + }) + if err != nil { + return "", err + } + if !r.OK { + return "", errCancelled + } + return r.Text, nil +} + +// confirm shows a two-button question. Returns false when the user declines. +func confirm(title, message, okLabel string) (bool, error) { + return ask(title, message, okLabel, "Cancel") +} + +// ask shows a two-button question with both labels spelled out. yesLabel is +// the highlighted button; dismissing the dialog counts as no. +func ask(title, message, yesLabel, noLabel string) (bool, error) { + if !panelUIUp.Load() { + return osaAsk(title, message, yesLabel, noLabel) + } + r, err := showPanelDialog(panelDialogSpec{ + Kind: "confirm", Title: title, Message: message, + OKLabel: yesLabel, CancelLabel: noLabel, + }) + if err != nil { + return false, err + } + return r.OK, nil +} diff --git a/cli/launcher/panelstate_darwin.go b/cli/launcher/panelstate_darwin.go new file mode 100644 index 0000000..8a6abd5 --- /dev/null +++ b/cli/launcher/panelstate_darwin.go @@ -0,0 +1,159 @@ +package main + +import ( + "fmt" + "net" + "os/exec" + "strings" +) + +// panelState is the one object panel.html renders from. Field names are the +// contract with the JavaScript side; change them in both places or not at all. +type panelState struct { + State string `json:"state"` // "running" | "starting" | "stopped" + Busy bool `json:"busy"` + // BusyLabel names the operation holding busy, for the loader button — + // "Stopping the server…" and "Applying the setting…" are the same wait + // with very different explanations. + BusyLabel string `json:"busyLabel,omitempty"` + Managed bool `json:"managed"` + Port int `json:"port"` + PID int `json:"pid,omitempty"` + Uptime string `json:"uptime,omitempty"` + LocalOnly bool `json:"localOnly"` + LanAddr string `json:"lanAddr,omitempty"` + Autostart bool `json:"autostart"` + + Engine string `json:"engine,omitempty"` + EngineReady bool `json:"engineReady"` + Model string `json:"model,omitempty"` + ServerVersion string `json:"serverVersion,omitempty"` + + // What is on disk, shown when nothing is answering. + Runtime string `json:"runtime,omitempty"` + AppVersion string `json:"appVersion,omitempty"` + + IndexingJobs int `json:"indexingJobs"` + Projects int `json:"projects"` +} + +// buildPanelState folds a poller snapshot and the menu's own busy flag into +// what the panel shows. +func buildPanelState(s snapshot, busy bool, busyLabel string) panelState { + ps := panelState{ + Busy: busy, + BusyLabel: busyLabel, + Managed: s.Managed, + Port: s.Port, + PID: s.PID, + LocalOnly: s.LocalOnly, + Autostart: s.Autostart, + Runtime: currentRuntimeVersion(), + AppVersion: displayVersion(), + } + + switch s.State { + case stateRunning: + ps.State = "running" + ps.Uptime = processUptime(s.PID) + case stateStarting: + ps.State = "starting" + default: + ps.State = "stopped" + } + + if !s.LocalOnly { + ps.LanAddr = lanAddr(s.Port) + } + + if s.Status != nil { + ps.Engine = providerLabel(s.Status.EmbeddingProvider) + ps.EngineReady = s.EmbeddingsOK + ps.Model = s.ModelName() + ps.ServerVersion = s.Status.ServerVersion + ps.IndexingJobs = s.Status.ActiveIndexingJobs + ps.Projects = s.Status.Projects + } + + return ps +} + +// processUptime asks ps for the elapsed time of a pid and renders it the way +// the design's header expects ("4h 12m"). Empty when anything goes wrong — +// an uptime is decoration, never worth an error dialog. +func processUptime(pid int) string { + if pid == 0 { + return "" + } + out, err := exec.Command("ps", "-o", "etime=", "-p", fmt.Sprint(pid)).Output() + if err != nil { + return "" + } + return formatEtime(strings.TrimSpace(string(out))) +} + +// formatEtime converts ps's [[dd-]hh:]mm:ss into a two-unit human string. +// Split out from processUptime so the parsing is testable without a process. +func formatEtime(etime string) string { + if etime == "" { + return "" + } + days := 0 + if d, rest, ok := strings.Cut(etime, "-"); ok { + if _, err := fmt.Sscanf(d, "%d", &days); err != nil { + return "" + } + etime = rest + } + parts := strings.Split(etime, ":") + nums := make([]int, 0, 3) + for _, p := range parts { + var n int + if _, err := fmt.Sscanf(p, "%d", &n); err != nil { + return "" + } + nums = append(nums, n) + } + + var h, m int + switch len(nums) { + case 3: + h, m = nums[0], nums[1] + case 2: + m = nums[0] + default: + return "" + } + h += days * 24 + + // Two units at most: "2d 3h", "4h 12m", "7m". Seconds are noise on an + // uptime and the panel repolls anyway. + switch { + case h >= 24: + return fmt.Sprintf("%dd %dh", h/24, h%24) + case h > 0: + return fmt.Sprintf("%dh %dm", h, m) + default: + return fmt.Sprintf("%dm", m) + } +} + +// lanAddr names the address the server is reachable at from other machines — +// the consequence the network toggle's hint line exists to state. Best-effort: +// the first non-loopback IPv4 is the address a home network knows this Mac by. +func lanAddr(port int) string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "" + } + for _, a := range addrs { + ipnet, ok := a.(*net.IPNet) + if !ok || ipnet.IP.IsLoopback() { + continue + } + if ip4 := ipnet.IP.To4(); ip4 != nil { + return fmt.Sprintf("%s:%d", ip4, port) + } + } + return "" +} diff --git a/cli/launcher/resetpw_darwin.go b/cli/launcher/resetpw_darwin.go index df00412..3685c36 100644 --- a/cli/launcher/resetpw_darwin.go +++ b/cli/launcher/resetpw_darwin.go @@ -51,11 +51,13 @@ func (m *menu) resetPasswordFlow() { if err != nil || !ok { return } + showPanelBusy("Downloading the cix server…") if err := ensureRuntime(m.updater, m.setProgress); err != nil { _ = alert("Could not install the cix server", err.Error()) return } m.setProgress("") + clearPanelBusy() } server, err := runtimeServerPath() @@ -70,10 +72,11 @@ func (m *menu) resetPasswordFlow() { return } - _ = alert("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)) + "account have been signed out.", email, password), + password, "password") } // runResetPassword executes the reset and returns the generated password. diff --git a/cli/launcher/selfupdate_darwin_test.go b/cli/launcher/selfupdate_darwin_test.go new file mode 100644 index 0000000..1df36ae --- /dev/null +++ b/cli/launcher/selfupdate_darwin_test.go @@ -0,0 +1,349 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// Self-update, end to end, against a fake releases API. +// +// This path had never been executed: no mac/v* release exists, so the app half +// of the updater has only ever seen an empty stream. Everything it does before +// the swap is reachable here — the version comparison that decides to offer, +// the download, the checksum, mounting the image and validating the signature — +// and the swap script itself is driven with a real process to wait for and a +// stub `open` on PATH. + +// fakeRelease builds a DMG containing a minimal signed cix.app and serves it, +// with checksums.txt, from a fake GitHub releases API. Returns the base URL. +func fakeRelease(t *testing.T, version string) string { + t.Helper() + dir := t.TempDir() + + // A bundle, not a stub directory: stageFromDMG runs `codesign --verify + // --strict` on what it copies out, and that is a check worth exercising + // rather than working around. + app := filepath.Join(dir, "payload", "cix.app") + macOS := filepath.Join(app, "Contents", "MacOS") + if err := os.MkdirAll(macOS, 0o755); err != nil { + t.Fatal(err) + } + plist := fmt.Sprintf(` + + +CFBundleIdentifiercom.dvcdsys.cix.test +CFBundleNamecix +CFBundleExecutablecix-launcher +CFBundleShortVersionString%s +`, version) + if err := os.WriteFile(filepath.Join(app, "Contents", "Info.plist"), []byte(plist), 0o644); err != nil { + t.Fatal(err) + } + // Any Mach-O will do; the point is that a signature seals it. `true` is + // present on every macOS and is tiny. + if out, err := exec.Command("cp", "/usr/bin/true", filepath.Join(macOS, "cix-launcher")).CombinedOutput(); err != nil { + t.Fatalf("cp: %v: %s", err, out) + } + if out, err := exec.Command("codesign", "--force", "--sign", "-", app).CombinedOutput(); err != nil { + t.Skipf("codesign unavailable in this environment: %v: %s", err, out) + } + + dmgName := "cix-" + version + "-arm64.dmg" + dmgPath := filepath.Join(dir, dmgName) + if out, err := exec.Command("hdiutil", "create", "-quiet", "-srcfolder", + filepath.Join(dir, "payload"), "-volname", "cix", "-format", "UDZO", dmgPath).CombinedOutput(); err != nil { + t.Skipf("hdiutil unavailable in this environment: %v: %s", err, out) + } + + sums, err := exec.Command("shasum", "-a", "256", dmgPath).Output() + if err != nil { + t.Fatal(err) + } + // The workflow writes "./name.dmg"; checksumFor matches on the base name, + // so keep the awkward shape rather than a tidied one. + sumLine := strings.Fields(string(sums))[0] + " ./" + dmgName + "\n" + sumsPath := filepath.Join(dir, "checksums.txt") + if err := os.WriteFile(sumsPath, []byte(sumLine), 0o644); err != nil { + t.Fatal(err) + } + + mux := http.NewServeMux() + var base string + mux.HandleFunc("/repos/dvcdsys/code-index/releases", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("ETag", `"fake-etag"`) + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "tag_name": "mac/v" + version, + "html_url": "https://example.invalid", + "assets": []map[string]any{ + {"name": dmgName, "browser_download_url": base + "/dl/" + dmgName}, + {"name": "checksums.txt", "browser_download_url": base + "/dl/checksums.txt"}, + }, + }}) + }) + mux.Handle("/dl/", http.StripPrefix("/dl/", http.FileServer(http.Dir(dir)))) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + base = srv.URL + return srv.URL +} + +func TestSelfUpdateOffersAndStages(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) // prefs and the update cache live under it + + base := fakeRelease(t, "9.9.9") + t.Setenv("CIX_UPDATE_BASE_URL", base) + + // The installed app, in a directory this test owns — the staged copy lands + // beside it, which is what the real swap needs. + apps := filepath.Join(home, "Applications") + root := filepath.Join(apps, "cix.app") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + + // A stamped build: "dev" deliberately refuses to update itself, and that + // refusal is why nothing was ever offered on a locally built app. + restore := version + version = "0.1.0" + t.Cleanup(func() { version = restore }) + + u := newUpdater(bundle{Root: root}) + av := u.check(true) + if av.App.Version != "9.9.9" { + t.Fatalf("check() offered app %q, want 9.9.9", av.App.Version) + } + // Only the mac stream is published in the fake API, so the runtime half + // must stay quiet rather than treating "no releases" as an update. + if av.Runtime.Version != "" { + t.Errorf("check() offered runtime %q from a stream with no releases", av.Runtime.Version) + } + + staged, err := u.stageUpdate(av.App, func(string) {}) + if err != nil { + t.Fatalf("stageUpdate: %v", err) + } + if want := filepath.Join(apps, ".cix.app.new"); staged != want { + t.Errorf("staged at %q, want %q", staged, want) + } + // Validated, not merely copied: the signature survived the DMG round trip. + if out, err := exec.Command("codesign", "--verify", "--strict", staged).CombinedOutput(); err != nil { + t.Errorf("staged bundle fails verification: %v: %s", err, out) + } + if b, err := os.ReadFile(filepath.Join(staged, "Contents", "Info.plist")); err != nil { + t.Error(err) + } else if !strings.Contains(string(b), "9.9.9") { + t.Error("the staged bundle is not the version that was offered") + } + + // A dev build must not be offered the app half, whatever is published. + version = "dev" + if got := u.check(true); got.App.Version != "" { + t.Errorf("a development build was offered app %q", got.App.Version) + } +} + +// A corrupted download must be discarded rather than installed: with no +// Developer ID signature, this checksum is the whole integrity story. +func TestSelfUpdateRejectsATamperedImage(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + base := fakeRelease(t, "9.9.9") + t.Setenv("CIX_UPDATE_BASE_URL", base) + + apps := filepath.Join(home, "Applications") + root := filepath.Join(apps, "cix.app") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + + restore := version + version = "0.1.0" + t.Cleanup(func() { version = restore }) + + u := newUpdater(bundle{Root: root}) + av := u.check(true) + + // Point the DMG asset at the checksums file: the bytes fetched are then not + // the bytes the release lists, which is exactly what a truncated or swapped + // download looks like. + for i, a := range av.App.Assets { + if strings.HasSuffix(a.Name, ".dmg") { + av.App.Assets[i].URL = base + "/dl/checksums.txt" + } + } + + staged, err := u.stageUpdate(av.App, func(string) {}) + if err == nil { + t.Fatal("stageUpdate accepted an image that does not match its checksum") + } + if !strings.Contains(err.Error(), "checksum") { + t.Errorf("error = %v, want it to name the checksum", err) + } + if staged != "" { + t.Errorf("staged = %q, want nothing staged", staged) + } + if _, err := os.Stat(filepath.Join(apps, ".cix.app.new")); !os.IsNotExist(err) { + t.Error("a rejected update left a staged bundle behind") + } +} + +// The up-to-date dialog is the only place most people ever read the app's own +// version, so what it says on a released build — as opposed to one built from +// source — is worth pinning. +func TestUpToDateMessage(t *testing.T) { + restore := version + t.Cleanup(func() { version = restore }) + + t.Run("released build", func(t *testing.T) { + version = "0.1.0" + msg := upToDateMessage() + if !strings.Contains(msg, "cix app\nv0.1.0") { + t.Errorf("message does not report the app version:\n%s", msg) + } + // The caveat belongs only to builds that cannot update themselves; + // on a release it would be a lie. + if strings.Contains(msg, "built from source") { + t.Errorf("a released build claims it cannot update itself:\n%s", msg) + } + }) + + t.Run("development build", func(t *testing.T) { + version = "dev" + msg := upToDateMessage() + if !strings.Contains(msg, "development build") { + t.Errorf("message does not say it is a development build:\n%s", msg) + } + // Why nothing is ever offered — without it the check looks broken. + if !strings.Contains(msg, "built from source") { + t.Errorf("message does not explain why it never updates:\n%s", msg) + } + }) +} + +// swap.sh is the one part that runs after the app is gone, so its failure modes +// are the ones nobody is around to see. +func TestSwapScript(t *testing.T) { + dir := t.TempDir() + + // Stub `open`, so the successful path can be exercised without handing a + // fake bundle to LaunchServices. + bin := filepath.Join(dir, "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + openLog := filepath.Join(dir, "open.log") + if err := os.WriteFile(filepath.Join(bin, "open"), + []byte("#!/bin/bash\necho \"$@\" >> "+openLog+"\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+":"+os.Getenv("PATH")) + + script := filepath.Join(dir, "swap.sh") + if err := os.WriteFile(script, swapScript, 0o755); err != nil { + t.Fatal(err) + } + + newBundle := func(t *testing.T, path, marker string) { + t.Helper() + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "marker"), []byte(marker), 0o644); err != nil { + t.Fatal(err) + } + } + + t.Run("replaces the bundle once the launcher exits", func(t *testing.T) { + live := filepath.Join(dir, "live", "cix.app") + staged := filepath.Join(dir, "live", ".cix.app.new") + newBundle(t, live, "old") + newBundle(t, staged, "new") + + // A real process to wait for, which exits shortly after the script + // starts — the sequence the update actually produces. + victim := exec.Command("/bin/sleep", "1") + if err := victim.Start(); err != nil { + t.Fatal(err) + } + // Reaped concurrently, and that detail is not incidental: an exited + // child whose parent has not waited on it keeps its pid in the process + // table, where `kill -0` still succeeds — so an unreaped victim makes + // swap.sh wait out its full patience and abort. In the real sequence + // the launcher is a child of launchd, which reaps it immediately. + reaped := make(chan struct{}) + go func() { _ = victim.Wait(); close(reaped) }() + + out, err := exec.Command("/bin/bash", script, fmt.Sprint(victim.Process.Pid), + live, staged, filepath.Join(dir, "swap.log")).CombinedOutput() + <-reaped + if err != nil { + t.Fatalf("swap.sh: %v: %s", err, out) + } + + if b, err := os.ReadFile(filepath.Join(live, "marker")); err != nil || string(b) != "new" { + t.Errorf("live bundle = %q (%v), want the staged one", b, err) + } + if _, err := os.Stat(staged); !os.IsNotExist(err) { + t.Error("the staged bundle was left behind") + } + // Nothing half-updated: the moved-aside copy is cleaned up. + if _, err := os.Stat(live + ".old"); !os.IsNotExist(err) { + t.Error("the previous bundle was left at .old") + } + if b, err := os.ReadFile(openLog); err != nil || !strings.Contains(string(b), live) { + t.Errorf("swap.sh did not reopen the app: %q (%v)", b, err) + } + }) + + t.Run("refuses to swap under a live launcher", func(t *testing.T) { + if testing.Short() { + // The guard is a 60s timeout, and there is no way to observe it + // firing without spending the 60s. + t.Skip("takes a minute: swap.sh's patience is the thing under test") + } + live := filepath.Join(dir, "alive", "cix.app") + staged := filepath.Join(dir, "alive", ".cix.app.new") + newBundle(t, live, "old") + newBundle(t, staged, "new") + + // Outlives the script's 60s patience, so the guard is what ends it. + victim := exec.Command("/bin/sleep", "120") + if err := victim.Start(); err != nil { + t.Fatal(err) + } + defer func() { + _ = victim.Process.Kill() + _ = victim.Wait() + }() + + done := make(chan error, 1) + cmd := exec.Command("/bin/bash", script, fmt.Sprint(victim.Process.Pid), + live, staged, filepath.Join(dir, "swap-alive.log")) + go func() { done <- cmd.Run() }() + + select { + case err := <-done: + if err == nil { + t.Error("swap.sh replaced a bundle under a running launcher") + } + case <-time.After(75 * time.Second): + _ = cmd.Process.Kill() + t.Fatal("swap.sh did not give up waiting for a launcher that never exits") + } + + if b, err := os.ReadFile(filepath.Join(live, "marker")); err != nil || string(b) != "old" { + t.Errorf("live bundle = %q (%v), want it untouched", b, err) + } + }) +} diff --git a/cli/launcher/status_darwin.go b/cli/launcher/status_darwin.go index 846f05d..b14aac2 100644 --- a/cli/launcher/status_darwin.go +++ b/cli/launcher/status_darwin.go @@ -1,8 +1,6 @@ package main import ( - "fmt" - "image/color" "strings" "sync" "time" @@ -91,168 +89,15 @@ func providerLabel(kind string) string { } } -// maxRowRunes caps every menu row. -// -// An NSMenu is exactly as wide as its widest row, so an untruncated model id -// (`ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF`, 41 characters) stretched the -// whole menu to fit one line nobody needs to read in full. Capping every row at -// the same width makes the menu a predictable size instead of a function of -// whichever model happens to be configured; the full value goes in the details -// submenu. -const maxRowRunes = 34 - -// ellipsize shortens s to at most maxRunes, cutting from the middle. -// -// Middle rather than tail because these values are qualified names — -// "awhiteside/CodeRankEmbed-Q8_0-GGUF" — where both ends carry information and -// the middle is the least missed. Tail truncation would leave every Hugging -// Face model rendered as its owner. -func ellipsize(s string, maxRunes int) string { - r := []rune(s) - if len(r) <= maxRunes || maxRunes < 3 { - return s - } - keep := maxRunes - 1 // one rune for the ellipsis - head := (keep + 1) / 2 - tail := keep - head - return string(r[:head]) + "…" + string(r[len(r)-tail:]) -} - -// row renders "label + value", ellipsizing the value so the whole row fits. -func row(label, value string) string { - return label + ellipsize(value, maxRowRunes-len([]rune(label))) -} - -// EmbeddingsLine renders the provider row of the menu. -// -// Readiness is carried by the row's dot, not by a "— ready" suffix: the words -// cost menu width that the model name needs, and a coloured indicator is how -// macOS status apps say this. -func (s snapshot) EmbeddingsLine() string { - if s.State != stateRunning || s.Status == nil { - return "Embeddings: unknown" - } - return row("Embeddings: ", providerLabel(s.Status.EmbeddingProvider)) -} - -// EmbeddingsDot is the indicator colour for the provider row. -func (s snapshot) EmbeddingsDot() color.NRGBA { - switch { - case s.State != stateRunning || s.Status == nil: - return dotGrey - case s.EmbeddingsOK: - return dotGreen - default: - return dotRed - } -} - -// ServerDot is the indicator colour for the server row. -func (s snapshot) ServerDot() color.NRGBA { - switch s.State { - case stateRunning: - return dotGreen - case stateStarting: - return dotAmber - default: - return dotRed - } -} - -// ServerLine renders the top row of the menu. -func (s snapshot) ServerLine() string { - switch s.State { - case stateRunning: - return fmt.Sprintf("cix-server: Running (:%d)", s.Port) - case stateStarting: - return "cix-server: Starting…" - default: - if !s.Managed { - // "(managed externally)" spelled out is 40 characters and would set - // the width of the whole menu on its own. The details submenu explains. - return "cix-server: Stopped (external)" - } - return "cix-server: Stopped" - } -} - -// Detail rows — the information the truncated rows cannot carry, shown in a -// submenu on the server row rather than in tooltips. -// -// Native menu-item tooltips were tried and removed. Two AppKit behaviours make -// them unusable here and neither has an API: once any tooltip in the app has -// appeared, every subsequent one shows with no delay at all; and they are -// positioned against the menu item rather than the pointer. Fixing either means -// giving every row a custom NSView with its own tracking area — that is, -// writing the menu in Objective-C instead of using systray. A submenu gets -// native timing and placement for free. - -// DetailProcess reports the running process, or that there is none. -func (s snapshot) DetailProcess() string { - if s.PID == 0 { - return "Process: not running" - } - return fmt.Sprintf("Process: %d", s.PID) -} - -func (s snapshot) DetailPort() string { - return fmt.Sprintf("Port: %d", s.Port) -} - -// DetailNetwork states the exposure in plain terms. "127.0.0.1" is precise and -// means nothing to most people; "this Mac only" is the fact they care about. -func (s snapshot) DetailNetwork() string { - if s.LocalOnly { - return "Network: this Mac only" - } - return "Network: reachable from your network" -} - -// DetailModel is the full, untruncated model id — the value the row had to cut. -func (s snapshot) DetailModel() string { - if name := s.ModelName(); name != "" { - return "Model: " + name - } - return "" -} - -func (s snapshot) DetailVersion() string { - if s.Status == nil || s.Status.ServerVersion == "" { - return "" - } - return "Server " + s.Status.ServerVersion -} - -// DetailManaged explains a disabled Start/Stop, which is otherwise inexplicable. -func (s snapshot) DetailManaged() string { - if s.Managed { - return "" - } - return "Managed by install-server.sh" -} - -// ModelLine renders the model row, or an empty string when there is nothing to -// say — the menu hides the item rather than showing "Model: unknown". -func (s snapshot) ModelLine() string { - if s.State != stateRunning || s.Status == nil || s.Status.EmbeddingModel == "" { - return "" - } - // The server reports the provider's fingerprint ID, which is prefixed with - // the provider kind ("ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF"). The row - // above already names the provider, and the prefix is the same misleading - // name providerLabel exists to avoid — so show the model alone. - return row("Model: ", s.ModelName()) -} - -// ModelName is the untruncated model, for the tooltip. +// ModelName strips the provider prefix off the reported model id. func (s snapshot) ModelName() string { if s.Status == nil { return "" } // The server reports the provider's fingerprint ID, which is prefixed with - // the provider kind ("ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF"). The row - // above already names the provider, and the prefix is the same misleading - // name providerLabel exists to avoid — so show the model alone. + // the provider kind ("ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF"). The panel + // already names the provider on its engine row, and the prefix is the same + // misleading name providerLabel exists to avoid — so show the model alone. model := s.Status.EmbeddingModel if _, rest, ok := strings.Cut(model, ":"); ok && rest != "" { model = rest diff --git a/cli/launcher/status_darwin_test.go b/cli/launcher/status_darwin_test.go index af51550..9b9d038 100644 --- a/cli/launcher/status_darwin_test.go +++ b/cli/launcher/status_darwin_test.go @@ -5,9 +5,7 @@ import ( "fmt" "os" "path/filepath" - "strings" "testing" - "unicode/utf8" "github.com/dvcdsys/code-index/cli/internal/client" ) @@ -29,114 +27,80 @@ func TestProviderLabel(t *testing.T) { } } -func TestSnapshotLines(t *testing.T) { +// buildPanelState is the whole contract between the poller and panel.html — +// every field the JavaScript renders comes through it. +func TestBuildPanelState(t *testing.T) { running := snapshot{ - State: stateRunning, - Port: 21847, - Managed: true, + State: stateRunning, PID: 4242, Port: 21847, + Managed: true, LocalOnly: true, Autostart: true, Status: &client.StatusResponse{ - EmbeddingProvider: "ollama", - EmbeddingModel: "ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF", + ServerVersion: "0.12.9", + EmbeddingProvider: "ollama", + EmbeddingModel: "ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF", + ActiveIndexingJobs: 2, + Projects: 14, }, EmbeddingsOK: true, } - - if got, want := running.ServerLine(), "cix-server: Running (:21847)"; got != want { - t.Errorf("ServerLine() = %q, want %q", got, want) - } - // Readiness is on the dot, not in the text: the words "— ready" cost menu - // width, and an NSMenu is as wide as its widest row. - if got, want := running.EmbeddingsLine(), "Embeddings: llama.cpp (bundled)"; got != want { - t.Errorf("EmbeddingsLine() = %q, want %q", got, want) + ps := buildPanelState(running, false, "") + if ps.State != "running" || ps.PID != 4242 || ps.Port != 21847 { + t.Errorf("state/pid/port = %q/%d/%d, want running/4242/21847", ps.State, ps.PID, ps.Port) } - // The provider prefix is stripped — the row above already names the - // provider, and repeating the misleading kind defeats providerLabel — and - // the remainder is middle-truncated to the width cap. - if got, want := running.ModelLine(), "Model: awhiteside/Co…bed-Q8_0-GGUF"; got != want { - t.Errorf("ModelLine() = %q, want %q", got, want) + // The provider kind is translated, and the model loses the misleading + // provider prefix — same reasoning as providerLabel. + if ps.Engine != "llama.cpp (bundled)" || !ps.EngineReady { + t.Errorf("engine = %q ready=%v, want the bundled label and ready", ps.Engine, ps.EngineReady) } - if got, want := running.ModelName(), "awhiteside/CodeRankEmbed-Q8_0-GGUF"; got != want { - t.Errorf("ModelName() = %q, want %q (the details submenu keeps the full id)", got, want) + if ps.Model != "awhiteside/CodeRankEmbed-Q8_0-GGUF" { + t.Errorf("model = %q, want the prefix stripped", ps.Model) } -} - -func TestSnapshotLines_NotRunning(t *testing.T) { - // A cold start loads the embedding model in silence for up to a few - // minutes. Calling that "Stopped" is what makes people kill and restart a - // server that was nearly ready, so it gets its own state. - starting := snapshot{State: stateStarting, Managed: true} - if got, want := starting.ServerLine(), "cix-server: Starting…"; got != want { - t.Errorf("ServerLine() = %q, want %q", got, want) + if ps.ServerVersion != "0.12.9" || ps.IndexingJobs != 2 || ps.Projects != 14 { + t.Errorf("version/jobs/projects = %q/%d/%d", ps.ServerVersion, ps.IndexingJobs, ps.Projects) } - // Provider details from a server that is not answering are stale by - // definition, so no row claims otherwise. - if got, want := starting.ModelLine(), ""; got != want { - t.Errorf("ModelLine() = %q, want %q (hidden)", got, want) + if !ps.LocalOnly || ps.LanAddr != "" { + t.Errorf("a loopback-bound server must not advertise a LAN address, got %q", ps.LanAddr) } - if got, want := starting.EmbeddingsLine(), "Embeddings: unknown"; got != want { - t.Errorf("EmbeddingsLine() = %q, want %q", got, want) + if !ps.Autostart { + t.Error("autostart lost on the way through") } - // An agent installed by install-server.sh owns the same launchd label. The - // app observes it but must not offer to drive it. Abbreviated in the row - // because spelled out it is 40 characters and would set the menu's width on - // its own; the details submenu carries the explanation. - external := snapshot{State: stateStopped, Managed: false} - if got, want := external.ServerLine(), "cix-server: Stopped (external)"; got != want { - t.Errorf("ServerLine() = %q, want %q", got, want) - } - // A disabled Start/Stop is otherwise inexplicable, so the reason is in the - // details submenu. - if !strings.Contains(external.DetailManaged(), "install-server.sh") { - t.Errorf("DetailManaged() should name the external installer, got %q", external.DetailManaged()) + // Provider details from a server that is not answering are stale by + // definition, so none are claimed. + starting := buildPanelState(snapshot{State: stateStarting, Managed: true}, false, "") + if starting.State != "starting" || starting.Engine != "" || starting.Model != "" { + t.Errorf("starting = %+v, want no provider details", starting) } -} -func TestDetailRows(t *testing.T) { - // The details submenu replaced menu-item tooltips: once any AppKit tooltip - // has shown, every later one appears with no delay, and they are positioned - // against the item rather than the pointer. Neither has an API. - s := snapshot{ - State: stateRunning, PID: 4242, Port: 21847, Managed: true, LocalOnly: true, - Status: &client.StatusResponse{ - ServerVersion: "0.12.4", - EmbeddingModel: "ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF", - }, - } - if got, want := s.DetailProcess(), "Process: 4242"; got != want { - t.Errorf("DetailProcess() = %q, want %q", got, want) - } - if got, want := s.DetailPort(), "Port: 21847"; got != want { - t.Errorf("DetailPort() = %q, want %q", got, want) - } - // "127.0.0.1" is precise and means nothing to most people; the exposure is - // what they need to know. - if got, want := s.DetailNetwork(), "Network: this Mac only"; got != want { - t.Errorf("DetailNetwork() = %q, want %q", got, want) - } - // The full id, which the truncated row could not carry — the whole reason - // this submenu exists. - if got, want := s.DetailModel(), "Model: awhiteside/CodeRankEmbed-Q8_0-GGUF"; got != want { - t.Errorf("DetailModel() = %q, want %q", got, want) - } - if got, want := s.DetailVersion(), "Server 0.12.4"; got != want { - t.Errorf("DetailVersion() = %q, want %q", got, want) - } - if got := s.DetailManaged(); got != "" { - t.Errorf("DetailManaged() = %q, want empty for an app-managed agent", got) + // The busy flag is the menu's, not the poller's; it must pass through with + // its label — the flag is what the panel disables its controls on, and the + // label is the only thing telling the user which slow operation is running. + busy := buildPanelState(running, true, "Stopping the server…") + if !busy.Busy || busy.BusyLabel != "Stopping the server…" { + t.Errorf("busy/label = %v/%q, want them passed through", busy.Busy, busy.BusyLabel) } - // Rows with nothing to say are hidden, never blank. - stopped := snapshot{State: stateStopped, Port: 21847, Managed: true} - if got, want := stopped.DetailProcess(), "Process: not running"; got != want { - t.Errorf("DetailProcess() = %q, want %q", got, want) + stopped := buildPanelState(snapshot{State: stateStopped, Managed: false}, false, "") + if stopped.State != "stopped" || stopped.Managed { + t.Errorf("stopped external = %+v", stopped) } - if stopped.DetailModel() != "" || stopped.DetailVersion() != "" { - t.Error("model and version rows should be empty when the server is not answering") - } - exposed := snapshot{State: stateRunning, Port: 21847, Managed: true, LocalOnly: false} - if got, want := exposed.DetailNetwork(), "Network: reachable from your network"; got != want { - t.Errorf("DetailNetwork() = %q, want %q", got, want) +} + +// formatEtime parses everything ps -o etime is documented to print. +func TestFormatEtime(t *testing.T) { + tests := map[string]string{ + "04:12": "4m", + "00:42": "0m", + "4:12:33": "4h 12m", + "04:12:33": "4h 12m", + "2-03:04:05": "2d 3h", + "12-00:00:01": "12d 0h", + "": "", + "garbage": "", + } + for in, want := range tests { + if got := formatEtime(in); got != want { + t.Errorf("formatEtime(%q) = %q, want %q", in, got, want) + } } } @@ -196,80 +160,17 @@ func TestPollerTrustsHTTPProviders(t *testing.T) { } } -func TestEllipsize(t *testing.T) { - tests := []struct { - in string - max int - want string - }{ - {"short", 10, "short"}, - {"exactly-10", 10, "exactly-10"}, - // Middle, not tail: both ends of a qualified name carry information, - // and tail truncation renders every Hugging Face model as its owner. - {"awhiteside/CodeRankEmbed-Q8_0-GGUF", 27, "awhiteside/Co…d-Q8_0-GGUF"}, - {"abcdefghij", 5, "abc…j"}, - // Multi-byte input must be cut on rune boundaries, not bytes. - {"привіт-світе-довгий-рядок", 10, "привіт…ядок"}, - } - for _, tc := range tests { - got := ellipsize(tc.in, tc.max) - if len([]rune(got)) > tc.max && len([]rune(tc.in)) > tc.max { - t.Errorf("ellipsize(%q, %d) = %q, %d runes — over the cap", tc.in, tc.max, got, len([]rune(got))) - } - if !utf8.ValidString(got) { - t.Errorf("ellipsize(%q, %d) produced invalid UTF-8: %q", tc.in, tc.max, got) - } - } -} - -func TestRowsFitTheWidthCap(t *testing.T) { - // An NSMenu is exactly as wide as its widest row, so a long model id used - // to stretch the whole menu. Every row must stay inside the cap. - s := snapshot{ - State: stateRunning, - Port: 21847, - Managed: true, - Status: &client.StatusResponse{ - EmbeddingProvider: "ollama", - EmbeddingModel: "ollama:some-extremely-long-organisation/an-even-longer-model-name-v2", - }, - EmbeddingsOK: true, - } - for name, line := range map[string]string{ - "ServerLine": s.ServerLine(), - "EmbeddingsLine": s.EmbeddingsLine(), - "ModelLine": s.ModelLine(), - } { - if n := len([]rune(line)); n > maxRowRunes { - t.Errorf("%s = %q is %d runes, over the %d cap", name, line, n, maxRowRunes) - } - } - // The untruncated value is still available for the details submenu. +// ModelName keeps the untruncated id — panel.html shows it whole and relies on +// CSS to fit it, so the only transformation allowed here is the prefix strip. +func TestModelName(t *testing.T) { + s := snapshot{Status: &client.StatusResponse{ + EmbeddingModel: "ollama:some-extremely-long-organisation/an-even-longer-model-name-v2", + }} if got, want := s.ModelName(), "some-extremely-long-organisation/an-even-longer-model-name-v2"; got != want { t.Errorf("ModelName() = %q, want %q", got, want) } -} - -func TestDots(t *testing.T) { - running := snapshot{State: stateRunning, Status: &client.StatusResponse{}, EmbeddingsOK: true} - if running.ServerDot() != dotGreen || running.EmbeddingsDot() != dotGreen { - t.Error("a running server with a ready provider should be green on both rows") - } - // Starting is amber, not red: a cold start loading a model is working, and - // red is what makes people kill it. - if (snapshot{State: stateStarting}).ServerDot() != dotAmber { - t.Error("starting should be amber") - } - if (snapshot{State: stateStopped}).ServerDot() != dotRed { - t.Error("stopped should be red") - } - // Nothing is known about the provider when the server is down; grey says - // "unknown", red would claim it is broken. - if (snapshot{State: stateStopped}).EmbeddingsDot() != dotGrey { - t.Error("provider state should be grey when the server is not answering") - } - if len(dotPNG(dotGreen)) == 0 { - t.Error("dotPNG returned no bytes") + if got := (snapshot{}).ModelName(); got != "" { + t.Errorf("ModelName() with no status = %q, want empty", got) } } diff --git a/cli/launcher/update_darwin.go b/cli/launcher/update_darwin.go index 0722a59..7d994d7 100644 --- a/cli/launcher/update_darwin.go +++ b/cli/launcher/update_darwin.go @@ -301,16 +301,28 @@ func serverSurvivedRestart(baseURL string, timeout time.Duration) bool { // launchd wrapper all live under ~/.cix — so replacing the .app is invisible to // it. Updating the app no longer interrupts indexing. func (u *updater) updateLauncher(rel release.Release, progress func(string)) error { + staged, err := u.stageUpdate(rel, progress) + if err != nil { + return err + } + return u.launchSwap(staged) +} + +// stageUpdate downloads and validates the new .app, leaving it beside the +// installed one ready to be moved into place. Split from updateLauncher so +// everything that can fail is reachable from a test: the swap itself ends the +// process, and a test cannot wait for its own exit. +func (u *updater) stageUpdate(rel release.Release, progress func(string)) (string, error) { defer progress("") progress("Downloading the cix update…") dmgAsset, ok := rel.AssetBySuffix(".dmg") if !ok { - return fmt.Errorf("release %s has no disk image attached", rel.TagName) + return "", fmt.Errorf("release %s has no disk image attached", rel.TagName) } sumsAsset, ok := rel.AssetByName("checksums.txt") if !ok { - return fmt.Errorf("release %s has no checksums.txt attached", rel.TagName) + return "", fmt.Errorf("release %s has no checksums.txt attached", rel.TagName) } // Preflight before spending a download. Writing into the bundle's parent is @@ -318,43 +330,42 @@ func (u *updater) updateLauncher(rel release.Release, progress func(string)) err // and the message can still be "reinstall from the DMG". parent := filepath.Dir(u.bundle.Root) if err := checkWritable(parent); err != nil { - return fmt.Errorf("cix cannot update itself because %s is not writable by you.\n\n"+ + return "", fmt.Errorf("cix cannot update itself because %s is not writable by you.\n\n"+ "Download the new version and drag it over the old one instead.", parent) } cacheDir, err := updatesCacheDir() if err != nil { - return err + return "", err } if err := os.MkdirAll(cacheDir, 0o700); err != nil { - return err + return "", err } defer os.RemoveAll(cacheDir) dmgPath := filepath.Join(cacheDir, dmgAsset.Name) if err := download(dmgAsset.URL, dmgPath); err != nil { - return fmt.Errorf("could not download the update: %w", err) + return "", fmt.Errorf("could not download the update: %w", err) } sumsPath := filepath.Join(cacheDir, "checksums.txt") if err := download(sumsAsset.URL, sumsPath); err != nil { - return fmt.Errorf("could not download the checksums: %w", err) + return "", fmt.Errorf("could not download the checksums: %w", err) } if err := verifyChecksum(dmgPath, sumsPath, dmgAsset.Name); err != nil { // With no Developer ID signature this checksum is the entire integrity // story, so a mismatch is fatal and loud rather than a warning. logf("checksum verification failed for %s: %v", dmgAsset.Name, err) - return fmt.Errorf("the downloaded update failed its checksum check and was discarded.\n\n%v", err) + return "", fmt.Errorf("the downloaded update failed its checksum check and was discarded.\n\n%v", err) } staged := filepath.Join(parent, ".cix.app.new") os.RemoveAll(staged) if err := stageFromDMG(dmgPath, staged); err != nil { os.RemoveAll(staged) - return err + return "", err } - - return u.launchSwap(staged) + return staged, nil } // launchSwap writes the swap script to a temp file and starts it detached. 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? 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