Skip to content

Centralize the app icon into one vector master - #24

Merged
chrisuthe merged 5 commits into
masterfrom
chrisuthe/task/centralize-the-app-icon-into-one-vector-master
Sep 4, 2026
Merged

chrisuthe merged 5 commits into
masterfrom
chrisuthe/task/centralize-the-app-icon-into-one-vector-master

Conversation

@chrisuthe

Copy link
Copy Markdown
Owner

The mark existed only as a 256×256 PNG, committed three times byte-identical, with no vector source. Nothing wired it to the Windows executable, the macOS bundle or the Linux window, so all three showed a generic icon.

One master, one generated set

packaging/icons/sendspin.svg is now the only hand-authored artwork of the app icon, and scripts/generate-icons.sh renders the committed set from it — the .ico, the .icns and the hicolor theme. scripts/pack-icons.py writes the .ico and .icns containers directly, so the generator needs no iconutil and runs on Linux as well as macOS.

The set is committed rather than built: a plain dotnet build needs the .ico, and a clean machine has no reason to own a rasterizer. Verified — with rsvg-convert and ImageMagick off PATH, the Windows head still builds.

The SVG reproduces the old raster rather than redesigning it. The geometry was recovered by fitting the PNG, not by eye: a full-bleed disc, a radial gradient linear in distance from (89.6, 89.6) fitting to an RMS under 0.5/255 with no angular structure, and a centre hole at r = 42.7. Overlaying the two, 90% of pixels are within 1/255 and the difference is confined to two thin rings — the old raster had a ~4.5px soft edge from being resampled, while 50% alpha still lands at exactly r = 128. Same geometry; the vector render is the sharper of the two.

sendspin-menubar.svg is a second master and travels with this change because it needs the same generator. It is a different surface — a monochrome status glyph, not the app icon — and it is the only runtime behaviour change here.

The CI guard hashes files, not a re-run

The new icons job checks packaging/icons/.source-hash, which records the SHA-256 of both masters, both scripts, and every generated file. The hashes are written at generation time and committed beside the files they describe, so the check asserts this set is what the generator last wrote from these masters — it catches a master edited without regenerating, an output hand-edited afterwards, and a regeneration only half committed.

It deliberately does not regenerate and diff. That would assert that two builds of librsvg agree on edge antialiasing, which they need not, and would eventually fail whenever the runner image moved, for a reason no contributor could act on.

IconSetTests covers what a hash cannot: that the containers are well formed. A truncated or single-size .ico is still a valid file whose only symptom is a generic icon.

Windows: one property, two symptoms

<ApplicationIcon>, conditioned on the Windows TFM for the same reason as the existing ApplicationManifest, is the whole change. The compiler emits it as RT_GROUP_ICON 32512 — IDI_APPLICATION. That is both the id Explorer, the taskbar and alt-tab read and the one ShellBalloonNotificationService's ExtractIconEx call was falling through on, so the notification balloons pick up the mark with no change to that file.

Parsed out of the built executable:

RT_GROUP_ICON #32512: 7 entries
   16x16   planes=1 bits=32  ->  RT_ICON #1
   24x24   planes=1 bits=32  ->  RT_ICON #2
   32x32   planes=1 bits=32  ->  RT_ICON #3
   48x48   planes=1 bits=32  ->  RT_ICON #4
   64x64   planes=1 bits=32  ->  RT_ICON #5
  128x128  planes=1 bits=32  ->  RT_ICON #6
  256x256  planes=1 bits=32  ->  RT_ICON #7

PNG payloads at every size rather than BMP below 256: the head targets Win10 2004+, where the shell and ExtractIconEx have read PNG entries since Vista.

macOS

The .icns ships as a BundleResource and Info.plist names it in CFBundleIconFile; the existing bundle check in CI now asserts both, alongside the keys it already checked. NSWorkspace.icon(forFile:) on the built bundle returns the mark — the LaunchServices path Finder, the Dock and alt-tab all use. iconutil round-trips the generated .icns into the full ten-file iconset.

The menu bar status item now uses the bundled silhouette, kept as Template = true so macOS keeps tinting it for light, dark and highlighted menu bars. Both existing fallbacks are preserved in order: the music.note SF Symbol, then the "Sendspin" title.

Linux

The window carries WM_CLASS=io.sendspin.client via X11PlatformOptions.WmClass, and the desktop entry names the same string in StartupWMClass — the pair a desktop matches a running window against. Every packaging path installs the whole hicolor size range instead of only 256×256.

The AppImage, deb and Flatpak paths now install the checked-in packaging/io.sendspin.client.desktop rather than each writing its own inline copy, which is what let the icon basename drift. Two byte-identical duplicates of it are gone.

Wayland is not fixed, and cannot be

This is an amendment to the acceptance criteria, not an omission. Avalonia's Wayland backend never sends xdg_toplevel.set_app_id, so the compositor sees an empty app id and cannot match the window to the desktop file. StartupWMClass does not cover it — that key is X11-only. Verified against the shipped assemblies: Avalonia.Wayland 12.1.2, the newest release, contains no app_id reference at all, while NWayland does expose XdgToplevel.SetAppId.

The upstream API is WaylandPlatformOptions.AppId — Avalonia#21783 asks for it, Avalonia#21982 implements it, and Avalonia's API review accepted it on 2026-08-28. It has not shipped. When it does, the Wayland arm of ConfigureWindowing takes the same one-line .With(...) the X11 arm already has, which is what the comment there records. The gap is documented in README.md and docs/ARCHITECTURE.md; SENDSPIN_X11=1 is the workaround meanwhile.

No workaround is attempted. The toplevel is not on the window implementation — it is on a worker-thread object behind a proxy, so reaching it means marshalling a libwayland call onto that thread, timed against a surface commit the app cannot observe. A soft-failing version would be indistinguishable from the current behaviour.

Deduplication

The mark's raster is committed once. The Avalonia resource is a link, not a copy:

<AvaloniaResource Include="..\..\packaging\icons\hicolor\256x256\apps\io.sendspin.client.png"
                  Link="Assets\sendspin.png" />

so avares://Sendspin.Player/Assets/sendspin.png still resolves and TrayIconController.cs is unchanged — which was the test of whether the centralization landed in the right place. MainWindow sets Icon to that same URI, so a broken link fails every MainWindowShellTests case at XAML load; that matters because TrayIconController.LoadIcon() deliberately swallows a missing asset into a warning and would not have shown the regression on its own.

Two things I touched that the brief scoped out

scripts/build-appimage.sh had to change: deduplicating the icon made its Icon=sendspin reference dangling, since no packaging path installs that basename any more, so shipping it untouched would have been a live bug. The out-of-scope item was reconciling the two AppImage builders, and that is untouched — it still duplicates the CI job, it just consumes canonical inputs now.

scripts/build.sh still writes its own Flatpak manifest, which has already drifted from the committed one (it names runtime 23.08 against 25.08). Reconciling them is genuinely separate work — the committed manifest hardcodes publish/linux-x64, so pointing this at it would drop --runtime linux-arm64. A comment there now says so rather than leaving a reader to wonder.

Verification

Green here: the full solution builds clean at Release with TreatWarningsAsErrors and EnforceCodeStyleInBuild, and each of the three heads builds individually. ./scripts/test.sh --configuration Release — 373 + 184 passed.

This was built on macOS, so Windows and Linux desktop behaviour is evidenced by artifact contents rather than screenshots: the resource dump above, and the installed hicolor tree plus the WM_CLASS/StartupWMClass pair. Live testing on Windows and on a real Linux desktop needs to happen after release.

Follow-ups, not fixed here

  • The .icns artwork is full-bleed, which is correct for Windows and hicolor but off-HIG for the Dock on Big Sur and later, where artwork is expected inset within the canvas. Fixing it is an .icns-only inset in the generator, not a change to the master — but it is a design change, which this task puts out of scope.
  • scripts/build.sh and scripts/build-appimage.sh still fetch appimagetool from continuous with no checksum, while the workflow pins a release and verifies its SHA-256 with a comment explaining why. Worth its own PR.

The mark existed only as a 256x256 PNG, committed three times byte-identical,
with no vector source. Nothing wired it to the Windows executable, the macOS
bundle or the Linux window, so all three showed a generic icon.

packaging/icons/sendspin.svg is now the only hand-authored artwork, and
scripts/generate-icons.sh renders the committed set from it: the .ico, the
.icns, the hicolor theme and the menu bar silhouette. The set is committed
rather than built because a plain `dotnet build` needs the .ico, and a clean
machine has no reason to own a rasterizer. scripts/pack-icons.py writes the
.ico and .icns containers directly, so the generator does not need iconutil
and runs on Linux as well as macOS.

The CI guard hashes the masters and the generator rather than regenerating and
diffing the outputs. A byte diff would be asserting that two builds of librsvg
agree on edge antialiasing, which they need not; it would eventually fail
whenever the runner image moved, for a reason no contributor could act on.

One <ApplicationIcon> fixes two Windows symptoms at once. The compiler emits
RT_GROUP_ICON 32512, which is both what Explorer, the taskbar and alt-tab read
and what ShellBalloonNotificationService's ExtractIconEx call was falling
through on, so the notification balloons pick up the mark with no change to
that file.

On Linux the window now carries WM_CLASS=io.sendspin.client, and every desktop
file names the same string in StartupWMClass — the pair a desktop matches a
running window against. Every packaging path installs the whole hicolor size
range instead of only 256x256, and the AppImage, deb and Flatpak paths now
install the checked-in desktop entry rather than each writing its own, which
is what let the icon basename drift.

Wayland is not addressed here: Avalonia.Wayland 12.1.1 never calls
xdg_toplevel.set_app_id, so no compositor can match the window to the desktop
file. The X11 half and the packaging half are correct and land regardless.
Avalonia cannot set it. The Wayland backend never sends
xdg_toplevel.set_app_id, so the compositor sees an empty app id and cannot
match the window to io.sendspin.client.desktop; StartupWMClass does not cover
it, because that key is X11-only. Verified against the shipped assemblies:
Avalonia.Wayland 12.1.2, the newest release, contains no app_id reference at
all, while NWayland does expose XdgToplevel.SetAppId.

The upstream API is WaylandPlatformOptions.AppId — Avalonia#21783 asks for it,
Avalonia#21982 implements it, and Avalonia's API review accepted it on
2026-08-28. It has not shipped. When it does, the Wayland arm of
ConfigureWindowing takes the same one-line .With(...) the X11 arm already has,
which is what the comment there records.

No workaround is attempted. Reaching the toplevel means walking a proxy to a
worker-thread object and marshalling a libwayland call onto that thread, timed
against a surface commit the app cannot observe — untestable here, and a
soft-failing version would be indistinguishable from the current behaviour.
Walking the working tree also found the icons a local build.sh run leaves
under artifacts/, and reported them as duplicates of the committed ones. The
rule is about what is committed, so ask git. Comparing content hashes rather
than guessing from size also drops the docs/ exclusion the heuristic needed.
macOS has no sha256sum and Linux CI should not depend on shasum being present
from perl. The generator picks whichever exists; CI checks with sha256sum. The
two write the same format, so a .source-hash written on either verifies on the
other.
The drift manifest recorded only the inputs, so it caught a master edited
without regenerating and nothing else — a hand-edited .ico, or a regeneration
only half committed, both left CI green while the committed set was no longer
what the generator produces. It now records the outputs too. That does not
reintroduce the reproducibility problem the input-only form avoided: the
hashes are written at generation time and committed beside the files they
describe, so the check asserts "this set is what the generator last wrote",
never "two builds of librsvg agree".

The no-duplicate test asked git, which meant spawning a process that is not
always there and asserting a repository-wide rule from a test named for the
icon set. It now walks packaging/ and src/, where all three copies lived and
which stays clear of the artifacts/ tree a local build.sh run fills.

packaging/flatpak/io.sendspin.client.desktop was byte-identical to
packaging/io.sendspin.client.desktop, which is the condition that justified
deleting the AppImage copy; flatpak-builder resolves a relative source against
the manifest's own directory, so it reads the shared one now.

Also: pin the menu bar template's two sizes, since AppKit scales a mismatched
template image rather than refusing it; drop the dead local in the status item
fallback chain; drop the Assets placeholder, whose directory now holds only a
linked item; and say plainly in build.sh that its inline Flatpak manifest is a
known duplicate of the committed one, and why reconciling it is separate work.
@chrisuthe
chrisuthe marked this pull request as ready for review September 4, 2026 17:20
@chrisuthe
chrisuthe merged commit 54fb364 into master Sep 4, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant