diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8a9b780..c45665b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -291,6 +291,7 @@ jobs:
# so it is not done either. What reaches users is built, installed and walked through Phase 16
# of docs/manual-checks.md by a person first.
- name: package the windows desktop client
+ id: winpack
if: github.event_name != 'pull_request'
run: |
set -euo pipefail
@@ -374,6 +375,93 @@ jobs:
ls -la "$releases"
echo "Packaged DodoSSH $packVersion for win-x64, from a build MinVer calls $version."
+ # Handed to the macOS step below rather than worked out again there. The floor logic above
+ # is thirty lines of reasoning about MinVer's pre-first-tag answer, and a second copy of it
+ # is a second thing to keep in step — while two desktop packages built from one commit
+ # carrying different version numbers is precisely the confusion this file spends that
+ # reasoning to avoid.
+ echo "packVersion=$packVersion" >> "$GITHUB_OUTPUT"
+
+ # ◆ AND THE macOS BUNDLE IS BUILT HERE, ON LINUX, AND IS ALSO THROWN AWAY.
+ #
+ # Same argument as the Windows step above, one platform along: the failures a release is most
+ # exposed to are the ones only the packager finds, and the person who would otherwise find them
+ # is the one midway through a release on the one Mac that can cut one.
+ #
+ # What this catches that the Windows step cannot: the osx-arm64 restore graph. A native package
+ # that resolves for win-x64 and has no osx-arm64 asset — libsodium and SkiaSharp both ship per
+ # RID — fails here, on every main build, rather than at the first `dotnet publish` of a release
+ # nobody can retry without a Mac.
+ #
+ # ◆ bundle, NOT pack, AND THE DIFFERENCE IS NOT A CHOICE.
+ #
+ # `vpk [osx]` cross-compiling from a non-Mac offers exactly one packaging verb: bundle, which
+ # builds the .app. There is no `[osx] pack` off a Mac, and that is correct rather than a gap —
+ # pack signs with codesign, submits to Apple with notarytool and staples the ticket, all of
+ # which is Apple tooling that exists on no other platform. So this proves the bundle and stops
+ # where the platform does.
+ #
+ # No --plist and no --icon either, deliberately. Both are proved by scripts/release-macos.sh on
+ # the machine that can also check the result; passing a rendered plist here would mean copying
+ # the substitution out of that script to no end, since nothing looks at what this produces.
+ #
+ # ◆ NOTHING IS UPLOADED, FOR THE REASON THE WINDOWS STEP GIVES.
+ #
+ # RUNNER_TEMP, dying with the job. ADR 0013 rule 3 puts the capability to ship somebody a build
+ # on a machine which is not a runner, and an unsigned .app is additionally something no Mac
+ # would open — so publishing it would be handing out a file whose only possible use is confusion.
+ - name: publish and bundle the macos desktop client
+ if: github.event_name != 'pull_request'
+ run: |
+ set -euo pipefail
+
+ # RestoreLockedMode=false for the RID, exactly as the win-x64 publish above does — see the
+ # long note there for why the committed lock files are deliberately RID-free. This runner's
+ # checkout is thrown away, so the lock files it rewrites go nowhere.
+ dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
+ --configuration Release --runtime osx-arm64 --self-contained true \
+ -p:RestoreLockedMode=false \
+ --output "$RUNNER_TEMP/osx-arm64"
+
+ # The apphost has no extension on macOS, so this is `DodoSSH` and not `DodoSSH.exe`. Named
+ # rather than globbed, because a publish that produced no apphost at all would otherwise
+ # bundle happily and produce an .app that launches nothing.
+ if [ ! -s "$RUNNER_TEMP/osx-arm64/DodoSSH" ]; then
+ echo "The osx-arm64 publish produced no apphost." >&2
+ ls -la "$RUNNER_TEMP/osx-arm64" >&2 || true
+ exit 1
+ fi
+
+ bundles="$RUNNER_TEMP/osx-bundle"
+
+ # The quotes around [osx] are load-bearing, exactly as they are on '[win]' above: unquoted,
+ # the shell reads it as a glob matching any one of o, s and x.
+ dotnet vpk '[osx]' bundle \
+ --skip-updates \
+ --packId DodoSSH.Desktop \
+ --packVersion '${{ steps.winpack.outputs.packVersion }}' \
+ --packDir "$RUNNER_TEMP/osx-arm64" \
+ --packTitle DodoSSH \
+ --packAuthors DodoTech \
+ --mainExe DodoSSH \
+ --bundleId dev.dodotech.dodossh \
+ --runtime osx-arm64 \
+ --channel osx \
+ --outputDir "$bundles"
+
+ # Asked for rather than inferred from an exit code, for the reason the Windows step gives.
+ # The Info.plist is the specific thing worth naming: a bundle missing it is a directory
+ # macOS will not treat as an application at all, and it is the one part of the .app that
+ # vpk composes rather than copies.
+ app="$bundles/DodoSSH.Desktop.app"
+ if [ ! -s "$app/Contents/Info.plist" ]; then
+ echo "vpk reported success and there is no Info.plist at $app/Contents/Info.plist." >&2
+ find "$bundles" -maxdepth 3 >&2 || true
+ exit 1
+ fi
+
+ echo "Bundled DodoSSH ${{ steps.winpack.outputs.packVersion }} for osx-arm64."
+
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
# server through Testcontainers and runs the API as a child process — so it needs a
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
diff --git a/README.md b/README.md
index 5477b6d..21e8139 100644
--- a/README.md
+++ b/README.md
@@ -80,6 +80,8 @@ docs/platform-flags.md what differs off Windows, and the gotchas that have c
docs/manual-checks.md what no test can reach, and what to look for when checking by hand
docs/android-port.md the Android head: what was decided, what is built, what is left
scripts/ release-windows.ps1 — builds, packs and publishes the Windows client
+ release-macos.sh — the same, signed and notarized, on a Mac
+build/macos/ the entitlements and Info.plist template the macOS bundle is built from
```
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
@@ -152,8 +154,32 @@ reinstalling asks for your passphrase rather than starting over. Use **Sign out*
you want the machine to genuinely forget everything — an uninstall is not a sign-out, and does not withdraw
this machine's device key from your account.
-Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine. Deliberately not a
-CI job; ADR 0013 decision 3 explains why, and it is not only that the runners are Linux.
+## Installing on macOS
+
+A `.pkg` on the same release page, for Apple Silicon. Everything above about where a client may come from,
+about the update check and about uninstalling applies unchanged; what differs is worth three short
+paragraphs.
+
+**It is signed and notarized, so there is no warning to click past.** That is not generosity — macOS refuses
+to open an un-notarized download outright rather than warning about it, so unlike the Windows build there
+was never an unsigned option. If you *do* see "cannot be opened because Apple cannot check it for malicious
+software", the file did not come from the project's release page, and that is worth taking literally.
+
+**Apple Silicon only for now.** An Intel package is a small amount of work and no one here has an Intel Mac
+to check it on, and this project does not ship desktop builds nobody has run — see
+[docs/manual-checks.md](docs/manual-checks.md). Under Rosetta the arm64 build will not run; there is no
+graceful version of that, and the honest answer is that the platform is not covered yet.
+
+**Touch ID can stand in for your passphrase**, on a Mac with a Secure Enclave. The key that unwraps your
+device key is generated inside the enclave and never leaves it, and the enclave — not DodoSSH — is what
+requires your fingerprint or login password before it will use it. Cancel the prompt and you get the
+passphrase screen, always. The application lives at `/Applications/DodoSSH.Desktop.app` and your vault cache
+at `~/Library/Application Support/DodoSSH`, which are deliberately two different places so that removing the
+first never touches the second.
+
+Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine, and
+`scripts/release-macos.sh` on a Mac. Deliberately not a CI job; ADR 0013 decision 3 explains why, and it is
+not only that the runners are Linux.
### The nightly desktop build
@@ -886,8 +912,18 @@ keychain plus a terminal — and the spike that gates all of it.
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md), and
[Installing on Windows](#installing-on-windows) for what a user sees.
- Still to do here: signing (the first release is unsigned, and the trigger for buying a certificate is the
- first release aimed at strangers), and macOS and Linux packaging.
+ **The macOS half is built on the same machinery**, and signed from the start because Gatekeeper leaves no
+ choice: `scripts/release-macos.sh` publishes, signs every native library, notarizes with Apple and staples
+ the ticket before it will hand anything over, and refuses to upload until a person has installed it. The
+ device key is held in the Secure Enclave behind Touch ID. CI publishes `osx-arm64` and builds the `.app`
+ on every main build to prove it still packages, and uploads nothing. See
+ [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 10 and
+ [Installing on macOS](#installing-on-macos).
+
+ Still to do here: Windows signing (the first Windows release is unsigned, and the trigger for buying a
+ certificate is the first release aimed at strangers), macOS on Intel, Linux packaging, and Phase 18 of the
+ manual checks — the macOS build has never actually run, because there is no macOS runner in CI and
+ everything above is verified only as far as the bundle.
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
## Licence
diff --git a/build/macos/DodoSSH.entitlements b/build/macos/DodoSSH.entitlements
new file mode 100644
index 0000000..41f7fb0
--- /dev/null
+++ b/build/macos/DodoSSH.entitlements
@@ -0,0 +1,67 @@
+
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+
+
+ com.apple.security.cs.disable-library-validation
+
+
+
+ com.apple.security.cs.allow-dyld-environment-variables
+
+
+
diff --git a/build/macos/Info.plist.template b/build/macos/Info.plist.template
new file mode 100644
index 0000000..2430a73
--- /dev/null
+++ b/build/macos/Info.plist.template
@@ -0,0 +1,125 @@
+
+
+
+
+
+ CFBundleName
+ DodoSSH
+
+ CFBundleDisplayName
+ DodoSSH
+
+
+ CFBundleIdentifier
+ dev.dodotech.dodossh
+
+
+ CFBundleExecutable
+ DodoSSH
+
+
+ CFBundleShortVersionString
+ @VERSION@
+
+ CFBundleVersion
+ @VERSION@
+
+
+ CFBundleIconFile
+ dodossh.icns
+
+ CFBundlePackageType
+ APPL
+
+
+ LSMinimumSystemVersion
+ 12.0
+
+
+ NSHighResolutionCapable
+
+
+ NSPrincipalClass
+ NSApplication
+
+
+ LSUIElement
+
+
+ NSHumanReadableCopyright
+ © DodoTech. MIT licensed.
+
+
diff --git a/docs/adr/0007-device-key-protection.md b/docs/adr/0007-device-key-protection.md
index 11fb21d..207dba3 100644
--- a/docs/adr/0007-device-key-protection.md
+++ b/docs/adr/0007-device-key-protection.md
@@ -1,4 +1,4 @@
-# ADR 0007 — What protects the device key on Windows
+# ADR 0007 — What protects the device key on the desktop
**Status:** accepted, 2026-07-30
**Supersedes nothing. Constrains** the device-unlock work described in the client roadmap.
@@ -141,6 +141,14 @@ would have become false under DPAPI alone. A gesture is still something the atta
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
is therefore required, not a nicety.
+- **macOS reaches the same decision through different hardware, and the argument transfers intact.**
+ `MacDeviceKeyStore` puts the wrapping key in the Secure Enclave under an access control requiring user
+ presence, so Touch ID or the login password is a condition of *using* it and the enforcement is the
+ platform's rather than the process's — which is the entire point of the 2026-07-30 amendment above, and
+ the thing a self-drawn prompt over a protected file would fail to be. The mechanical differences are
+ incidental: P-256 with ECIES because the enclave holds no other kind of key, and no prompt when sealing
+ because the public half needs no consent. See docs/platform-flags.md for the three ordinary Macs where the
+ probe answers no, one of which is every unsigned development build.
- **The stored key must be treated as losable at any time** — a reset PIN, a cleared TPM, a replaced key.
Every loss degrades to a passphrase prompt and never to a locked-out vault, which is why every failure in
the store returns null rather than throwing and why the three unlock statuses all end in the same advice.
diff --git a/docs/adr/0013-desktop-distribution-and-updates.md b/docs/adr/0013-desktop-distribution-and-updates.md
index f87a922..52eb127 100644
--- a/docs/adr/0013-desktop-distribution-and-updates.md
+++ b/docs/adr/0013-desktop-distribution-and-updates.md
@@ -228,6 +228,44 @@ changes.
token, and it puts a compellable third party in the signing path — which is ADR 0011 rule 3's shape one
layer down, declined there for reasons that do not stop applying because the vendor changed.
+**This rule is Windows-only, and macOS gets the opposite one.** See decision 10: there is no "unsigned for
+now" available on that platform at any price, because Gatekeeper refuses rather than warns.
+
+### 10. macOS is a second desktop platform on the same machinery, signed from the start
+
+The macOS head is the same application, the same Velopack, and the same two-phase person-run release. Four
+things differ, and each is forced rather than chosen.
+
+**Signing is a precondition, not an improvement.** Decision 8's whole argument — one dialog per user per
+lifetime, buy a certificate when a stranger is invited to install — has no macOS equivalent. An
+un-notarized download is refused outright, so the Developer ID certificate and the notarization round trip
+are the price of the package existing. `scripts/release-macos.sh` therefore refuses to run without the
+signing identities, where the Windows script refuses nothing.
+
+**The channels are `osx` and `osx-nightly`, and they are separate for decision 9's reason.** Four channels
+now publish to one repository, and the only thing keeping a Mac from being offered a Windows package is
+that it never reads that index. The macOS nightly channel is named and has no publisher: CI builds and
+bundles the macOS head to prove it still builds, and uploads nothing, exactly as it does for the Windows
+release channel.
+
+**The pack id is shared with Windows, and on macOS it is visible.** vpk names the bundle after the pack id,
+so `/Applications` holds `DodoSSH.Desktop.app`. Decision 2's reasoning applies with more force here rather
+than less: a pack id of `DodoSSH` would put Velopack's install root on `~/Library/Application
+Support/DodoSSH`, which is `ClientPaths.DataDirectory`, and an uninstall would take the user's un-synced
+outbox with it. `CFBundleDisplayName` puts the product name back in front of a person; the directory keeps
+the id.
+
+**arm64 only, because the check is the scarce thing.** Velopack keys a channel to one architecture, and an
+Intel package would be the only artefact in this repository reaching users without somebody having walked
+Phase 18 against it. The engineering for a second channel is small and is described in the release script;
+what is missing is an Intel Mac to verify on, and shipping blind is the thing this project's manual-check
+discipline exists to refuse.
+
+**And one thing that does not differ, which is worth saying because it is the expensive half.** The
+capability to publish still lives on a person's machine and never in CI. Notarization does not change that:
+Apple's ticket says this build came from this developer account, and says nothing about whether the build
+should have been made. Velopack clients still apply what their feed serves. Rule 3 is untouched.
+
### 9. There is a second desktop channel, published by CI, and it is a second application
[ADR 0014](0014-android-updates.md) gave the phone a nightly channel and rule 3 above gives the desktop
diff --git a/docs/manual-checks.md b/docs/manual-checks.md
index 1d07b81..911d665 100644
--- a/docs/manual-checks.md
+++ b/docs/manual-checks.md
@@ -2593,3 +2593,120 @@ package manager will not offer to.
**Failure means:** the channels are not separate, and a public key is signing the application people keep
their credentials in.
+
+## Phase 18 — Installing the macOS client, and being updated by it
+
+The macOS counterpart of phase 16, and it needs a Mac with a Secure Enclave — an Apple Silicon machine or
+an Intel one with a T2. Every check here is structurally unreachable by a test for the reasons phase 16
+gives, plus one this platform adds: **CI has no macOS runner at all**, so this phase is the only place the
+suite and the application ever run on macOS. Anything `docs/platform-flags.md` marks as unverified on macOS
+is verified here or nowhere.
+
+Run `bash scripts/release-macos.sh` first. It stops after packing and notarizing, on purpose, so that
+everything below happens before anything reaches a user. Phase 16.0 — the feed being readable without
+credentials — applies unchanged and is not repeated.
+
+### 18.1 Gatekeeper accepts it on a machine that did not build it · **do this one first**
+
+The Mac that signed a package trusts it locally whatever happened, so the build machine cannot answer this
+question about itself. Copy the `.pkg` to a second Mac — or at minimum download it through a browser, which
+is what applies the quarantine attribute — and open it.
+
+**Pass:** it installs with no warning beyond the ordinary installer prompts.
+
+**Failure means:** "cannot be opened because Apple cannot check it for malicious software" is notarization
+that did not happen or a ticket that did not staple. The script's `spctl --assess` and `xcrun stapler
+validate` should have caught it before this point, so reaching here means one of those two checks was
+removed or skipped. Do not distribute the package.
+
+### 18.2 The Dock shows the product and not the pack id
+
+Look at the installed application in `/Applications`, in the Dock, and in the menu bar while it runs.
+
+**Pass:** the menu bar says **DodoSSH**. Finder shows **DodoSSH**. The bundle on disk is
+`DodoSSH.Desktop.app` and that is expected — see the pack id note in `scripts/release-macos.sh`.
+
+**Failure means:** "DodoSSH.Desktop" in the menu bar is `CFBundleName` not reaching the bundle, which means
+the rendered `Info.plist` did not get used. Since vpk copies a custom plist verbatim and substitutes
+nothing, check the same bundle's `CFBundleShortVersionString` — if it reads `@VERSION@`, the template was
+passed through unrendered.
+
+### 18.3 The icon is the mark, at every size
+
+Look at it in the Dock, in Finder's icon view at a large size, and in `⌘I` Get Info.
+
+**Pass:** the accent tile and the `>_` mark, crisp at 1024, with the same air around it that Finder and
+Safari have.
+
+**Failure means:** a generic application icon is `CFBundleIconFile` naming a file that is not in
+`Contents/Resources`. An icon that fills its square edge to edge, larger than its neighbours, is
+`New-MarkPng` having been called with the Windows tile fraction — see `dodossh-icon.ps1`.
+
+### 18.4 Touch ID guards the device key, and the enclave enforces it
+
+Register a device key from the security settings page, then lock the vault and unlock it again.
+
+**Pass:** registering shows **no** prompt at all — sealing uses only the public half — and unlocking raises
+the system Touch ID sheet saying DodoSSH is trying to *unlock your DodoSSH vault*. The vault opens on a
+successful touch.
+
+**Failure means:** a prompt at registration is not a failure of correctness but says the key was not created
+in the enclave; check that `kSecAttrTokenID` reached the attributes. **No prompt at unlock, with the vault
+opening anyway, is the serious one** — it means the key is a software key and the access control did nothing,
+which is precisely the "a gate inside the process is not a gate" mistake `WindowsDeviceKeyStore` documents.
+
+### 18.5 Declining the fingerprint falls back to the passphrase
+
+Repeat 18.4 and cancel the Touch ID sheet.
+
+**Pass:** the unlock screen asks for the passphrase, and it works.
+
+**Failure means:** an error dialog, or a stuck screen, is `TryLoadAsync` throwing rather than answering
+null. Every failure it can meet — cancelled, timed out, key invalidated by a password reset — is meant to
+be indistinguishable and to land on the passphrase.
+
+### 18.6 A development build offers no device key at all
+
+Run the application with `dotnet run` rather than from the installed bundle, and open the security settings
+page.
+
+**Pass:** registering a device key is not offered.
+
+**Failure means:** being offered it is `IsSupported` having inferred availability from the OS rather than
+probing. An unsigned build cannot create an enclave key, so accepting the offer would put a wrap on the
+server that nothing can ever open and list a capability this machine does not have.
+
+### 18.7 The terminal works, which is the WKWebView question
+
+Connect to a host and use the shell: type, run something that scrolls, resize the window.
+
+**Pass:** the terminal attaches within a second or two and behaves as it does on Windows.
+
+**Failure means:** a blank pane that reports a renderer timeout after fifteen seconds is the loopback
+WebSocket not reaching WKWebView. This is the check that most needs walking, because the data plane has
+never run against this backend — see `TerminalDataPlane`. If it fails, the App Sandbox is the first thing to
+rule out: the entitlements deliberately do not enable it, and a sandboxed process cannot listen on loopback
+without `com.apple.security.network.server`.
+
+### 18.8 An update is offered, downloaded and applied
+
+With the release installed, cut a second release with a higher version and publish it, then leave the first
+running.
+
+**Pass:** the banner appears, downloads, and on applying the application closes and reopens on the new
+version. The vault's contents and the known hosts survive.
+
+**Failure means:** an update that never arrives is usually the channel — `osx` here and `osx` in
+`VelopackUpdateChannel.MacReleaseChannel`, with no error anywhere when they disagree. An update that
+downloads and fails to apply, leaving the application unable to restart, is library validation: check that
+`com.apple.security.cs.disable-library-validation` survived into the entitlements.
+
+### 18.9 Uninstalling does not take the vault with it
+
+Register a device, sync something, then remove the application.
+
+**Pass:** `~/Library/Application Support/DodoSSH` still holds the cache and the outbox afterwards.
+
+**Failure means:** an empty directory is the pack id having been changed to `DodoSSH`, which puts Velopack's
+install root on top of `ClientPaths.DataDirectory` and makes an uninstall delete a user's un-synced work.
+This is the single reason the bundle is named `DodoSSH.Desktop.app`.
diff --git a/docs/platform-flags.md b/docs/platform-flags.md
index 5cbde70..8baace3 100644
--- a/docs/platform-flags.md
+++ b/docs/platform-flags.md
@@ -3,8 +3,18 @@
Things known or suspected to behave differently outside Windows, plus deployment gotchas that
have already cost time once. Development is Windows-first, but **the full test suite now runs on
Linux in CI on every change**, so a Linux claim here is usually a measurement now rather than a
-suspicion. **macOS is still untested**, and anything marked *unverified* has not run on the platform
-in question and must not be assumed to work.
+suspicion. Anything marked *unverified* has not run on the platform in question and must not be
+assumed to work.
+
+**macOS now builds and packages, and has still never run.** The distinction matters more here than
+anywhere else on this page, because the two halves are verified in completely different places. The
+build is measured on every main and tag build: CI publishes `osx-arm64` and runs `vpk [osx] bundle`
+on a Linux runner, which is enough to catch a restore graph with no macOS native asset and an `.app`
+that will not compose. Everything past that — whether the window draws, whether the terminal's
+loopback WebSocket reaches WKWebView, whether the Secure Enclave holds a device key — is verified
+only by a person walking Phase 18 of [manual-checks.md](manual-checks.md) on a Mac, because **there
+is no macOS runner in CI**. Treat every macOS runtime claim below as unverified unless it says
+otherwise.
Each entry says what the risk is, why it matters, and what to do about it. Delete an entry when it
has been verified or made moot — not when it merely stops being convenient.
@@ -17,6 +27,19 @@ docs/crypto.md §1. *Already mitigated* — but if a BCL AEAD path is ever added
**must** gate on `IsSupported` rather than assuming availability, or the client will fail to open
any vault on macOS.
+**The Secure Enclave holds P-256 keys and nothing else**, which is why `MacDeviceKeyStore` wraps the
+device key with ECIES rather than with the RSA-OAEP the Windows store uses. It will not hold an RSA
+key at any size, so this is not a preference. The useful consequence is that the macOS shape is
+*better* than the Windows one: `SecKeyCopyPublicKey` works on an enclave key without prompting, so
+registering a device is silent and only unlock asks — where Windows raises a dialog at key creation
+too. *Unverified:* no enclave call in this repository has ever run.
+
+**Three ordinary Macs have no usable enclave**, and `IsSupported` probes rather than infers for that
+reason: an Intel machine without a T2, a machine with no login password set, and — the one that
+surprises people — **any build that is not code signed**, because enclave key creation needs a
+signing identity. So `dotnet run` correctly offers no device key at all. Do not "fix" this by
+checking the OS instead; the offer would then put a wrap on the server that nothing can ever open.
+
**Argon2id timings are measured on one Windows machine only.** 256 MiB with t=4 took 323 ms here.
The floor and ceiling in `EnrollmentLimits` were chosen against that number. *Unverified
elsewhere:* recalibrate on the slowest target platform before recommending a default profile,
@@ -26,7 +49,11 @@ and the parameters are stored per user at enrollment, so a bad default is a per-
**libsodium ships native binaries per RID.** This complicates single-file and AOT publishing, and
on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`)
must be signed **individually** with `--options runtime --timestamp` before the bundle is signed,
-or notarization fails with an error that does not name the offending file.
+or notarization fails with an error that does not name the offending file. *Mitigated* in
+`scripts/release-macos.sh`, which signs every `.dylib` and `createdump` in a loop before vpk touches
+anything — vpk's own pass uses `codesign --deep`, which is the shape Apple documents as wrong for
+nested code and is the likeliest source of that unnamed rejection. The loop looks redundant next to
+`--deep` and is not; do not delete it because a release once succeeded without it.
## Desktop client
@@ -366,6 +393,37 @@ AppContainer where loopback connections are blocked without a `CheckNetIsolation
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package manager.
+**The App Sandbox is ruled out on macOS for the same reason, and the entitlements say so.** A
+sandboxed process cannot listen on loopback without `com.apple.security.network.server`, and the
+terminal is that listener. Developer ID distribution outside the App Store does not require the
+sandbox, so this costs nothing today — but it does mean the Mac App Store is closed to this
+application without solving the data plane differently first. See
+`build/macos/DodoSSH.entitlements`.
+
+**The hardened runtime is not optional and .NET needs four holes punched in it.** Notarization
+refuses a Developer ID submission without it, and CoreCLR will not start under it without
+`allow-jit` and `allow-unsigned-executable-memory` — both, not either, because the runtime allocates
+executable memory outside the `MAP_JIT` path as well. `disable-library-validation` and
+`allow-dyld-environment-variables` are needed for Velopack's updater rather than for the runtime.
+Each is argued individually in the entitlements file; the failure mode for a missing one is a
+process that dies during runtime initialisation, before anything exists that could report it.
+
+**`vpk` cross-compiles to macOS only as far as the bundle.** `vpk [osx] bundle` runs anywhere and
+produces a real `.app`; there is no `[osx] pack` off a Mac, because pack drives `codesign`,
+`notarytool` and `stapler`. So CI can prove the bundle builds and only a Mac can produce something
+installable. Note this is the *opposite* of the Windows story, where `vpk [win] pack` builds the
+whole installer on Linux — the asymmetry is Apple tooling, not a Velopack limitation.
+
+**A custom `Info.plist` is copied verbatim by vpk, with no substitution whatsoever.** That is why
+`--plist` and `--bundleId` are mutually exclusive, and why `build/macos/Info.plist.template` is a
+template the release script renders rather than a committed file. A committed plist would carry one
+version into every release afterwards, and the symptom is silent: Velopack's index would still be
+right, the updater would still work, and only Get Info and any crash report would disagree.
+
+**macOS app icons live on an 824-in-1024 grid.** An icon that bleeds to the edge of its canvas is
+not bolder, it is the one icon in the Dock that is too big. `dodossh-icon.ps1` draws the `.icns` at
+that fraction and the `.ico` at full bleed, from one geometry.
+
*Checked rather than assumed, now that Velopack is actually wired up:* its Windows path does not
reintroduce the thing MSIX was ruled out for. `Setup.exe` is an ordinary Win32 executable that unpacks a
directory under `%LOCALAPPDATA%` and creates shortcuts — there is no `AppxManifest`, no package identity,
diff --git a/scripts/release-macos.sh b/scripts/release-macos.sh
new file mode 100644
index 0000000..b7964c3
--- /dev/null
+++ b/scripts/release-macos.sh
@@ -0,0 +1,404 @@
+#!/usr/bin/env bash
+#
+# Builds, packages and publishes the macOS desktop client.
+#
+# The counterpart of scripts/release-windows.ps1, and deliberately the same shape: run by a person, on a
+# Mac that is not a CI runner, in two phases with the upload withheld until somebody has installed what
+# phase one built and walked the manual checks. docs/adr/0011-android-distribution.md rule 1 puts the
+# capability to ship somebody a build on a machine which is not a runner, and
+# docs/adr/0013-desktop-distribution-and-updates.md explains why the token that writes a Gitea release is
+# that capability: Velopack clients trust their feed and do not verify a package signature when they apply
+# it, so whoever can write a release can ship an update every install runs.
+#
+# 1. Without --upload: builds, signs, notarizes, packs, and stops. Nothing has left this machine
+# except the notarization submission, which Apple sees and users do not.
+# 2. With --upload: asks for the forge token and publishes what phase one produced. It does not
+# rebuild, so the bytes that reach users are the bytes that were installed and checked.
+#
+# ◆ WHAT IS DIFFERENT FROM THE WINDOWS SCRIPT, AND WHY.
+#
+# Signing is not optional here. On Windows an unsigned installer costs a SmartScreen dialog once per
+# user, which is why that script has no --signParams and says so. On macOS an un-notarized download is
+# refused outright by Gatekeeper — not warned about, refused — so the Developer ID certificate and the
+# notarization round trip are the price of the package being installable at all, not an improvement to
+# be bought later.
+#
+# ◆ CREDENTIALS COME FROM THE KEYCHAIN AND THE ENVIRONMENT, NOT FROM THIS FILE.
+#
+# Three values are read from the environment, and none of them is itself a secret — they name things the
+# keychain holds, and the keychain is what guards the private key and the App Store Connect credentials:
+#
+# DODOSSH_SIGN_APP_IDENTITY e.g. "Developer ID Application: DodoTech (TEAMID)"
+# DODOSSH_SIGN_INSTALL_IDENTITY e.g. "Developer ID Installer: DodoTech (TEAMID)"
+# DODOSSH_NOTARY_PROFILE the profile name given to `xcrun notarytool store-credentials`
+#
+# `security find-identity -v -p codesigning` lists the first two exactly as codesign wants them. The
+# third is created once per machine:
+#
+# xcrun notarytool store-credentials DodoSSH \
+# --apple-id you@example.com --team-id TEAMID --password
+#
+# The forge token is the one real secret, and it is prompted for rather than read from a file or the
+# environment, and only in the phase that needs it — for the reason the Windows script gives: the fewer
+# minutes a credential that can publish an update spends in a shell's memory the better.
+#
+# Usage:
+# bash scripts/release-macos.sh
+# bash scripts/release-macos.sh --upload
+# bash scripts/release-macos.sh --skip-tests
+
+set -euo pipefail
+
+UPLOAD=0
+SKIP_TESTS=0
+
+for arg in "$@"; do
+ case "$arg" in
+ --upload) UPLOAD=1 ;;
+ --skip-tests) SKIP_TESTS=1 ;;
+ *)
+ echo "Unknown argument: $arg" >&2
+ echo "Usage: bash scripts/release-macos.sh [--upload] [--skip-tests]" >&2
+ exit 1
+ ;;
+ esac
+done
+
+# ---- The contract with every installed client ---------------------------------------------------------
+
+# Velopack's identity for this application, and it is effectively irreversible for the reasons the Windows
+# script states — it is what an installed client matches an update against.
+#
+# ◆ THE SAME PACK ID AS WINDOWS, AND ON THIS PLATFORM IT IS VISIBLE.
+#
+# vpk names the bundle after the pack id, so this produces DodoSSH.Desktop.app rather than DodoSSH.app,
+# and that is what somebody sees in /Applications. It is kept anyway, because the alternative is worse:
+# a pack id of DodoSSH would put Velopack's install and its uninstall on ~/Library/Application Support/
+# DodoSSH, which is exactly where ClientPaths keeps the encrypted cache, the outbox of changes not yet
+# pushed and the device key. Sharing that directory would mean an uninstall silently taking a user's
+# un-synced work with it. The same reasoning, and the same conclusion, as the Windows script.
+#
+# What a person actually reads is CFBundleDisplayName, which build/macos/Info.plist.template sets to
+# DodoSSH. So the bundle keeps the id and the Dock shows the product.
+PACK_ID='DodoSSH.Desktop'
+PACK_TITLE='DodoSSH'
+PACK_AUTHORS='DodoTech'
+
+# The project's own forge. Never a DodoSSH deployment — ADR 0011 rule 2. The same URL is a constant in
+# VelopackUpdateChannel, and the two have to agree or the client polls somewhere nothing is published.
+# The owner is part of it: Gitea left a 301 at the old organisation's path, which a GET follows and an
+# upload does not.
+REPO_URL='https://git.dodotech.cloud/DodoTech-Public/DodoSSH'
+
+# A contract with VelopackUpdateChannel.MacReleaseChannel. Velopack's macOS default is also "osx", so
+# leaving it unsaid on both sides would work — but unsaid here and stated there is how a feed goes quiet
+# with no error at all: the client checks, finds nothing, and reports itself up to date forever.
+CHANNEL='osx'
+
+# ◆ ARM64 ONLY, AND THAT IS A DECISION RATHER THAN AN OVERSIGHT.
+#
+# Velopack keys a channel to one architecture, so shipping Intel too means a second channel, a second
+# publish, a second set of deltas and a second thing to keep in step with the client's channel picker.
+# That is all affordable. What is not currently affordable is testing it: nobody here has an Intel Mac,
+# and docs/manual-checks.md exists because this project does not ship desktop builds no one has run.
+# An x64 package built blind and published beside a checked arm64 one would be the only artefact in this
+# repository that reached users unverified.
+#
+# Adding it later is this constant, a second channel name in VelopackUpdateChannel, and a picker keyed on
+# RuntimeInformation.ProcessArchitecture — which reports X64 for a build running under Rosetta, so an
+# Intel build correctly stays on the Intel feed. The work is small; the check is the part that is missing.
+RUNTIME='osx-arm64'
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PROJECT="$REPO_ROOT/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj"
+SOLUTION="$REPO_ROOT/DodoSSH.slnx"
+PUBLISH_DIR="$REPO_ROOT/publish/$RUNTIME"
+RELEASES_DIR="$REPO_ROOT/Releases"
+ICON="$REPO_ROOT/src/DodoSSH.Client.App/Assets/dodossh.icns"
+ENTITLEMENTS="$REPO_ROOT/build/macos/DodoSSH.entitlements"
+PLIST_TEMPLATE="$REPO_ROOT/build/macos/Info.plist.template"
+
+write_step() { printf '\n\033[36m==> %s\033[0m\n' "$1"; }
+stop_with() { printf '\n\033[31m%s\033[0m\n' "$1" >&2; exit 1; }
+
+# ---- Is this machine able to do the job at all? -------------------------------------------------------
+
+if [ "$(uname -s)" != 'Darwin' ]; then
+ # codesign, notarytool and stapler are Apple tooling and exist nowhere else. The build and even the
+ # .app bundle cross-compile fine from Windows or Linux — `vpk [osx] bundle` does exactly that, and
+ # ci.yml uses it to prove the bundle still builds — but a signed, notarized, installable package
+ # cannot be produced anywhere but here.
+ stop_with 'This builds a signed macOS package and has to run on macOS.'
+fi
+
+for tool in dotnet git xcrun codesign; do
+ command -v "$tool" >/dev/null 2>&1 || stop_with "$tool is not on PATH."
+done
+
+# Checked before anything is built rather than at the step that uses them. Notarization is the last thing
+# this script does and the slowest, and discovering there that a profile name was never exported means
+# throwing away a full build and test run.
+for required in DODOSSH_SIGN_APP_IDENTITY DODOSSH_SIGN_INSTALL_IDENTITY DODOSSH_NOTARY_PROFILE; do
+ if [ -z "${!required-}" ]; then
+ stop_with "$required is not set. See the header of this script for what the three are and how to make them."
+ fi
+done
+
+cd "$REPO_ROOT"
+
+# ---- What is being released ---------------------------------------------------------------------------
+
+# Restored before the version is read, and both halves are load-bearing — the same two traps the Windows
+# script documents. -t:MinVer, because -getProperty alone evaluates the project and runs no targets, while
+# MinVer sets Version from inside one, so the read would answer the SDK's default 1.0.0 regardless of the
+# tag. And a restore first, because naming a target that arrives with a package fails MSB4057 on a clean
+# clone where obj/ has no MinVer targets to import yet.
+write_step 'Restoring the desktop head, so the version can be read'
+dotnet restore "$PROJECT" --locked-mode || stop_with 'Restore failed.'
+
+VERSION="$(dotnet msbuild "$PROJECT" -getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"
+[ -n "$VERSION" ] || stop_with 'Could not read the version from MSBuild.'
+
+TAG="v$VERSION"
+
+# Apple's two version keys take one to three dot-separated integers and nothing else, so a prerelease
+# version has to have its suffix removed before it reaches the plist. 1.2.3-rc.1 becomes 1.2.3.
+#
+# The full version, suffix and all, is what vpk packs and what the release index carries, so the updater
+# still tells an rc from the release it precedes. These two keys are for Finder and Gatekeeper, which
+# care that the string parses and not what it says. See build/macos/Info.plist.template.
+PLIST_VERSION="${VERSION%%-*}"
+PLIST_VERSION="${PLIST_VERSION%%+*}"
+
+write_step "DodoSSH $VERSION ($PACK_ID, channel $CHANNEL, $RUNTIME)"
+
+# ---- Phase 2: publish what phase 1 built --------------------------------------------------------------
+
+if [ "$UPLOAD" -eq 1 ]; then
+ # The installer package is the artefact a person downloads, so its absence is the honest test of
+ # whether phase one ever ran. A directory holding only a .nupkg is a pack that failed part way.
+ if ! ls "$RELEASES_DIR"/*.pkg >/dev/null 2>&1; then
+ stop_with "Nothing to upload: $RELEASES_DIR has no .pkg. Run this without --upload first."
+ fi
+
+ echo "About to publish the contents of $RELEASES_DIR to $REPO_URL as $TAG."
+ echo 'Only do this once you have installed it and walked Phase 18 of docs/manual-checks.md.'
+
+ # -s so the token is never echoed and never lands in the shell's history.
+ printf 'Gitea token (write:repository): '
+ read -r -s TOKEN
+ echo
+
+ [ -n "$TOKEN" ] || stop_with 'No token given.'
+
+ # --merge because Gitea already has a release entry for the pushed tag — and on this platform it may
+ # also already hold the Windows package for the same tag, which is the case --merge is really doing
+ # the work for: without it the second platform to publish a given version fails on a release that
+ # exists, and with it the two sit side by side under one tag. --channel keeps the indexes apart.
+ UPLOAD_ARGS=(
+ upload gitea
+ --repoUrl "$REPO_URL"
+ --token "$TOKEN"
+ --outputDir "$RELEASES_DIR"
+ --channel "$CHANNEL"
+ --releaseName "$TAG"
+ --tag "$TAG"
+ --merge
+ --publish
+ )
+
+ # Mirrors the rule the docker image job and the Windows script already apply to the same tag, so a
+ # release candidate is a prerelease in every channel or in none.
+ case "$VERSION" in
+ *-*) UPLOAD_ARGS+=(--pre) ;;
+ esac
+
+ write_step 'Uploading'
+ dotnet vpk "${UPLOAD_ARGS[@]}" || stop_with 'vpk upload failed.'
+
+ write_step "Published $TAG."
+ exit 0
+fi
+
+# ---- Phase 1: build, sign, notarize, pack -------------------------------------------------------------
+
+[ -z "$(git status --porcelain)" ] || stop_with 'The working tree is not clean. A release is cut from a commit, not from a desk.'
+
+HEAD_TAG="$(git describe --exact-match --tags HEAD 2>/dev/null || true)"
+[ -n "$HEAD_TAG" ] || stop_with "HEAD is not tagged. Tag it $TAG first, or change the version and tag that."
+
+# Cannot happen while MinVer is deriving the version from this very tag, and checked anyway: the day
+# somebody pins a version by hand this is the guard that notices.
+[ "$HEAD_TAG" = "$TAG" ] || stop_with "HEAD is tagged $HEAD_TAG but the computed version is $VERSION."
+
+write_step 'Restoring tools'
+dotnet tool restore || stop_with 'dotnet tool restore failed.'
+
+write_step 'Restoring packages (locked, exactly as CI does)'
+dotnet restore "$SOLUTION" --locked-mode || stop_with 'Restore failed. A lock file that only works on Linux fails here.'
+
+write_step 'Building'
+dotnet build "$SOLUTION" --no-restore --configuration Release || stop_with 'Build failed.'
+
+if [ "$SKIP_TESTS" -eq 0 ]; then
+ # The end-to-end suite starts containers and takes minutes. It is run here anyway rather than taken
+ # on trust from CI, because a tag is the one build nobody is watching — and on this platform there is
+ # a second reason: CI has no macOS runner, so this is the only place the suite ever runs on a Mac at
+ # all. Everything docs/platform-flags.md lists as unverified on macOS is verified here or nowhere.
+ write_step 'Testing'
+ dotnet test "$SOLUTION" --no-build --configuration Release || stop_with 'Tests failed.'
+fi
+
+write_step "Publishing $RUNTIME"
+rm -rf "$PUBLISH_DIR"
+
+# Self-contained, and not single-file, for the reasons the Windows script gives: the native libraries ship
+# per RID and a self-extracting bundle breaks delta updates.
+#
+# RestoreLockedMode=false, and the lock files put back straight afterwards. A RID-specific publish resolves
+# a graph the committed lock files do not describe, because they are deliberately kept RID-free —
+# declaring a RID on the head writes a net10.0/ target into every project it references transitively,
+# including DodoSSH.Contracts and DodoSSH.Crypto, and the API's Dockerfile then restores those with no RID
+# under locked mode and fails NU1004. Packaging the desktop client would have broken the server's image
+# build. The gate that matters is the locked solution restore above, which is untouched.
+dotnet publish "$PROJECT" \
+ --configuration Release \
+ --runtime "$RUNTIME" \
+ --self-contained true \
+ --output "$PUBLISH_DIR" \
+ -p:RestoreLockedMode=false \
+ || stop_with 'Publish failed.'
+
+# An unlocked restore rewrites the lock files it walked. Left there, the next commit would carry exactly
+# the change that breaks the image build. Safe to do bluntly because this script refuses to run on a dirty
+# tree, so anything modified here is its own.
+git checkout -- '*packages.lock.json' || stop_with 'Could not restore the lock files after publishing.'
+
+# Checked rather than assumed. A publish directory without Velopack.dll would pack into an installer for an
+# application that never checks for updates — which looks completely normal until the next release goes out
+# and nobody receives it.
+for required in DodoSSH Velopack.dll; do
+ [ -e "$PUBLISH_DIR/$required" ] || stop_with "$required is missing from $PUBLISH_DIR."
+done
+
+echo " $(du -sh "$PUBLISH_DIR" | cut -f1) in $(find "$PUBLISH_DIR" -type f | wc -l | tr -d ' ') files"
+
+# ---- Signing the native libraries, before vpk signs anything ------------------------------------------
+
+# ◆ THIS LOOP IS WHY NOTARIZATION SUCCEEDS, AND IT LOOKS REDUNDANT.
+#
+# vpk signs the finished bundle itself, with `codesign -f -v --timestamp --options runtime --entitlements
+# --deep`, and --deep is documented by Apple as the wrong way to sign nested code. Apple's guidance
+# is inside-out: sign each nested binary first, then the bundle around it. --deep does the reverse in one
+# pass and applies the outer entitlements to everything it touches.
+#
+# In practice --deep alone is where the failure recorded in docs/platform-flags.md comes from — a
+# notarization rejection that does not name the offending file, on a submission that took its time getting
+# there. Signing each dylib properly first means vpk's pass has nothing left to get wrong, and re-signing
+# an already correctly signed binary with -f is a no-op in effect.
+#
+# No --entitlements here, and that is the difference that matters. Entitlements belong on the main
+# executable; a dylib carrying allow-jit is at best meaningless and at worst a rejection.
+write_step 'Signing native libraries'
+
+# createdump is a Mach-O executable the runtime ships and it is signed like the libraries: a nested
+# executable that is not signed fails notarization exactly as an unsigned dylib does, and it is the one
+# people forget because it has no extension to grep for.
+NATIVE_COUNT=0
+while IFS= read -r -d '' binary; do
+ codesign --force --verbose=0 --timestamp --options runtime \
+ --sign "$DODOSSH_SIGN_APP_IDENTITY" "$binary" \
+ || stop_with "codesign failed on $binary"
+ NATIVE_COUNT=$((NATIVE_COUNT + 1))
+done < <(find "$PUBLISH_DIR" \( -name '*.dylib' -o -name 'createdump' \) -type f -print0)
+
+[ "$NATIVE_COUNT" -gt 0 ] || stop_with "No native binaries found under $PUBLISH_DIR, which cannot be right for a self-contained publish."
+echo " signed $NATIVE_COUNT native binaries"
+
+# ---- The bundle's Info.plist --------------------------------------------------------------------------
+
+# Rendered rather than committed, because vpk copies a custom plist verbatim and substitutes nothing —
+# so a committed one would carry whatever version it was written with into every release afterwards.
+# See the header of build/macos/Info.plist.template.
+write_step "Rendering Info.plist for $PLIST_VERSION"
+RENDERED_PLIST="$(mktemp -t dodossh-plist)"
+trap 'rm -f "$RENDERED_PLIST"' EXIT
+
+sed "s/@VERSION@/$PLIST_VERSION/g" "$PLIST_TEMPLATE" > "$RENDERED_PLIST"
+
+# The placeholder is the whole mechanism, so its absence is checked rather than hoped for. A template
+# somebody edited into a literal version would otherwise sail through and pin every future release to it.
+grep -q '@VERSION@' "$PLIST_TEMPLATE" || stop_with "$PLIST_TEMPLATE has no @VERSION@ placeholder left in it."
+! grep -q '@VERSION@' "$RENDERED_PLIST" || stop_with 'Substitution into the rendered Info.plist did not take.'
+
+mkdir -p "$RELEASES_DIR"
+
+# The previous release, so a delta can be built against it. Tolerated when it finds nothing: the first
+# macOS release has no predecessor, and a hard failure here would make cutting it impossible.
+write_step 'Fetching the previous release, for deltas'
+if ! dotnet vpk download gitea --repoUrl "$REPO_URL" --outputDir "$RELEASES_DIR" --channel "$CHANNEL"; then
+ echo ' Nothing came down. This package will be full-only, which is right for a first release.'
+fi
+
+# ---- Pack, sign, notarize, staple ---------------------------------------------------------------------
+
+# One command does the rest, and it is worth knowing what it is doing on your behalf, because the slow
+# part is not local: it builds the .app from the published files, signs it with the Developer ID
+# certificate and the entitlements below, submits it to Apple with `xcrun notarytool submit --wait`,
+# staples the resulting ticket to the package, and then builds the .pkg installer and the release index.
+#
+# The notarization wait is the reason this step can take a quarter of an hour and occasionally much
+# longer — it is a queue at Apple, not a computation here, and vpk's own message says so.
+#
+# --signInstallIdentity is a different certificate from --signAppIdentity, and the pair is not
+# interchangeable: "Developer ID Application" signs the bundle, "Developer ID Installer" signs the .pkg.
+# Passing one where the other belongs fails with a message about an identity that cannot be found, which
+# reads like a keychain problem rather than like the wrong certificate.
+write_step 'Packing, signing and notarizing (the notarization wait is Apple queueing, not this machine)'
+
+dotnet vpk pack \
+ --packId "$PACK_ID" \
+ --packVersion "$VERSION" \
+ --packDir "$PUBLISH_DIR" \
+ --packTitle "$PACK_TITLE" \
+ --packAuthors "$PACK_AUTHORS" \
+ --mainExe 'DodoSSH' \
+ --icon "$ICON" \
+ --plist "$RENDERED_PLIST" \
+ --entitlements "$ENTITLEMENTS" \
+ --signAppIdentity "$DODOSSH_SIGN_APP_IDENTITY" \
+ --signInstallIdentity "$DODOSSH_SIGN_INSTALL_IDENTITY" \
+ --notaryProfile "$DODOSSH_NOTARY_PROFILE" \
+ --runtime "$RUNTIME" \
+ --channel "$CHANNEL" \
+ --outputDir "$RELEASES_DIR" \
+ || stop_with 'vpk pack failed.'
+
+# ---- Did the notarization actually take? --------------------------------------------------------------
+
+# Asked rather than assumed, and this is the check worth having above all the others. A package whose
+# ticket did not staple is indistinguishable from a good one on the machine that built it — the Mac that
+# signed something trusts it locally — and reveals itself only on somebody else's machine, as a refusal
+# to open at all. spctl assesses it the way Gatekeeper will on a machine that has never seen this
+# certificate.
+write_step 'Verifying the notarization the way another Mac will'
+
+PKG="$(ls -t "$RELEASES_DIR"/*.pkg 2>/dev/null | head -n 1)"
+[ -n "$PKG" ] || stop_with 'vpk pack reported success but produced no .pkg.'
+
+if ! spctl --assess --type install --verbose=4 "$PKG"; then
+ stop_with "Gatekeeper rejects $PKG. It is signed but the notarization ticket is missing or stale; do not upload it."
+fi
+
+xcrun stapler validate "$PKG" || stop_with "The notarization ticket is not stapled to $PKG."
+
+write_step 'Built, notarized, and deliberately not uploaded'
+
+ls -lh "$RELEASES_DIR" | tail -n +2
+
+cat < wants an .ico and nothing
-# else, and Window.Icon wants a bitmap. So the raster exists, and this script is how it stays
-# honest: the numbers below are the ones in that XML, and regenerating is the whole edit.
+# to forget one of. Neither desktop platform will take a vector: wants an .ico
+# and nothing else, Window.Icon wants a bitmap, and vpk wants an .icns for the macOS bundle. So
+# the rasters exist, and this script is how they stay honest: the numbers below are the ones in
+# that XML, and regenerating is the whole edit.
#
# pwsh -File src/DodoSSH.Client.App/Assets/dodossh-icon.ps1
#
+# Both outputs are written every run, deliberately. Two scripts, or one script with a switch,
+# is how the two files come to be drawn from different geometry — which nobody would notice,
+# because no one person looks at a Windows taskbar and a macOS Dock on the same afternoon.
+#
# Coordinates are the launcher's 108-unit viewport, mapped so the middle 72 fills the canvas.
# That 72 is not an arbitrary crop: it is the part of an adaptive icon a launcher actually shows,
# the outer 18 on each edge being what it eats for masking and parallax. Rendering the whole 108
@@ -30,29 +35,49 @@ $ink = [System.Drawing.ColorTranslator]::FromHtml('#FFFFFF') # AccentInk
# the gap, and its downsampler is not kind to a hairline.
$sizes = @(16, 20, 24, 32, 40, 48, 64, 128, 256)
-function New-MarkPng([int]$size)
+# $tileFraction is how much of the canvas the accent tile fills, and it is the one number that
+# differs between the two platforms.
+#
+# Windows passes 1.0: the tile bleeds to the edge, because Windows draws application icons at
+# whatever size they come in and every other icon on the taskbar does the same.
+#
+# macOS passes 0.8047, and that is not taste. Apple's icon grid puts a rounded-rect app icon in
+# an 824-pixel square inside a 1024-pixel canvas — 824/1024 — with the remaining hundred pixels a
+# side left as air for the Dock's shadow and its magnification. An icon that ignores the grid and
+# bleeds to the edge does not read as bold; it reads as the one icon in the Dock that is too big,
+# because it sits beside Finder and Safari which do not.
+function New-MarkPng([int]$size, [double]$tileFraction = 1.0)
{
$bitmap = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$g = [System.Drawing.Graphics]::FromImage($bitmap)
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
+ # The tile, and the inset that centres it when it does not fill the canvas.
+ $tile = [double]$size * $tileFraction
+ $inset = ([double]$size - $tile) / 2.0
+
# The accent tile, rounded as a launcher mask rounds it. A square-cornered tile would be the
# one icon on the taskbar with corners, which reads as unfinished rather than as deliberate.
- $radius = [double]$size * 0.22
+ #
+ # 0.22 of the tile rather than of the canvas, so the corner keeps its proportion to the shape
+ # it is rounding instead of growing as the air around it does. It is also within a whisker of
+ # the 185/824 Apple's own grid specifies, which is why one radius serves both files.
+ $radius = $tile * 0.22
$d = $radius * 2.0
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
- $path.AddArc(0.0, 0.0, $d, $d, 180, 90)
- $path.AddArc($size - $d, 0.0, $d, $d, 270, 90)
- $path.AddArc($size - $d, $size - $d, $d, $d, 0, 90)
- $path.AddArc(0.0, $size - $d, $d, $d, 90, 90)
+ $path.AddArc($inset, $inset, $d, $d, 180, 90)
+ $path.AddArc($inset + $tile - $d, $inset, $d, $d, 270, 90)
+ $path.AddArc($inset + $tile - $d, $inset + $tile - $d, $d, $d, 0, 90)
+ $path.AddArc($inset, $inset + $tile - $d, $d, $d, 90, 90)
$path.CloseFigure()
$brush = New-Object System.Drawing.SolidBrush($accent)
$g.FillPath($brush, $path)
- # 108-viewport units to pixels, with the outer 18 dropped on each edge.
- $scale = [double]$size / 72.0
- function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale), (($y - 18.0) * $scale)) }
+ # 108-viewport units to pixels, with the outer 18 dropped on each edge. Scaled to the tile and
+ # offset by the inset, so the glyph keeps its place within the tile at either fraction.
+ $scale = $tile / 72.0
+ function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale + $inset), (($y - 18.0) * $scale + $inset)) }
# A stroke thinner than a pixel renders as a grey suggestion of itself, which at 16px is the
# difference between a mark and a smudge. The phone's file already bumps this width for the
@@ -117,3 +142,86 @@ $target = Join-Path $PSScriptRoot 'dodossh.ico'
$w.Dispose(); $out.Dispose()
Write-Output "Wrote $target ($($sizes.Count) sizes, $((Get-Item $target).Length) bytes)"
+
+# ---- dodossh.icns, for the macOS bundle ----------------------------------------------------------
+#
+# Written here rather than by `iconutil` on a Mac, and that is the point of doing it the long way.
+# iconutil is the documented tool and it exists only on macOS, so an icon that needed it could not
+# be regenerated on the machine this project is developed on — the geometry above would change and
+# the .icns would quietly keep the old mark until somebody next opened a Mac. The container format
+# is a magic word, a length and a run of typed PNG chunks, which is little enough to own.
+#
+# ◆ EVERY LENGTH IN THIS FILE IS BIG-ENDIAN, AND BinaryWriter IS NOT.
+#
+# The one thing that will catch anybody editing this. A .icns written little-endian is not rejected
+# with an error — Finder and vpk both just show the placeholder icon, because the first chunk claims
+# a length of about two billion and the parser walks off the end and gives up. Hence Write-BE32.
+#
+# Type codes are Apple's, and the pairs are not redundant. ic08 and ic13 are both 256 pixels because
+# one is "256 at 1x" and the other is "128 at 2x", and a Retina display asked for the second will not
+# accept the first. Same for ic09/ic14 at 512. iconutil emits both from an .iconset for this reason,
+# so this does too.
+$icnsTypes = @(
+ @{ Type = 'ic11'; Size = 32 } # 16@2x
+ @{ Type = 'ic12'; Size = 64 } # 32@2x
+ @{ Type = 'ic07'; Size = 128 } # 128@1x
+ @{ Type = 'ic13'; Size = 256 } # 128@2x
+ @{ Type = 'ic08'; Size = 256 } # 256@1x
+ @{ Type = 'ic14'; Size = 512 } # 256@2x
+ @{ Type = 'ic09'; Size = 512 } # 512@1x
+ @{ Type = 'ic10'; Size = 1024 } # 512@2x
+)
+
+# Apple's icon grid: an 824-pixel shape centred in a 1024-pixel canvas. See New-MarkPng.
+$macTileFraction = 824.0 / 1024.0
+
+# Rendered once per distinct pixel size rather than once per type code, so the two 256s and the two
+# 512s are byte-identical and the file does not carry the same image twice over at different
+# compression. It also halves the drawing, which at 1024 is not nothing.
+$rendered = @{}
+foreach ($size in ($icnsTypes.Size | Sort-Object -Unique))
+{
+ [byte[]]$png = New-MarkPng $size $macTileFraction
+ $rendered[$size] = $png
+}
+
+$icns = New-Object System.IO.MemoryStream
+
+function Write-BE32([System.IO.Stream]$stream, [uint32]$value)
+{
+ $bytes = [System.BitConverter]::GetBytes($value)
+ if ([System.BitConverter]::IsLittleEndian) { [array]::Reverse($bytes) }
+ $stream.Write($bytes, 0, 4)
+}
+
+function Write-Ascii([System.IO.Stream]$stream, [string]$text)
+{
+ $bytes = [System.Text.Encoding]::ASCII.GetBytes($text)
+ $stream.Write($bytes, 0, $bytes.Length)
+}
+
+# The header's length field covers the whole file including the header, so it is written last —
+# eight bytes of nothing now, seeked back to and filled in once the total is known.
+Write-Ascii $icns 'icns'
+Write-BE32 $icns 0
+
+foreach ($entry in $icnsTypes)
+{
+ $payload = $rendered[$entry.Size]
+ Write-Ascii $icns $entry.Type
+
+ # Length includes this chunk's own eight-byte header, which is the off-by-eight everybody
+ # writes once.
+ Write-BE32 $icns ([uint32]($payload.Length + 8))
+ $icns.Write($payload, 0, $payload.Length)
+}
+
+$total = [uint32]$icns.Length
+$icns.Position = 4
+Write-BE32 $icns $total
+
+$icnsTarget = Join-Path $PSScriptRoot 'dodossh.icns'
+[System.IO.File]::WriteAllBytes($icnsTarget, $icns.ToArray())
+$icns.Dispose()
+
+Write-Output "Wrote $icnsTarget ($($icnsTypes.Count) entries, $((Get-Item $icnsTarget).Length) bytes)"
diff --git a/src/DodoSSH.Client.App/Assets/dodossh.icns b/src/DodoSSH.Client.App/Assets/dodossh.icns
new file mode 100644
index 0000000..4a55a89
Binary files /dev/null and b/src/DodoSSH.Client.App/Assets/dodossh.icns differ
diff --git a/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj b/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
index 4efba22..59542bb 100644
--- a/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
+++ b/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
@@ -66,6 +66,21 @@
-->
release
+
+ true
+