-
Notifications
You must be signed in to change notification settings - Fork 240
feat(desktop): let the user choose or import the app icon #3431
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ARE404
wants to merge
3
commits into
apache:main
Choose a base branch
from
ARE404:are404/feat-app-icon-switcher
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import { open } from 'node:fs/promises'; | ||
| import { join } from 'node:path'; | ||
| import { test } from 'node:test'; | ||
| import { APP_ICONS } from '@maka/core/settings'; | ||
| import { | ||
| appIconAssetSegments, | ||
| appIconLoadOrder, | ||
| resolveAppIconPath, | ||
| } from '../app-icon.js'; | ||
| import { desktopAssetRoot } from '../desktop-assets.js'; | ||
|
|
||
| const DEV_ROOT = desktopAssetRoot({ isPackaged: false, resourcesPath: '/not-used-in-dev' }); | ||
|
|
||
| /** | ||
| * The OS is handed these files directly, and Electron reports an unreadable | ||
| * one as an EMPTY image rather than as an error — a dock tile silently goes | ||
| * blank. So the contract every id must meet is checked here, against the | ||
| * bytes: present, a real PNG, and the square master the dock wants rather | ||
| * than a screenshot someone dropped in with the right name. | ||
| */ | ||
| test('every shipped icon id resolves to a square 1024px PNG master', async () => { | ||
| for (const icon of APP_ICONS) { | ||
| const path = resolveAppIconPath(DEV_ROOT, icon); | ||
| const where = `app icon "${icon}" (${appIconAssetSegments(icon).join('/')})`; | ||
| const file = await open(path, 'r').catch(() => undefined); | ||
| assert.ok(file, `${where} has no artwork in the build`); | ||
| try { | ||
| // PNG signature, then the IHDR width/height at bytes 16..24. | ||
| const header = Buffer.alloc(24); | ||
| await file.read(header, 0, header.length, 0); | ||
| assert.equal( | ||
| header.subarray(0, 8).toString('hex'), | ||
| '89504e470d0a1a0a', | ||
| `${where} is not a PNG`, | ||
| ); | ||
| assert.equal(header.readUInt32BE(16), 1024, `${where} is not 1024px wide`); | ||
| assert.equal(header.readUInt32BE(20), 1024, `${where} is not 1024px tall`); | ||
| } finally { | ||
| await file.close(); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| test('a packaged build reads artwork from the copy beside the app', () => { | ||
| // `files` in the builder config does not carry `assets/`, so a packaged app | ||
| // has no repo tree to resolve against; the artwork rides along as an extra | ||
| // resource instead. Resolving the dev path there would hand `setIcon` an | ||
| // empty image and blank the dock tile without raising anything. | ||
| assert.equal( | ||
| resolveAppIconPath( | ||
| desktopAssetRoot({ isPackaged: true, resourcesPath: join('/Apps', 'Maka.app', 'Contents', 'Resources') }), | ||
| 'sky', | ||
| ), | ||
| join('/Apps', 'Maka.app', 'Contents', 'Resources', 'assets', 'app-icons', 'sky.png'), | ||
| ); | ||
| }); | ||
|
|
||
| test('the default keeps its long-standing path while variants live in their own directory', () => { | ||
| assert.deepEqual(appIconAssetSegments('default'), ['assets', 'icon.png']); | ||
| assert.deepEqual(appIconAssetSegments('mono'), ['assets', 'app-icons', 'mono.png']); | ||
| assert.equal( | ||
| resolveAppIconPath(join('/tmp', 'desktop'), 'mono'), | ||
| join('/tmp', 'desktop', 'assets', 'app-icons', 'mono.png'), | ||
| ); | ||
| }); | ||
|
|
||
| test('a variant falls back to the brand mark, and the brand mark has nothing to fall back to', () => { | ||
| // A build that lost assets/app-icons/ — a packaging filter, a half-applied | ||
| // update — should land on the brand mark rather than on the OS placeholder. | ||
| assert.deepEqual(appIconLoadOrder('mono'), ['mono', 'default']); | ||
| // No self-referential retry: if the brand mark itself is unreadable there is | ||
| // nothing left to try, and looping over it twice would only hide that. | ||
| assert.deepEqual(appIconLoadOrder('default'), ['default']); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
71 changes: 71 additions & 0 deletions
71
apps/desktop/src/main/__tests__/custom-app-icon-store.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import { mkdtemp, mkdir, readdir, writeFile } from 'node:fs/promises'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { test } from 'node:test'; | ||
| import { | ||
| customAppIconDirectory, | ||
| listCustomAppIconIds, | ||
| removeCustomAppIcon, | ||
| resolveCustomAppIconPath, | ||
| } from '../custom-app-icon-store.js'; | ||
|
|
||
| const ID = 'a'.repeat(32); | ||
|
|
||
| async function scratch(): Promise<string> { | ||
| return mkdtemp(join(tmpdir(), 'maka-icon-')); | ||
| } | ||
|
|
||
| test('an imported id resolves inside the directory the app owns', () => { | ||
| assert.equal( | ||
| resolveCustomAppIconPath('/user-data', ID), | ||
| join('/user-data', 'app-icons', `${ID}.png`), | ||
| ); | ||
| }); | ||
|
|
||
| /** | ||
| * The id IS the file name, so this is the boundary that decides whether a | ||
| * settings file can name a path. Core normalizes the same shape, but a second | ||
| * gate here is what makes the store safe to call from anywhere. | ||
| */ | ||
| test('an id that is not 32 hex characters never reaches the filesystem', () => { | ||
| for (const bad of [ | ||
| '../../../etc/passwd', | ||
| `..${'a'.repeat(30)}`, | ||
| 'A'.repeat(32), // uppercase is outside the generated alphabet | ||
| 'a'.repeat(31), | ||
| 'a'.repeat(33), | ||
| '', | ||
| 'a/b', | ||
| ]) { | ||
| assert.throws(() => resolveCustomAppIconPath('/user-data', bad), /custom icon id/); | ||
| } | ||
| }); | ||
|
|
||
| test('listing reports imported ids and ignores everything else in the directory', async () => { | ||
| const root = await scratch(); | ||
| const dir = customAppIconDirectory(root); | ||
| await mkdir(dir, { recursive: true }); | ||
| const other = 'b'.repeat(32); | ||
| await writeFile(join(dir, `${ID}.png`), 'x'); | ||
| await writeFile(join(dir, `${other}.png`), 'x'); | ||
| // Neither of these is artwork this store wrote, so neither may be offered. | ||
| await writeFile(join(dir, 'notes.txt'), 'x'); | ||
| await writeFile(join(dir, 'not-an-id.png'), 'x'); | ||
|
|
||
| assert.deepEqual(await listCustomAppIconIds(root), [ID, other].sort()); | ||
| }); | ||
|
|
||
| test('listing an app that never imported anything is empty, not an error', async () => { | ||
| assert.deepEqual(await listCustomAppIconIds(await scratch()), []); | ||
| }); | ||
|
|
||
| test('removing is idempotent, so a double click cannot fail the second time', async () => { | ||
| const root = await scratch(); | ||
| await mkdir(customAppIconDirectory(root), { recursive: true }); | ||
| await writeFile(resolveCustomAppIconPath(root, ID), 'x'); | ||
|
|
||
| await removeCustomAppIcon({ id: ID, userDataPath: root }); | ||
| await removeCustomAppIcon({ id: ID, userDataPath: root }); | ||
| assert.deepEqual(await readdir(customAppIconDirectory(root)), []); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import { join } from 'node:path'; | ||
| import { test } from 'node:test'; | ||
| import { desktopAssetPath, desktopAssetRoot } from '../desktop-assets.js'; | ||
|
|
||
| test('a packaged build reads assets from the copy beside the app', () => { | ||
| const resourcesPath = join('/Applications', 'Maka.app', 'Contents', 'Resources'); | ||
| assert.equal(desktopAssetRoot({ isPackaged: true, resourcesPath }), resourcesPath); | ||
| assert.equal( | ||
| desktopAssetPath({ isPackaged: true, resourcesPath }, 'assets', 'icon.png'), | ||
| join(resourcesPath, 'assets', 'icon.png'), | ||
| ); | ||
| }); | ||
|
|
||
| test('a dev run keeps resolving the repo layout, not the resources path', () => { | ||
| const root = desktopAssetRoot({ isPackaged: false, resourcesPath: '/unused' }); | ||
| assert.ok(root.endsWith(join('apps', 'desktop')), `${root} should point at apps/desktop`); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| import { app, BrowserWindow, nativeImage } from 'electron'; | ||
| import { join } from 'node:path'; | ||
| import { | ||
| APP_ICONS, | ||
| CUSTOM_APP_ICON_PREFIX, | ||
| customAppIconId, | ||
| type AppIcon, | ||
| type AppIconChoice, | ||
| } from '@maka/core/settings'; | ||
| import { | ||
| customAppIconDirectory, | ||
| listCustomAppIconIds, | ||
| resolveCustomAppIconPath, | ||
| } from './custom-app-icon-store.js'; | ||
| import { appIconLoadOrder, resolveAppIconPath } from './app-icon.js'; | ||
| import { desktopAssetRoot } from './desktop-assets.js'; | ||
|
|
||
| /** | ||
| * One choice's artwork path — shipped art under the asset root, imported art | ||
| * under the directory the app owns. Never throws: a malformed id can only come | ||
| * from a settings file that dodged normalization, and a window being created | ||
| * is no place to raise. The brand mark is the answer to every such question. | ||
| */ | ||
| export function appIconPath(choice: AppIconChoice): string { | ||
| const custom = customAppIconId(choice); | ||
| if (custom === undefined) return resolveAppIconPath(currentAssetRoot(), choice as AppIcon); | ||
| try { | ||
| return resolveCustomAppIconPath(app.getPath('userData'), custom); | ||
| } catch { | ||
| return resolveAppIconPath(currentAssetRoot(), 'default'); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Where this process reads icon artwork from. Exported so the window `icon` | ||
| * option resolves the same root the dock does — one of them guessing wrong | ||
| * would ship a build whose windows and dock disagree. | ||
| */ | ||
| export function currentAssetRoot(): string { | ||
| return desktopAssetRoot({ isPackaged: app.isPackaged, resourcesPath: process.resourcesPath }); | ||
| } | ||
|
|
||
| /** Edge length of the picker thumbnails handed to the renderer. */ | ||
| const PREVIEW_SIZE = 128; | ||
|
|
||
| export interface AppIconPreview { | ||
| readonly id: AppIconChoice; | ||
| /** Imported art can be deleted; the shipped set cannot. */ | ||
| readonly removable?: boolean; | ||
| /** PNG data URL, sized for the Settings picker tile. */ | ||
| readonly dataUrl: string; | ||
| } | ||
|
|
||
| let shippedPreviews: readonly AppIconPreview[] | undefined; | ||
|
|
||
| /** | ||
| * Point the OS at one of the shipped icons. | ||
| * | ||
| * macOS draws one tile for the whole app, so the dock owns the icon there and | ||
| * per-window icons are ignored. Windows and Linux draw it per window instead, | ||
| * which is why every open window is updated: the `icon` option in | ||
| * `createWindow` only covers windows opened *after* the choice was persisted. | ||
| */ | ||
| export function applyAppIcon(icon: AppIconChoice, onIconError: (error: unknown) => void): void { | ||
| try { | ||
| const image = loadAppIcon(icon); | ||
| if (!image) { | ||
| onIconError(new Error(`no readable artwork for app icon "${icon}"`)); | ||
| return; | ||
| } | ||
| if (app.dock) { | ||
| app.dock.setIcon(image); | ||
| return; | ||
| } | ||
| for (const window of BrowserWindow.getAllWindows()) window.setIcon(image); | ||
| } catch (error) { | ||
| onIconError(error); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Thumbnails for the Settings picker. The renderer never learns a path — it | ||
| * asks for the set and gets ids plus artwork — so the icon files stay outside | ||
| * the renderer bundle (they are 1024px masters) and outside its reach. | ||
| * | ||
| * Computed once: the artwork ships with the build and cannot change while the | ||
| * app runs, and decoding a 1024px PNG per picker visit is pure waste. | ||
| */ | ||
| export async function listAppIconPreviews(): Promise<readonly AppIconPreview[]> { | ||
| if (!shippedPreviews) { | ||
| const built: AppIconPreview[] = []; | ||
| for (const id of APP_ICONS) { | ||
| const image = loadAppIcon(id); | ||
| if (image) built.push({ id, dataUrl: thumbnail(image) }); | ||
| } | ||
| shippedPreviews = built; | ||
| } | ||
|
|
||
| // Imported art is read fresh: unlike the shipped set it changes while the | ||
| // app runs, and it is NOT loaded through the fallback chain — art that has | ||
| // gone missing must drop out of the picker rather than list a second copy | ||
| // of the brand mark under someone's imported id. | ||
| const imported: AppIconPreview[] = []; | ||
| for (const id of await listCustomAppIconIds(app.getPath('userData'))) { | ||
| const image = nativeImage.createFromPath( | ||
| join(customAppIconDirectory(app.getPath('userData')), `${id}.png`), | ||
| ); | ||
| if (image.isEmpty()) continue; | ||
| imported.push({ | ||
| id: `${CUSTOM_APP_ICON_PREFIX}${id}` as AppIconChoice, | ||
| dataUrl: thumbnail(image), | ||
| removable: true, | ||
| }); | ||
| } | ||
| return [...shippedPreviews, ...imported]; | ||
| } | ||
|
|
||
| function thumbnail(image: Electron.NativeImage): string { | ||
| return image | ||
| .resize({ width: PREVIEW_SIZE, height: PREVIEW_SIZE, quality: 'better' }) | ||
| .toDataURL(); | ||
| } | ||
|
|
||
| /** | ||
| * `nativeImage.createFromPath` reports a missing or undecodable file as an | ||
| * EMPTY image rather than throwing, and handing an empty image to `setIcon` | ||
| * blanks the dock tile instead of leaving the previous one alone. So emptiness | ||
| * is the read failure, and it is what advances the fallback chain. | ||
| */ | ||
| function loadAppIcon(icon: AppIconChoice): Electron.NativeImage | undefined { | ||
| for (const candidate of appIconLoadOrder(icon)) { | ||
| const image = nativeImage.createFromPath(appIconPath(candidate)); | ||
| if (!image.isEmpty()) return image; | ||
| } | ||
| return undefined; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { join } from 'node:path'; | ||
| import type { AppIcon, AppIconChoice } from '@maka/core/settings'; | ||
|
|
||
| /** | ||
| * Where one icon choice's artwork lives, relative to `apps/desktop`. | ||
| * | ||
| * `default` deliberately keeps pointing at the long-standing | ||
| * `assets/icon.png` instead of moving under `assets/app-icons/`: that path is | ||
| * also what the packaging config and the window `icon` option name, so moving | ||
| * it to make the set look tidy would be a rename with no product value. | ||
| */ | ||
| export function appIconAssetSegments(icon: AppIcon): readonly string[] { | ||
| return icon === 'default' ? ['assets', 'icon.png'] : ['assets', 'app-icons', `${icon}.png`]; | ||
| } | ||
|
|
||
| export function resolveAppIconPath(desktopRoot: string, icon: AppIcon): string { | ||
| return join(desktopRoot, ...appIconAssetSegments(icon)); | ||
| } | ||
|
|
||
| /** | ||
| * Which artwork to try, in order, for one choice. A build whose optional | ||
| * artwork is missing — a packaging filter that dropped `assets/app-icons/`, | ||
| * a partially applied update — falls back to the brand mark rather than to | ||
| * the OS placeholder, which on macOS is the generic Electron rocket. | ||
| */ | ||
| export function appIconLoadOrder(icon: AppIconChoice): readonly AppIconChoice[] { | ||
| return icon === 'default' ? ['default'] : [icon, 'default']; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P1] Reject non-catalog values before constructing a shipped asset path.
AppIconChoiceis only a compile-time type:settings:client:updateaccepts renderer input at runtime,clientOwnedSettingsPatch()forwardsappearanceunchanged, andSettingsStore.update()merges without normalization. Thereforeappearance.appIcon = "../../../../tmp/owned"reaches this cast and resolves outside the asset root (/tmp/owned.pngin a direct reproduction), after which Electron's native decoder reads it. UseisAppIcon(choice)here (falling back or rejecting) and normalize/validate the generic settings update boundary as defense in depth; add a test that sends a malformed runtime settings patch.