Skip to content

feat(desktop): add app icon switcher - #3563

Closed
ARE404 wants to merge 15 commits into
apache:mainfrom
ARE404:are404/feat-app-icon-switcher
Closed

feat(desktop): add app icon switcher#3563
ARE404 wants to merge 15 commits into
apache:mainfrom
ARE404:are404/feat-app-icon-switcher

Conversation

@ARE404

@ARE404 ARE404 commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add built-in desktop app icon choices grouped in Appearance settings
  • support validated custom PNG icon import, persistence, removal, and serialized shell updates
  • vertically center compact palette and app-icon choices when a grid row stretches to fit wrapped content

What changed

  • keep the app-icon write path and lifecycle owned by the desktop main process
  • expose the typed preload bridge and renderer settings controls
  • preserve top-aligned media-led theme cards while centering compact horizontal choice content
  • add geometry-based Electron coverage for the stretched-row alignment regression

Testing

  • npm run build:renderer
  • npm run typecheck
  • npx playwright test e2e/settings.spec.ts --config e2e/playwright.config.ts --workers=1 (4 passed)
  • spacing detector scan (clean)

ARE404 added 15 commits August 23, 2026 02:30
Settings → 外观 gains an 应用图标 picker: the dock tile on macOS, the
window/taskbar icon on Windows and Linux. `appearance.appIcon` is a
closed enum normalized fail-closed the same way `appearance.palette`
is — the renderer names an id, never a path, so a hand-edited settings
file can only ever select artwork that ships with the build.

Applying it hangs off the client-settings effect that already owns the
OS-level side effects of a settings write (keep-awake, bot channels), so
a switch lands live and is deduped against the file watcher echoing the
same write back. Startup keeps setting the default mark synchronously —
awaiting the settings store there would reintroduce the generic Electron
rocket that PR-GRAY-CARD-LIFT-0 removed — and the persisted choice lands
a tick later from the same effect. New windows are born with the chosen
icon, which is what Windows and Linux need since they draw it per window.

Two icons ship: the existing brand mark and a grayscale variant derived
from it. The variant is placeholder artwork — the mechanism is the point,
and adding an id is one entry in APP_ICONS plus one PNG.

The picker gets its thumbnails from the main process (ids plus 128px data
URLs, computed once) rather than importing the 1024px masters into the
renderer bundle.
Three things the picker needed once it shipped more than two icons.

**A set to choose from.** 14 recolours of one geometric mark, in four
families. The artwork carries macOS's 824/1024 margin itself, because the OS
scales the canvas to the dock tile without adding any — a full-bleed tile
sits 24% wider than every neighbour.

**Groups.** 16 tiles in one grid gives the eye nowhere to start, the same
problem the palette picker solved with `PALETTE_GROUPS`; this follows that
shape, down to the per-group `role="group"` and its label id. Membership is a
renderer concern: the main process reports what artwork loaded, and anything
no group claims falls into the trailing group rather than disappearing.

**Import.** `appearance.appIcon` was a closed enum precisely so a settings
file could not name a path, and imported art must not weaken that. So a
custom icon is referenced as `custom:<32 hex>`: the main process generates
the id, the id IS the whole file name, and both core's normalization and the
store re-check its shape before anything reaches `join`. The renderer never
learns a path; the file dialog runs in the main process.

Imported files are re-encoded rather than copied — decode, centre-crop to
square, scale to 1024, write PNG — so the stored shape is the only one the
rest of the app reasons about, and nothing rides along inside the original.
Non-square art is cropped rather than letterboxed: silently adding a margin
would reframe art the user already framed. Deleting the artwork under the
current choice hands the dock back to the brand mark first, so it is never
left pointing at a file that no longer exists.
… removal

Review findings on the icon picker, all reachable at runtime.

**A settings value could become a path outside the asset root.**
`normalizeSettings` runs when settings are READ from disk; `SettingsStore.update`
merges and writes without it, so the object the effects act on carries whatever
a patch put there. `appIconPath` then cast that string to a shipped id, and
`../../../../tmp/owned` resolved to `/tmp/owned.png` on its way to Electron's
decoder. Every runtime ingress now coerces through `toAppIconChoice`, and the
declared type is no longer treated as a guarantee.

**Removal was sequenced by the renderer, in the order its comment denied.**
It deleted the file and then persisted the reset, so a failed write left the
setting pointing at artwork that was gone. Selection and artwork are one pair
of state, so the operation that can break them moved to a single main-process
owner: it resets the selection, applies it, and only then deletes.

`app-icon-ipc.ts` takes its Electron-touching capabilities as parameters, which
is what lets the removal contract be tested without booting a browser process.

**Dropping the artwork from a package was silent.** `assertPackagedResources`
now requires `assets/icon.png` and every `APP_ICONS` file, with a test that the
check actually fails when one goes missing — Electron reports an unreadable
file as an empty image, so nothing downstream would have noticed.

Also drops `desktopAssetPath`, whose only production caller became
`appIconPath`, and the trailing blank line that failed `git diff --check`.
Three more review findings, all about trusting something that had not been
checked yet.

**A byte cap does not bound a bitmap.** `stat()` capped the compressed form,
so a few hundred KB of PNG declaring 30000×30000 reached the platform decoder
and allocated the full image inside the main process. Reading the file also
happened twice — once to size it, once to decode it — leaving a window where
the path could be swapped in between.

Both close the same way: read one capped snapshot, parse the header out of
those bytes, reject the dimensions there, and decode that same buffer. The
parser is pure and covers the fixture the reviewer asked for — a small file
declaring a huge bitmap.

**The dialog offered formats the decoder does not guarantee.** `nativeImage`
guarantees PNG and JPEG on every platform; TIFF and WebP were advertised
anyway, so a Windows or Linux user could pick a file the app then refused.
The picker now offers PNG and JPEG, and the header parser recognises exactly
those two — the dialog filter and the validator can no longer drift apart.

**A window could be born with an icon path that decodes to nothing.** A
persisted custom id whose file was deleted resolves to a perfectly valid
path; the dock fell back through `loadAppIcon`, but `BrowserWindow` got the
raw path, which matters on Windows and Linux where the icon is per window.
Window creation now walks the same fallback, via a pure helper so the
missing-file case is covered without a browser process.
…ifier

Self-review of the previous two commits. Three of these are mine.

**Crop framed the wrong rectangle.** Validating dimensions from the header was
right; using those same numbers as the crop geometry was not. A JPEG carrying
an EXIF orientation comes out of the decoder rotated, so the pre-rotation
rectangle frames the wrong part of the picture or falls outside it. The header
decides admission; `getSize()` decides geometry.

**A single `read()` may return early.** It is one syscall and can hand back
fewer bytes than asked for. The truncated buffer still passed the header check
— which only reads the first bytes — and then failed to decode, surfacing a
valid file as an unreadable image. Now it reads to EOF through one handle, so
the snapshot property that closes the swap window is kept while the result is
actually whole. Growing in chunks also stops a 20 KB icon from allocating the
whole 16 MB cap up front.

**Deleting the selected icon left the picker showing it.** The main process
owns the reset, and nothing notifies this surface of a client-settings write it
did not make, so the tile stayed highlighted over artwork that was gone. The
renderer now persists the selection the operation reports.

**The packaged-resource verifier could no longer load without a build.**
Importing `APP_ICONS` from `packages/core/dist` — build output, not in the
repository — broke the existing `verify-packaged-app.test.mjs` in a clean
checkout, and it runs in `check:release`. The required list now comes from the
artwork directory; that the catalog matches the shipped ids is already the
desktop suite's job, where `APP_ICONS` is walked against the same files.

Also drops the `as never` in the IPC harness, which was letting the stub drift
from the real dependency shape unnoticed.
**The new packaged-resource requirement broke upgrade verification.** The
Windows upgrade lifecycle verifies the previously released installer under
`artifactContract: 'legacy-baseline'`, and every one of those files is new to
this branch — `assets/` was not packaged at all before it, so the check would
fail an artifact that was correct when it shipped, before the upgrade was even
attempted. It now rides on `requireAppIconCatalog`, threaded from
`requiresCurrentContract` exactly as `requireDisclaimer` already is, with the
current contract left strict. Note the gate covers `assets/icon.png` as well
as the catalog: the whole directory postdates every previous release.

**A click could land inside the removal window.** Removal reads the current
selection in the main process before deleting, and only the remove button was
disabled — so a selection made during that read could persist the very icon
being deleted. Every card is fenced while a removal is in flight.

**And the renderer no longer writes the selection back.** `SettingsSurface`
already subscribes to `settings:clientChanged`, and the effects emit it for
this write, so the reset propagates on its own; the second write only added a
way to stamp a stale value over a newer choice. My earlier reasoning that
nothing notified the surface was wrong — I had found `settings:externalChanged`
and stopped looking.
The icon picker's import and remove controls added the first Button to
appearance-settings-page.tsx, and the generated inventory tracks which Astryx
components each surface uses. Regenerated with astryx:surface-inventory:write.
`CustomAppIconImportResult` stayed behind in the store when the handlers moved
to app-icon-ipc.ts, which declares its own. Knip flags it, and that check only
ran once the stale surface inventory stopped failing ahead of it.
The 外观 story threw on mount: the picker asks the bridge for its previews as
soon as it opens, and the fixture had no `iconPreviews`. Calling a method the
fixture does not define is a synchronous TypeError, so the effect's own
`.catch()` never saw it and the story rendered nothing at all.

This has been broken since the picker landed on this branch — the visual smoke
sits behind fifteen steps that a stale generated file was failing ahead of, so
nothing reached it until now.

The fixture spans one shipped icon per group plus an imported one, so the smoke
covers the group headings and the remove affordance rather than an empty
picker. `importIcon` answers `cancelled`, the one outcome that needs no dialog.
Removal read the selection and then wrote unconditionally. Between those two
calls another surface can select a different icon, and the reset would then
stamp `default` over a choice the user had just made. The renderer's busy flag
cannot help: the window is on the far side of the IPC boundary.

`settingsStore.updateIf` evaluates its predicate and writes on one queue, which
is the only place the pair can be made atomic. The reset now applies only while
the current choice is still the icon being removed; when it is not, the newer
selection stands and is what the caller is told.

The file is deleted either way — it is no longer in use, which is what the
caller asked for — and the OS surface is only re-applied when the selection
actually moved.

This also removes a call: `updateIf` reports the settings whether or not it
applied, so the separate `get()` is gone and `SettingsStore` is narrowed to the
one seam this needs.
The compare-and-set covered the settings write and nothing after it. Applying
the icon yields, and a selection arriving in that gap — from another renderer,
or already queued on the generic settings channel — would be left pointing at
artwork this sequence was about to delete.

Guarding that window would only move it, so selection joins the seam instead of
racing it: `app:selectIcon` persists and applies the choice, and select, import
and remove now run one at a time under a single owner. The renderer no longer
writes `appearance.appIcon` through the generic settings channel at all, which
is what let a selection interleave in the first place.

Selection can now also be refused: the generic channel would happily persist an
id whose file is gone, while this seam checks the artwork is still there.

The regression test issues a selection mid-removal without awaiting it and pins
the whole sequence — the selection runs after, not inside, and is refused
because the artwork it names no longer exists.
It was removed as unused — at the time its only caller was its own test. The
permission overlay now resolves its icon through it on main, so the reason for
deleting it no longer holds and the branch cannot compile without it.
The header sweep and its `check:asf-headers` gate landed upstream while this
branch was open, so the eleven files it introduces predate the requirement.
Generated with `npm run write:asf-headers`; no other content changed.
… on a dialog

Two follow-ups from review, both about the seam owning what it claims to own.

**The comment on the seam was not true as written.** It said the icon owner is
the only thing that can refuse a choice whose artwork is gone, but
`clientOwnedSettingsPatch` forwarded `appearance` as one object, so a
well-formed `custom:<32hex>` could still reach the store through
`settings:client:update` — from a second window, an older preload, or a direct
`settings.update`. The id shape was still checked, so the failure was never a
malformed value; it was a valid id landing between a removal's settings apply
and its file deletion. `appearance` is now filtered field by field, the way
`personalization` beside it already was, which makes the comment true rather
than adding another compare-and-set.

**The file dialog was holding the serialized owner.** It sits open for as long
as the user looks at it, which blocked selection and removal in every other
window on someone reading a file list. The dialog changes nothing — only the
copy-into-place does — so it now runs before the queue, and only the write path
is serialized. Covered by a test that selects while a dialog is deliberately
left open.
@ARE404

ARE404 commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #3564, which contains only the alignment fix on top of current main. The app-icon feature itself already landed in #3431.

@ARE404 ARE404 closed this Aug 23, 2026
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