Give the desktop a macOS head, signed from the first release
ci / android head (pull_request) Canceled after 0s
ci / desktop nightly (pull_request) Canceled after 0s
ci / api image (pull_request) Canceled after 0s
ci / build and test (pull_request) Canceled after 1m21s

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.
This commit is contained in:
jaap-jan committed 2026-08-10 10:43:28 +02:00
1 parent e936ab4646
commit 890a5f2246
17 files changed
+2219 -39

No files matched your search

+121 -13
View File
@@ -1,13 +1,18 @@
# Regenerates dodossh.ico from the same geometry the Android launcher icon draws.
# Regenerates dodossh.ico and dodossh.icns from the same geometry the Android launcher icon draws.
#
# The phone's mark is a vector — Resources/drawable/ic_launcher_foreground.xml — and the whole
# reason it is a vector is that there is then one geometry to change and no set of PNG densities
# to forget one of. Windows will not take a vector: <ApplicationIcon> 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: <ApplicationIcon> 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)"