Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions .github/workflows/release-mac.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
50 changes: 50 additions & 0 deletions cli/launcher/dialog_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 11 additions & 6 deletions cli/launcher/firstrun_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions cli/launcher/resetpw_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 17 additions & 10 deletions doc/MACOS_APP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>-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?

Expand Down