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?