Krill Desktop built .deb (Linux) and .msi (Windows) artifacts and the
Compose nativeDistributions block even listed TargetFormat.Dmg, but no
pipeline ever produced a macOS installer — so Mac users had nothing to download.
Two gaps, neither in application code: (1) jpackage can only emit a .dmg from
a macOS host, and there was no CI job running on the existing self-hosted macOS
runner; (2) the desktop packageVersion was a hand-edited literal, decoupled
from the canonical version.txt. macOS desktop is the same jvm("desktop")
target as Linux/Windows, so no new Kotlin target or expect/actual was
needed — only a macOS build host, packaging metadata, and a publish workflow.
composeApp/build.gradle.kts: read packageVersion from the repo-root
version.txt, and add a macOS { } block (bundle ID zone.krill.desktop, app
name, optional signing gated on the APPLE_SIGNING_IDENTITY env var so v1
ships unsigned). New .github/workflows/Deploy macOS.yml runs on
[self-hosted, macOS, ARM64], builds :composeApp:packageReleaseDmg, and
publishes versioned + latest keys to s3://cms.krill.systems/distro/macos/
with a CloudFront invalidation (E1MKRDHOFUF59Y, the same distribution the
screenshot/SDK-docs workflows use). A Download-category post documents the link
and the Gatekeeper open-steps for the unsigned build.
When a cross-platform desktop target advertises an installer format
(TargetFormat.Dmg), confirm a build host for that OS actually exists in CI —
the format being listed does not mean anything ships. Drive every installer’s
version from version.txt, never a hand-edited literal, so all platforms stay
in lockstep at release time. Platform-specific runtime surfaces in
desktopMain (the expect/actuals) are only proven on the OSes CI actually
runs the app on; a Linux-only build pipeline silently leaves the macOS paths
unexercised — track that as a manual smoke checklist until a macOS build exists.
:composeApp:packageReleaseDmg (ProGuard variant) —
succeeded on first try in ~2m16s on version.txt = 1.0.1139. No fallback
to non-release packageDmg was needed; the ProGuard + jpackage
interaction on macOS feared in design D3 did not materialize.CFBundleShortVersionString / CFBundleVersion
both 1.0.1139 (matches version.txt), CFBundleIdentifier
zone.krill.desktop, architecture arm64 (Apple-silicon-only as
intended for v1). codesign -dvvv reports Signature=adhoc /
TeamIdentifier=not set — the unsigned-by-default v1 path is wired
correctly (the APPLE_SIGNING_IDENTITY-gated signing block in
composeApp/build.gradle.kts stayed inactive).krill-desktop.app launches on macOS 25.3,
stays running, and renders the Compose window. Multicast beacon raises
NoRouteToHostException on a machine with no Krill server on the LAN
but the IO_SCOPE handler logs and continues — not a regression.desktopMain actuals audited: AlertSound (Toolkit.beep),
Clipboard (Toolkit.systemClipboard), ImageUtil (Skia), EmojiSupport
(delegates to system fonts — Apple Color Emoji), ImagePicker /
SvgFilePicker (JFileChooser) — all are cross-platform JVM APIs; no
macOS-specific fixes required to ship v1.~/.krill/ path init: the desktop-only paths in Platform.jvm.kt
(installId, trustStore) resolve to ~/.krill/install_id /
~/.krill/trusted. Platform.jvm.kt::createOrReadInstallId writes
~/.krill/install_id without mkdirs on the parent — that would
FileNotFoundException on a fresh Mac, except that Koin resolves
FileNodePersistence first and its ensureStoreDir mkdirs
~/.krill/data/nodes, materializing ~/.krill/ before installId()
is ever read. Verified: after a clean launch, ~/.krill/{install_id,
data, pin_token, tos_accepted} are all present. The dormant
ordering-fragility lives in shared/, out of scope for this change,
and surfaced separately as the test-seam fix in
NodeObservationRegistry (which mocked persistence and so tripped
the same installId() path).The .dmg now ships signed by Developer ID Application: Sautner Studio
LLC (HDUTVLGRF7) and notarized + stapled by Apple, so Gatekeeper opens it
without the “unidentified developer” warning. spctl -a -t open --context
context:primary-signature -vv <dmg> reports
accepted / source=Notarized Developer ID.
.cer and double-clicked to install. Keep exactly one such identity in
the keychain; duplicates make codesign fail with “Multiple matching
certificates are found” even when the SHA-1 fingerprint is unambiguous,
because Compose’s signing wrapper validates the identity by name before
invoking codesign and does not accept fingerprints.AuthKey_<KEY_ID>.p8 (one-shot — Apple
won’t re-issue), stashed at ~/.private/AuthKey_<KEY_ID>.p8 (chmod
600). Registered as a keychain profile named krill-notary via
`xcrun notarytool store-credentials krill-notary –key Compose’s macOS { signing { } } block signs the .app with the
hardened runtime (flags=0x10000(runtime) in codesign -dvvv) when the
APPLE_SIGNING_IDENTITY env var is set; the env var is the full
identity name ("Developer ID Application: …"), not a SHA-1. The
.dmg shell itself is then signed, submitted, and stapled by
.github/workflows/Deploy macOS.yml — see the Sign + notarize +
staple .dmg step, gated on secrets.APPLE_SIGNING_IDENTITY. Absent
secret → the workflow falls through to the unsigned publish path,
preserving v1 behavior.
.app staple is intentionally one-shotThe notarization ticket is stapled to the .dmg shell, not to the
.app inside it (the .dmg is a read-only image; jpackage would re-sign
and clobber any prior .app staple if we tried). For a downloaded .dmg,
Gatekeeper validates the stapled ticket on the container without internet;
on first launch of the installed .app Gatekeeper does an online check
against Apple’s notary, which succeeds because the .app’s CDHash is
recorded with the original .dmg submission. The realistic offline-first
case (user copied the .dmg over via thumb drive and is offline on first
launch) would prompt — a v2 improvement is to notarize the .app
standalone before packaging and staple it too; for v1 the .dmg-only
staple is sufficient because users get the .dmg from CloudFront, which
implies online.