From 890a5f2246e468bc594c0b8e4d1a702ead08183a Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Mon, 10 Aug 2026 10:43:28 +0200 Subject: [PATCH] Give the desktop a macOS head, signed from the first release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same application, the same Velopack and the same two-phase person-run release as Windows, with four things forced to differ. Signing is a precondition rather than an improvement: Gatekeeper refuses an un-notarized download outright instead of warning about it, so there was never the "unsigned for now" that ADR 0013 decision 8 argues for on Windows, and release-macos.sh refuses to start without the identities. The packaging split is narrower than it first looked, and the old claim at the foot of ci.yml is why it was worth checking rather than assuming. vpk cross-compiles when told to: 'vpk [osx] bundle' builds a real .app on any platform, and CI now publishes osx-arm64 and bundles it on every main and tag build, which is what catches a restore graph with no macOS native asset. There is no '[osx] pack' off a Mac, and that part is correct — pack drives codesign, notarytool and stapler, which exist nowhere else. The dylib signing loop in the script looks redundant beside vpk's own pass and is not. vpk signs with 'codesign --deep', which is the shape Apple documents as wrong for nested code, and platform-flags has recorded a notarization rejection that names no file since before any of this existed. Signing each native binary inside-out first leaves that pass nothing to get wrong. MacDeviceKeyStore reaches ADR 0007's conclusion through different hardware: a P-256 key in the Secure Enclave under an access control requiring user presence, so the platform enforces the gate rather than this process — which is the whole point of that ADR's amendment. The enclave holds no other kind of key, hence ECIES where Windows uses RSA-OAEP, and the shape that falls out is better than the Windows one: sealing needs only the public half and is silent, so only unlock prompts. IsSupported probes rather than infers, because three ordinary Macs answer no — an Intel machine without a T2, one with no login password, and every unsigned development build, since enclave keys need a signing identity. Two decisions worth stating because they are reversible. arm64 only: a second channel is small work and nobody here has an Intel Mac to walk Phase 18 on, and an x64 package would be the only artefact in this repository reaching users unverified. And the pack id stays DodoSSH.Desktop even though vpk names the bundle after it, so /Applications holds DodoSSH.Desktop.app: decision 2's reasoning binds harder here, because a pack id of DodoSSH would put Velopack's install root on top of ClientPaths.DataDirectory and let an uninstall take the user's un-synced outbox with it. CFBundleDisplayName puts the product name back in front of a person. Measured rather than assumed, since none of it is obvious: the publish and the bundle were both run, LSMinimumSystemVersion is 12.0 because that is the minos in the apphost's own LC_BUILD_VERSION, and vpk copies a custom Info.plist verbatim with no substitution at all — which is why the plist is a template the script renders and not a committed file. What is not done is the half that needs the hardware. There is no macOS runner, so nothing past "it bundles" has ever run. Phase 18 is the whole of the verification, and the two checks most likely to fail are the terminal against WKWebView and the enclave interop, neither of which has executed once. --- .github/workflows/ci.yml | 88 +++ README.md | 44 +- build/macos/DodoSSH.entitlements | 67 ++ build/macos/Info.plist.template | 125 ++++ docs/adr/0007-device-key-protection.md | 10 +- .../0013-desktop-distribution-and-updates.md | 38 ++ docs/manual-checks.md | 117 ++++ docs/platform-flags.md | 64 +- scripts/release-macos.sh | 404 ++++++++++++ .../Assets/dodossh-icon.ps1 | 134 +++- src/DodoSSH.Client.App/Assets/dodossh.icns | Bin 0 -> 45102 bytes .../DodoSSH.Client.App.csproj | 15 + .../Platform/MacDeviceKeyStore.cs | 598 ++++++++++++++++++ .../Platform/MacSecurity.cs | 254 ++++++++ src/DodoSSH.Client.App/Platform/MacSymbols.cs | 185 ++++++ .../Platform/VelopackUpdateChannel.cs | 87 ++- .../Platform/WindowsDeviceKeyStore.cs | 28 +- 17 files changed, 2219 insertions(+), 39 deletions(-) create mode 100644 build/macos/DodoSSH.entitlements create mode 100644 build/macos/Info.plist.template create mode 100644 scripts/release-macos.sh create mode 100644 src/DodoSSH.Client.App/Assets/dodossh.icns create mode 100644 src/DodoSSH.Client.App/Platform/MacDeviceKeyStore.cs create mode 100644 src/DodoSSH.Client.App/Platform/MacSecurity.cs create mode 100644 src/DodoSSH.Client.App/Platform/MacSymbols.cs 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 0000000000000000000000000000000000000000..4a55a8918342b18cb4ef047126546af058bf3490 GIT binary patch literal 45102 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%0D1oRruzJQiE*M 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 literal 0 HcmV?d00001 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 +