From d9aa0c91ae5d8920ab301bd56d1864134796eae0 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Mon, 10 Aug 2026 18:24:46 +0100 Subject: [PATCH] feat(mac): copy generated passwords from the dialog, and say less to ask more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to what the first-run wizard and the password reset put on screen. **The password is now one click away from the clipboard.** Both dialogs show a credential that has to be typed into a browser, and a value like Ab3-xY_9kQ is exactly the thing people mistype. AppleScript cannot make a run of text clickable — `display dialog` draws one static string — so the click target is a button, and the dialog re-shows after copying rather than dismissing: a password displayed once is what someone reaches for twice, and a window that vanishes on the first attempt is how credentials end up being read off a screenshot. The value goes to pbcopy through a pipe rather than through AppleScript's `set the clipboard to`, which would put a secret inside a script string, one quoting mistake from being interpreted. **The wizard leads with the instruction.** It opened with why an account is needed and buried "enter the email address" in the second paragraph, which reads like a sign-up form — and left unanswered the question everybody actually asks: where is my address going. Nowhere. Saying so is worth more than the explanation it replaced. Also corrects the Gatekeeper instructions, in doc/MACOS_APP.md and in the release body. They promised one trip to System Settings. There are two: the downloaded disk image is refused when it is opened, and the app is refused again on first launch, because it inherits the quarantine flag from the image it was dragged out of. Confirmed with a genuinely quarantined build — `spctl -a -t open` rejects the image whether or not it carries the flag; the flag only decides whether that verdict is enforced. Co-Authored-By: Claude Opus 5 --- .github/workflows/release-mac.yml | 17 ++++++----- cli/launcher/dialog_darwin.go | 50 +++++++++++++++++++++++++++++++ cli/launcher/firstrun_darwin.go | 17 +++++++---- cli/launcher/resetpw_darwin.go | 5 ++-- doc/MACOS_APP.md | 27 ++++++++++------- 5 files changed, 91 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release-mac.yml b/.github/workflows/release-mac.yml index d9f1557..aad2270 100644 --- a/.github/workflows/release-mac.yml +++ b/.github/workflows/release-mac.yml @@ -141,18 +141,21 @@ jobs: That is the same build the Docker images are cut from, and it updates on its own schedule: a new server does not need a new app. - ### First launch will be blocked — this is expected + ### macOS will block it twice — this is expected cix is open source and is **not** signed with a paid Apple Developer - certificate, so macOS refuses the first launch with "cix cannot be - verified". + certificate, so it is not notarized. macOS refuses both the disk + image and the app inside it with "Apple could not verify…". Nothing + is wrong with the download — choose **Done**, never *Move to Bin*. - To allow it: **System Settings → Privacy & Security**, scroll to - **Security**, then click **Open Anyway** next to the message about cix. + Clear each one the same way: **System Settings → Privacy & Security**, + scroll to **Security**, then **Open Anyway** next to the message. + Once for the `.dmg` when you open it, and once for **cix.app** the + first time you launch it — the app inherits the quarantine flag from + the image, so clearing the first does not clear the second. On macOS 15 and later the old right-click → Open shortcut no longer - works — you have to use System Settings. You only need to do this - once per installed version. + works for either. You do this once per installed version. ### Verify the download diff --git a/cli/launcher/dialog_darwin.go b/cli/launcher/dialog_darwin.go index 0509d68..ea680c5 100644 --- a/cli/launcher/dialog_darwin.go +++ b/cli/launcher/dialog_darwin.go @@ -65,6 +65,56 @@ func alert(title, message string) error { return runOsascript(2*time.Minute, script) } +// alertWithCopy is alert() plus a button that puts secret on the clipboard. +// +// AppleScript cannot make a run of text clickable — `display dialog` draws one +// static string — so the click target has to be a button. That is the whole +// reason this is not simply "click the password". +// +// It re-shows the dialog after copying rather than dismissing. A password +// displayed once is exactly the thing someone reaches for a second time, and a +// window that disappears on the first attempt is how people end up reading +// credentials off a screenshot. The loop always terminates: every turn of it +// waits for a click. +func alertWithCopy(title, message, secret, copyLabel string) error { + body := message + for { + script := fmt.Sprintf( + `display dialog %s with title %s %s buttons {%s, "Done"} default button "Done"`, + quoteAS(body), quoteAS(title), iconClause(), quoteAS(copyLabel), + ) + out, err := outputOsascript(5*time.Minute, script) + if err != nil { + // Dismissing the window is a perfectly good way to say "I have it". + if errors.Is(err, errCancelled) { + return nil + } + return err + } + if !strings.Contains(out, copyLabel) { + return nil + } + + if err := copyToClipboard(secret); err != nil { + logf("could not copy to the clipboard: %v", err) + body = message + "\n\nCould not copy to the clipboard." + continue + } + body = message + "\n\nCopied to the clipboard." + } +} + +// copyToClipboard pipes a value to pbcopy. +// +// Not AppleScript's `set the clipboard to`: that would put the secret into a +// script string, where it is one quoting mistake away from being interpreted. +// A pipe carries bytes and nothing else. +func copyToClipboard(s string) error { + cmd := exec.Command("pbcopy") + cmd.Stdin = strings.NewReader(s) + return cmd.Run() +} + // errCancelled is returned when the user dismissed a dialog instead of // answering it. osascript reports this as exit status 1 — the same status as a // real failure — so it has to be told apart from the error text. diff --git a/cli/launcher/firstrun_darwin.go b/cli/launcher/firstrun_darwin.go index 1879567..11f2f88 100644 --- a/cli/launcher/firstrun_darwin.go +++ b/cli/launcher/firstrun_darwin.go @@ -43,10 +43,14 @@ func needsFirstRun() bool { // Returns errCancelled if the user backs out, which is not an error condition — // the app stays running with Start disabled until they complete setup. func runFirstRun(u *updater) error { - intro := "cix needs an administrator account before it can start.\n\n" + - "Enter the email address to sign in with. A password will be generated for you, " + - "and you will be asked to change it the first time you log in.\n\n" + - "Setup then downloads the cix server itself — around 40 MB — which takes a moment." + // Short, and leading with the thing to type. The first version of this + // opened with why an account is needed and buried the instruction in the + // second paragraph — which reads like a sign-up form, and the one question + // it left unanswered was the one everybody asks: where is my address going. + // Nowhere. Saying so is worth more than the explanation it replaced. + intro := "Enter an email address for the administrator account.\n\n" + + "It is the login for the cix dashboard on this Mac — nothing is sent anywhere. " + + "A password is generated for you, and setup then downloads the server (about 40 MB)." email, err := prompt("Set up cix", intro, "") if err != nil { @@ -138,10 +142,11 @@ func runFirstRun(u *updater) error { "model, which can take a few minutes; the menu bar will show it as running when it is ready." } - return alert("cix is set up", fmt.Sprintf( + return alertWithCopy("cix is set up", fmt.Sprintf( "Sign in at %s\n\nEmail:\n%s\n\nTemporary password:\n%s\n\n"+ "You will be asked to change this password on first login.%s%s", - dashboardURL(vars), email, password, waitNote, cliNote)) + dashboardURL(vars), email, password, waitNote, cliNote), + password, "Copy Password") } // registerWithCLI adds (or updates) the local server in ~/.cix/config.yaml. diff --git a/cli/launcher/resetpw_darwin.go b/cli/launcher/resetpw_darwin.go index df00412..f37ce3c 100644 --- a/cli/launcher/resetpw_darwin.go +++ b/cli/launcher/resetpw_darwin.go @@ -70,10 +70,11 @@ func (m *menu) resetPasswordFlow() { return } - _ = alert("Password reset", fmt.Sprintf( + _ = alertWithCopy("Password reset", fmt.Sprintf( "Account:\n%s\n\nTemporary password:\n%s\n\n"+ "You will be asked to change it at the next sign-in. Other sessions for this "+ - "account have been signed out.", email, password)) + "account have been signed out.", email, password), + password, "Copy Password") } // runResetPassword executes the reset and returns the generated password. diff --git a/doc/MACOS_APP.md b/doc/MACOS_APP.md index 7f92f36..2181d13 100644 --- a/doc/MACOS_APP.md +++ b/doc/MACOS_APP.md @@ -43,23 +43,30 @@ Verify the download first if you like: shasum -a 256 -c checksums.txt ``` -### The first launch is blocked, and that is expected +### macOS will block it twice, and that is expected -macOS will refuse to open the app the first time, reporting that it "cannot be -verified" or "is damaged". Neither is true. cix is open source and is signed -**ad-hoc** rather than with a paid Apple Developer certificate, so it has no -Gatekeeper trust. +cix is open source and is signed **ad-hoc** rather than with a paid Apple +Developer certificate, so it is not notarized and has no Gatekeeper trust. +Anything downloaded carries a quarantine flag, and macOS refuses both the disk +image and the app inside it, reporting that Apple "could not verify" them. It +is not damaged and there is nothing wrong with the download. -To allow it: +Each block is cleared the same way: 1. **System Settings → Privacy & Security** -2. Scroll down to **Security**. There is a message about cix being blocked. +2. Scroll down to **Security**. There is a message naming what was blocked. 3. Click **Open Anyway** and confirm. -This is once per installed version. +You will do this twice: once for `cix--arm64.dmg` when you open it, +and once for **cix.app** the first time you launch it. The app inherits the +quarantine flag from the image it was dragged out of, so clearing the first +does not clear the second. After that the app opens normally, until the next +version. -On macOS 15 and later, right-clicking the app and choosing **Open** no longer -works as a shortcut for this — the System Settings route is the only one. +Choose **Done**, never **Move to Bin**, when the dialog appears. + +On macOS 15 and later, right-clicking and choosing **Open** no longer works as +a shortcut for either — the System Settings route is the only one. ### Why not Homebrew?