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 55027a2..ea206ea 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -2596,3 +2596,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 +