diff --git a/apps/desktop/assets/app-icons/alpine.png b/apps/desktop/assets/app-icons/alpine.png new file mode 100644 index 0000000000..2eafc515f4 Binary files /dev/null and b/apps/desktop/assets/app-icons/alpine.png differ diff --git a/apps/desktop/assets/app-icons/cyan.png b/apps/desktop/assets/app-icons/cyan.png new file mode 100644 index 0000000000..8344a4f153 Binary files /dev/null and b/apps/desktop/assets/app-icons/cyan.png differ diff --git a/apps/desktop/assets/app-icons/dusk.png b/apps/desktop/assets/app-icons/dusk.png new file mode 100644 index 0000000000..81dd755457 Binary files /dev/null and b/apps/desktop/assets/app-icons/dusk.png differ diff --git a/apps/desktop/assets/app-icons/forest.png b/apps/desktop/assets/app-icons/forest.png new file mode 100644 index 0000000000..b3d216d3f2 Binary files /dev/null and b/apps/desktop/assets/app-icons/forest.png differ diff --git a/apps/desktop/assets/app-icons/graphite.png b/apps/desktop/assets/app-icons/graphite.png new file mode 100644 index 0000000000..c022e83b47 Binary files /dev/null and b/apps/desktop/assets/app-icons/graphite.png differ diff --git a/apps/desktop/assets/app-icons/ice.png b/apps/desktop/assets/app-icons/ice.png new file mode 100644 index 0000000000..2b99cc6c87 Binary files /dev/null and b/apps/desktop/assets/app-icons/ice.png differ diff --git a/apps/desktop/assets/app-icons/ink.png b/apps/desktop/assets/app-icons/ink.png new file mode 100644 index 0000000000..c79306e3a4 Binary files /dev/null and b/apps/desktop/assets/app-icons/ink.png differ diff --git a/apps/desktop/assets/app-icons/mono.png b/apps/desktop/assets/app-icons/mono.png new file mode 100644 index 0000000000..db8ca96f34 Binary files /dev/null and b/apps/desktop/assets/app-icons/mono.png differ diff --git a/apps/desktop/assets/app-icons/night.png b/apps/desktop/assets/app-icons/night.png new file mode 100644 index 0000000000..8e822a7355 Binary files /dev/null and b/apps/desktop/assets/app-icons/night.png differ diff --git a/apps/desktop/assets/app-icons/pale-inverted.png b/apps/desktop/assets/app-icons/pale-inverted.png new file mode 100644 index 0000000000..bac5ab882c Binary files /dev/null and b/apps/desktop/assets/app-icons/pale-inverted.png differ diff --git a/apps/desktop/assets/app-icons/paper.png b/apps/desktop/assets/app-icons/paper.png new file mode 100644 index 0000000000..c09f4e6038 Binary files /dev/null and b/apps/desktop/assets/app-icons/paper.png differ diff --git a/apps/desktop/assets/app-icons/pencil-kraft.png b/apps/desktop/assets/app-icons/pencil-kraft.png new file mode 100644 index 0000000000..f0c84c4949 Binary files /dev/null and b/apps/desktop/assets/app-icons/pencil-kraft.png differ diff --git a/apps/desktop/assets/app-icons/pencil-navy.png b/apps/desktop/assets/app-icons/pencil-navy.png new file mode 100644 index 0000000000..c3af8e7450 Binary files /dev/null and b/apps/desktop/assets/app-icons/pencil-navy.png differ diff --git a/apps/desktop/assets/app-icons/pencil-sky.png b/apps/desktop/assets/app-icons/pencil-sky.png new file mode 100644 index 0000000000..ec1faa9bd3 Binary files /dev/null and b/apps/desktop/assets/app-icons/pencil-sky.png differ diff --git a/apps/desktop/assets/app-icons/sky.png b/apps/desktop/assets/app-icons/sky.png new file mode 100644 index 0000000000..7e3ee3dd24 Binary files /dev/null and b/apps/desktop/assets/app-icons/sky.png differ diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 24a451e100..03436ca5ff 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -59,6 +59,14 @@ export default { from: 'bundled-tools.json', to: 'bundled-tools.json', }, + { + // The app icon is read at runtime by the BrowserWindow `icon` option, and + // `files` above does not carry `assets/`. Electron reports the missing + // file as an empty image rather than an error, so without this the + // packaged app just draws no window icon. + from: 'assets', + to: 'assets', + }, { // Menu bar status item art. Without this the packaged app resolves an // empty NativeImage and Electron silently shows no icon at all. diff --git a/apps/desktop/src/main/__tests__/app-icon.test.ts b/apps/desktop/src/main/__tests__/app-icon.test.ts new file mode 100644 index 0000000000..d8d8b731c4 --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-icon.test.ts @@ -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']); +}); diff --git a/apps/desktop/src/main/__tests__/client-settings-effects.test.ts b/apps/desktop/src/main/__tests__/client-settings-effects.test.ts index 09f63d2a9f..9201f7528f 100644 --- a/apps/desktop/src/main/__tests__/client-settings-effects.test.ts +++ b/apps/desktop/src/main/__tests__/client-settings-effects.test.ts @@ -8,6 +8,7 @@ test('applies each client settings snapshot once across local writes and file wa const keepAwake: boolean[] = []; let botApplications = 0; let rendererEvents = 0; + const appIcons: string[] = []; const effects = createClientSettingsEffects({ settingsStore: { get: async () => current }, applyKeepSystemAwake: async (enabled) => { @@ -16,6 +17,9 @@ test('applies each client settings snapshot once across local writes and file wa applyBotSettings: async () => { botApplications += 1; }, + applyAppIcon: async (icon) => { + appIcons.push(icon); + }, observeLocale: () => undefined, emitExternalChanged: () => { rendererEvents += 1; @@ -35,4 +39,33 @@ test('applies each client settings snapshot once across local writes and file wa assert.deepEqual(keepAwake, [false, true]); assert.equal(botApplications, 1); assert.equal(rendererEvents, 1); + // The shipped default is already on screen before the first snapshot is + // read, so a run that never leaves it must not touch the OS icon at all. + assert.deepEqual(appIcons, []); +}); + +test('applies a chosen app icon once, and again only when the choice changes', async () => { + let current = createDefaultSettings(); + const appIcons: string[] = []; + const effects = createClientSettingsEffects({ + settingsStore: { get: async () => current }, + applyKeepSystemAwake: async () => undefined, + applyBotSettings: async () => undefined, + applyAppIcon: async (icon) => { + appIcons.push(icon); + }, + observeLocale: () => undefined, + emitExternalChanged: () => undefined, + }); + + await effects.refresh(false); + current = { ...current, appearance: { ...current.appearance, appIcon: 'mono' } }; + assert.equal(await effects.apply(current, false), true); + // The file watcher echoes the same write back; the OS call must not repeat. + assert.equal(await effects.refresh(false), false); + + current = { ...current, appearance: { ...current.appearance, appIcon: 'default' } }; + assert.equal(await effects.apply(current, false), true); + + assert.deepEqual(appIcons, ['mono', 'default']); }); diff --git a/apps/desktop/src/main/__tests__/custom-app-icon-store.test.ts b/apps/desktop/src/main/__tests__/custom-app-icon-store.test.ts new file mode 100644 index 0000000000..6d8fd9d795 --- /dev/null +++ b/apps/desktop/src/main/__tests__/custom-app-icon-store.test.ts @@ -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 { + 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)), []); +}); diff --git a/apps/desktop/src/main/__tests__/desktop-assets.test.ts b/apps/desktop/src/main/__tests__/desktop-assets.test.ts new file mode 100644 index 0000000000..ab34bd43ee --- /dev/null +++ b/apps/desktop/src/main/__tests__/desktop-assets.test.ts @@ -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`); +}); diff --git a/apps/desktop/src/main/app-icon-surface.ts b/apps/desktop/src/main/app-icon-surface.ts new file mode 100644 index 0000000000..a0f99d3101 --- /dev/null +++ b/apps/desktop/src/main/app-icon-surface.ts @@ -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 { + 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; +} diff --git a/apps/desktop/src/main/app-icon.ts b/apps/desktop/src/main/app-icon.ts new file mode 100644 index 0000000000..09ccabf8ff --- /dev/null +++ b/apps/desktop/src/main/app-icon.ts @@ -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']; +} diff --git a/apps/desktop/src/main/app-ipc-main.ts b/apps/desktop/src/main/app-ipc-main.ts index 93a696cee7..c2c71f6254 100644 --- a/apps/desktop/src/main/app-ipc-main.ts +++ b/apps/desktop/src/main/app-ipc-main.ts @@ -2,6 +2,14 @@ import { join } from 'node:path'; import { arch as osArch, homedir, release as osRelease } from 'node:os'; import { app, ipcMain, shell } from 'electron'; import { resolveOperationalStateDatabasePath } from '@maka/storage'; +import { listAppIconPreviews, type AppIconPreview } from './app-icon-surface.js'; +import { importCustomAppIcon } from './custom-app-icons.js'; +import { + CustomAppIconError, + removeCustomAppIcon, + type CustomAppIconImportResult, +} from './custom-app-icon-store.js'; +import { customAppIconId, isAppIconChoice } from '@maka/core/settings'; import { resolveProjectGitInfo } from '@maka/runtime/system-prompt/project-context'; import type { createMainWindowController } from './main-window.js'; import type { ProjectRootController } from './project-root-controller.js'; @@ -57,6 +65,40 @@ export function registerAppClientIpc( targetIpc.handle('window:setTitleBarOverlayTheme', (event, theme: unknown): void => { mainWindowController.setTitleBarOverlayTheme(event.sender, theme); }); + // The picker asks for the whole set at once; there is no per-id request, so + // no id from the renderer ever reaches the filesystem. + targetIpc.handle('app:iconPreviews', (): Promise => + listAppIconPreviews(), + ); + // The picker hands back a choice it was given, never a path: the dialog runs + // here and the file it returns is the only path this ever sees. + targetIpc.handle('app:importIcon', async (): Promise => { + const picked = await mainWindowController.showOpenDialog({ + properties: ['openFile'], + filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'tiff', 'webp'] }], + }); + const sourcePath = picked.canceled ? undefined : picked.filePaths[0]; + if (!sourcePath) return { ok: false, reason: 'cancelled' }; + try { + return { + ok: true, + icon: await importCustomAppIcon({ sourcePath, userDataPath: app.getPath('userData') }), + }; + } catch (error) { + return { + ok: false, + reason: error instanceof CustomAppIconError ? error.reason : 'unreadable', + }; + } + }); + targetIpc.handle('app:removeIcon', async (_event, icon: unknown): Promise => { + if (!isAppIconChoice(icon)) return false; + const id = customAppIconId(icon); + // The shipped set is not the user's to delete. + if (!id) return false; + await removeCustomAppIcon({ id, userDataPath: app.getPath('userData') }); + return true; + }); targetIpc.handle('app:updateStatus', (): AppUpdateStatus => updateService.getStatus()); targetIpc.handle('app:checkForUpdates', () => updateService.checkForUpdatesNow()); targetIpc.handle('app:retryUpdateDownload', () => updateService.retryUpdateDownload()); diff --git a/apps/desktop/src/main/client-settings-effects.ts b/apps/desktop/src/main/client-settings-effects.ts index 93ecfc1d1c..fb993458d7 100644 --- a/apps/desktop/src/main/client-settings-effects.ts +++ b/apps/desktop/src/main/client-settings-effects.ts @@ -1,4 +1,4 @@ -import type { AppSettings } from '@maka/core/settings'; +import type { AppIconChoice, AppSettings } from '@maka/core/settings'; import type { SettingsStore } from '@maka/storage'; export interface ClientSettingsEffects { @@ -10,6 +10,7 @@ interface ClientSettingsEffectDependencies { readonly settingsStore: Pick; readonly applyKeepSystemAwake: (enabled: boolean) => Promise; readonly applyBotSettings: (settings: AppSettings['botChat']) => Promise; + readonly applyAppIcon: (icon: AppIconChoice) => Promise; readonly observeLocale: (settings: AppSettings) => void; readonly emitExternalChanged: () => void; } @@ -20,6 +21,11 @@ export function createClientSettingsEffects( let rendererFingerprint: string | undefined; let botFingerprint: string | undefined; let keepSystemAwake: boolean | undefined; + // Seeded rather than left undefined: the shipped default is already on + // screen before the first snapshot arrives — the dock gets it synchronously + // at startup and a new window gets it from its `icon` option — so treating + // "default" as unapplied would cost a 1024px PNG decode on every launch. + let appIcon: AppIconChoice = 'default'; let tail = Promise.resolve(); const schedule = ( @@ -33,6 +39,11 @@ export function createClientSettingsEffects( const rendererChanged = nextRendererFingerprint !== rendererFingerprint; const keepAwakeChanged = settings.system.keepSystemAwake !== keepSystemAwake; const botChanged = nextBotFingerprint !== botFingerprint; + // Normalized settings always carry an id; the fallback covers a + // snapshot handed straight to apply() by a caller that built it from a + // partial patch rather than from a store read. + const nextAppIcon = settings.appearance.appIcon ?? 'default'; + const appIconChanged = nextAppIcon !== appIcon; dependencies.observeLocale(settings); if (keepAwakeChanged) { await dependencies.applyKeepSystemAwake(settings.system.keepSystemAwake); @@ -42,9 +53,13 @@ export function createClientSettingsEffects( await dependencies.applyBotSettings(settings.botChat); botFingerprint = nextBotFingerprint; } + if (appIconChanged) { + await dependencies.applyAppIcon(nextAppIcon); + appIcon = nextAppIcon; + } rendererFingerprint = nextRendererFingerprint; if (notifyRenderer && rendererChanged) dependencies.emitExternalChanged(); - return rendererChanged || keepAwakeChanged || botChanged; + return rendererChanged || keepAwakeChanged || botChanged || appIconChanged; }); tail = run.then( () => undefined, diff --git a/apps/desktop/src/main/custom-app-icon-store.ts b/apps/desktop/src/main/custom-app-icon-store.ts new file mode 100644 index 0000000000..897ea1996d --- /dev/null +++ b/apps/desktop/src/main/custom-app-icon-store.ts @@ -0,0 +1,68 @@ +import { readdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { CustomAppIcon } from '@maka/core/settings'; + +/** Edge length every imported icon is normalized to before it is stored. */ +export const CUSTOM_ICON_EDGE = 1024; + +/** Below this the art has nothing to say at 1024 and reads as a mistake. */ +export const CUSTOM_ICON_MIN_EDGE = 128; + +/** A decode cap, not a quality bar: 16 MB is far past any real icon. */ +export const CUSTOM_ICON_MAX_INPUT_BYTES = 16 * 1024 * 1024; + +const ID_PATTERN = /^[0-9a-f]{32}$/; + +export type CustomAppIconImportReason = + | 'cancelled' + | 'too_large' + | 'unreadable' + | 'too_small' + | 'write_failed'; + +export type CustomAppIconImportResult = + | { readonly ok: true; readonly icon: CustomAppIcon } + | { readonly ok: false; readonly reason: CustomAppIconImportReason }; + +export class CustomAppIconError extends Error { + readonly reason: Exclude; + + constructor(reason: Exclude, message: string) { + super(message); + this.name = 'CustomAppIconError'; + this.reason = reason; + } +} + +/** Where imported artwork lives. Owned by the app, never named by the renderer. */ +export function customAppIconDirectory(userDataPath: string): string { + return join(userDataPath, 'app-icons'); +} + +/** + * The id is the entire file name, so an id that is not 32 hex characters must + * never reach `join`: `..` would walk straight out of the directory the app + * owns. Core validates the same shape when it normalizes the setting; this is + * the second gate, at the boundary that actually touches the disk. + */ +export function resolveCustomAppIconPath(userDataPath: string, id: string): string { + if (!ID_PATTERN.test(id)) throw new CustomAppIconError('unreadable', `bad custom icon id`); + return join(customAppIconDirectory(userDataPath), `${id}.png`); +} + +/** Ids of every imported icon, oldest first, skipping anything unrecognised. */ +export async function listCustomAppIconIds(userDataPath: string): Promise { + const entries = await readdir(customAppIconDirectory(userDataPath)).catch(() => []); + return entries + .filter((name) => name.endsWith('.png') && ID_PATTERN.test(name.slice(0, -4))) + .map((name) => name.slice(0, -4)) + .sort(); +} + +/** Deleting art that is not there is success: the caller wanted it gone. */ +export async function removeCustomAppIcon(input: { + readonly id: string; + readonly userDataPath: string; +}): Promise { + await rm(resolveCustomAppIconPath(input.userDataPath, input.id), { force: true }); +} diff --git a/apps/desktop/src/main/custom-app-icons.ts b/apps/desktop/src/main/custom-app-icons.ts new file mode 100644 index 0000000000..df4c39d886 --- /dev/null +++ b/apps/desktop/src/main/custom-app-icons.ts @@ -0,0 +1,63 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, stat, writeFile } from 'node:fs/promises'; +import { nativeImage } from 'electron'; +import { CUSTOM_APP_ICON_PREFIX, type CustomAppIcon } from '@maka/core/settings'; +import { + CustomAppIconError, + CUSTOM_ICON_EDGE, + CUSTOM_ICON_MAX_INPUT_BYTES, + CUSTOM_ICON_MIN_EDGE, + customAppIconDirectory, + resolveCustomAppIconPath, +} from './custom-app-icon-store.js'; + +/** + * Decode, square, scale, store. Non-square art is centre-cropped rather than + * letterboxed: an icon that keeps its own transparent margin is the caller's + * business, and silently adding one would change art the user already framed. + */ +export async function importCustomAppIcon(input: { + readonly sourcePath: string; + readonly userDataPath: string; +}): Promise { + const info = await stat(input.sourcePath).catch(() => undefined); + if (!info?.isFile()) throw new CustomAppIconError('unreadable', 'not a file'); + if (info.size > CUSTOM_ICON_MAX_INPUT_BYTES) { + throw new CustomAppIconError('too_large', `over ${CUSTOM_ICON_MAX_INPUT_BYTES} bytes`); + } + + // An undecodable file comes back EMPTY rather than throwing — the same trap + // the shipped set falls into, and the reason emptiness is the failure test. + const source = nativeImage.createFromPath(input.sourcePath); + if (source.isEmpty()) throw new CustomAppIconError('unreadable', 'no decodable image'); + + const { width, height } = source.getSize(); + if (Math.min(width, height) < CUSTOM_ICON_MIN_EDGE) { + throw new CustomAppIconError('too_small', `${width}×${height} is under ${CUSTOM_ICON_MIN_EDGE}`); + } + + const edge = Math.min(width, height); + const squared = + width === height + ? source + : source.crop({ + x: Math.round((width - edge) / 2), + y: Math.round((height - edge) / 2), + width: edge, + height: edge, + }); + const png = squared + .resize({ width: CUSTOM_ICON_EDGE, height: CUSTOM_ICON_EDGE, quality: 'better' }) + .toPNG(); + if (png.length === 0) throw new CustomAppIconError('unreadable', 're-encode produced nothing'); + + const id = randomUUID().replaceAll('-', ''); + try { + await mkdir(customAppIconDirectory(input.userDataPath), { recursive: true }); + await writeFile(resolveCustomAppIconPath(input.userDataPath, id), png); + } catch (error) { + throw new CustomAppIconError('write_failed', `could not store the icon: ${String(error)}`); + } + return `${CUSTOM_APP_ICON_PREFIX}${id}` as CustomAppIcon; +} + diff --git a/apps/desktop/src/main/desktop-assets.ts b/apps/desktop/src/main/desktop-assets.ts new file mode 100644 index 0000000000..e3466a1a75 --- /dev/null +++ b/apps/desktop/src/main/desktop-assets.ts @@ -0,0 +1,28 @@ +import { join } from 'node:path'; + +/** + * Root that `apps/desktop/assets` hangs off at runtime. + * + * Dev resolves the repo layout: two levels up from the built main bundle in + * `dist/main/` lands on `apps/desktop`. A packaged app has no such tree — + * `files` in the builder config carries `dist/`, `dist-renderer/` and + * `package.json`, and nothing else — so the assets ride along as an extra + * resource and the same segments hang off `process.resourcesPath` instead. + * + * Resolving the dev path in a packaged build fails silently: Electron reports + * an unreadable file as an EMPTY NativeImage rather than as an error, and the + * BrowserWindow `icon` option simply draws nothing. + */ +export function desktopAssetRoot(runtime: { + readonly isPackaged: boolean; + readonly resourcesPath: string; +}): string { + return runtime.isPackaged ? runtime.resourcesPath : join(import.meta.dirname, '..', '..'); +} + +export function desktopAssetPath( + runtime: { readonly isPackaged: boolean; readonly resourcesPath: string }, + ...segments: readonly string[] +): string { + return join(desktopAssetRoot(runtime), ...segments); +} diff --git a/apps/desktop/src/main/desktop-shell-presentation.ts b/apps/desktop/src/main/desktop-shell-presentation.ts index 755606c8d3..dcb58b6232 100644 --- a/apps/desktop/src/main/desktop-shell-presentation.ts +++ b/apps/desktop/src/main/desktop-shell-presentation.ts @@ -1,5 +1,5 @@ -import { app, nativeImage } from 'electron'; -import { join } from 'node:path'; +import { app } from 'electron'; +import { applyAppIcon } from './app-icon-surface.js'; import { installApplicationMenu } from './application-menu.js'; import { resolveDockPresentation } from './dock-presentation.js'; import type { createMainWindowController } from './main-window.js'; @@ -23,18 +23,13 @@ export function installDesktopShellPresentation( if (dockPresentation === 'hide') { app.dock.hide(); } else if (dockPresentation === 'icon') { - try { - const iconPath = join( - import.meta.dirname, - '..', - '..', - 'assets', - 'icon.png', - ); - app.dock.setIcon(nativeImage.createFromPath(iconPath)); - } catch (error) { - deps.onIconError(error); - } + // The DEFAULT mark, synchronously, even when the user picked another + // one: reading the persisted choice means awaiting the settings store, + // and a dock that shows the generic Electron rocket until that resolves + // is the exact regression PR-GRAY-CARD-LIFT-0 fixed. The persisted + // choice lands a tick later, from the same client-settings effect that + // applies it when the user switches (see client-settings-effects.ts). + applyAppIcon('default', deps.onIconError); } } diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index b84a229671..c86c2100b9 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -3,6 +3,7 @@ import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import type { AppSettings } from '@maka/core/settings'; +import { appIconPath } from './app-icon-surface.js'; import { isExternalUrl } from './external-link-guard.js'; import { readSavedBounds, writeSavedBounds, SAFE_MIN_HEIGHT, SAFE_MIN_WIDTH, type SavedBounds } from './window-state.js'; import { BrowserViewController } from './browser/controller.js'; @@ -226,7 +227,8 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // pref. This guarantees the BrowserWindow backgroundColor matches the // theme variant we're about to screenshot, so the very first frame // doesn't capture a light-on-dark or dark-on-light flash. - const persistedTheme = (await settingsStore.get()).appearance?.theme ?? 'auto'; + const persistedAppearance = (await settingsStore.get()).appearance; + const persistedTheme = persistedAppearance?.theme ?? 'auto'; // Quit cleanup permanently closes process-scoped stores. Re-check after // asynchronous preparation so an in-flight request cannot attach a new // renderer to resources that teardown has already started closing. @@ -263,12 +265,16 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main ...(bounds.x !== undefined && bounds.y !== undefined ? { x: bounds.x, y: bounds.y } : {}), title: 'Maka', // PR-GRAY-CARD-LIFT-0 (WAWQAQ msg `0eb99429` 2026-06-20): the - // app icon ships as a 1024px PNG under apps/desktop/assets/icon.png. - // BrowserWindow accepts a PNG path directly on macOS for the dock - // / window title bar; .icns / .ico packaging will come with the - // installer build pass. The asset path resolves from the built - // dist/main/main.js (two levels up to apps/desktop, then assets). - icon: join(import.meta.dirname, '..', '..', 'assets', 'icon.png'), + // app icon ships as a 1024px PNG under apps/desktop/assets/. BrowserWindow + // accepts a PNG path directly on macOS for the dock / window title bar; + // .icns / .ico packaging will come with the installer build pass. The + // asset path resolves from the built dist/main/main.js (two levels up to + // apps/desktop, then assets). + // + // Windows and Linux draw this icon per window, so a window opened after + // the user switched icons must be born with the chosen one — waiting for + // the client-settings effect to catch up would show the default first. + icon: appIconPath(persistedAppearance?.appIcon ?? 'default'), // PR-WINDOW-TITLEBAR-0: hide the native title bar so the renderer // chrome can extend to the top edge on every platform. macOS keeps // `hiddenInset` + traffic-light buttons (top-left); Windows uses diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 76932aa736..b225da52fc 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -49,6 +49,7 @@ import { assembleDesktopNativeCapabilities } from "./desktop-native-capability-a import { clientSettingsConfirmation } from "./client-settings-confirmation-copy.js"; import { createDesktopLocaleAuthority } from "./desktop-locale-authority.js"; import { buildRiveWorkflowTool } from "./rive-workflow-tool.js"; +import { applyAppIcon } from "./app-icon-surface.js"; import { installDesktopShellPresentation } from "./desktop-shell-presentation.js"; import { resolveE2eFixture, @@ -426,6 +427,11 @@ const clientSettingsEffects = createClientSettingsEffects({ applyBotSettings: useBotOnboardingFixture ? async () => undefined : (settings) => botRegistry.applySettings(settings), + applyAppIcon: async (icon) => { + applyAppIcon(icon, (error) => + console.error("[icon] failed to apply the app icon:", error), + ); + }, observeLocale: (settings) => desktopLocale.observe(settings), emitExternalChanged: () => { mainWindowController.send("settings:clientChanged"); diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 70bdff7262..04dc5a73ad 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -8,6 +8,8 @@ import type { UpdateConnectionInput, } from '@maka/core/llm-connections'; import type { + AppIcon, + AppIconChoice, AppSettings, ChatDefaultsSettings, SettingsTestResult, @@ -95,6 +97,18 @@ import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; import type { TestProxyInput } from '@maka/core/settings/network-settings'; import type { ExternalSessionImportIpcResult } from './external-session-import-result.js'; import type { DesktopSessionSummary } from '../shared/desktop-session-projection.js'; +/** + * Outcome of importing artwork. `cancelled` is the user closing the dialog and + * is not an error; the rest name why the file could not become an icon, so the + * picker can say which rather than showing one generic failure. + */ +export type AppIconImportResult = + | { readonly ok: true; readonly icon: AppIconChoice } + | { + readonly ok: false; + readonly reason: 'cancelled' | 'too_large' | 'unreadable' | 'too_small' | 'write_failed'; + }; + export type { DesktopSessionSummary } from '../shared/desktop-session-projection.js'; import type { DesktopConnectionSnapshot } from '../shared/desktop-connection-snapshot.js'; import type { DesktopExternalSessionCatalogItem } from './external-session-catalog.js'; @@ -1074,6 +1088,18 @@ export interface MakaBridge { }; app: { info(host?: DesktopRuntimeHostRef): Promise; + /** + * Every selectable icon — the shipped set plus whatever the user imported + * — each with a thumbnail for the Settings picker. `removable` marks the + * imported ones; the shipped set is not the user's to delete. + */ + iconPreviews(): Promise< + ReadonlyArray<{ id: AppIconChoice; dataUrl: string; removable?: boolean }> + >; + /** Opens a file picker in the main process and stores a normalized copy. */ + importIcon(): Promise; + /** Deletes imported artwork. Shipped ids are refused. */ + removeIcon(icon: AppIconChoice): Promise; subscribeUpdateStatus(handler: (status: AppUpdateStatus) => void): () => void; updateStatus(): Promise; checkForUpdates(): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 18dba010bd..326a0d6688 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -33,6 +33,7 @@ import type { DesktopAppInfo, DesktopSessionTracePage, DesktopSessionUsageSummary, + AppIconImportResult, } from './bridge-contract.js'; import type { ExternalSessionImportIpcResult } from './external-session-import-result.js'; import { @@ -61,6 +62,8 @@ import type { UpdateConnectionInput, } from '@maka/core/llm-connections'; import type { + AppIcon, + AppIconChoice, AppSettings, SettingsTestResult, UpdateAppSettingsInput, @@ -2540,6 +2543,15 @@ const makaBridge = { info(host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'app:info'); }, + iconPreviews(): Promise> { + return ipcRenderer.invoke('app:iconPreviews'); + }, + importIcon(): Promise { + return ipcRenderer.invoke('app:importIcon'); + }, + removeIcon(icon: AppIconChoice): Promise { + return ipcRenderer.invoke('app:removeIcon', icon); + }, subscribeUpdateStatus(handler: (status: AppUpdateStatus) => void): () => void { const listener = (_event: Electron.IpcRendererEvent, status: AppUpdateStatus) => handler(status); ipcRenderer.on('app:updateStatusChanged', listener); diff --git a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts index 603aec2027..c676bd7ca7 100644 --- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts @@ -1,4 +1,4 @@ -import type { ThemePalette, ThemePreference } from '@maka/core/settings'; +import type { AppIcon, ThemePalette, ThemePreference } from '@maka/core/settings'; import type { UiCatalog, UiLocale, UiLocalePreference } from '@maka/core/ui-locale'; @@ -41,6 +41,8 @@ export type SettingsPreferencesCopy = { themeHelp: string; palette: string; paletteHelp: string; + appIcon: string; + appIconHelp: string; pets: string; petsHelp: string; }; @@ -52,6 +54,18 @@ export type SettingsPreferencesCopy = { paletteLabels: Record; paletteHelp: Record; paletteGroups: { editor: string; product: string }; + appIconLabels: Record; + appIconHelp: Record; + appIconGroups: Record<'mascot' | 'blue' | 'contrast' | 'pencil' | 'mountain' | 'custom', string>; + appIconCustom: string; + appIconCustomHelp: string; + appIconImport: string; + appIconImporting: string; + appIconImportHelp: string; + appIconRemove: string; + appIconImportError: string; + appIconImportFailed: Record<'too_large' | 'unreadable' | 'too_small' | 'write_failed', string>; + appIconUnavailable: string; }; pets: { import: string; @@ -212,6 +226,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { network: '网络', networkHelp: 'AI 模型请求走的网络通道。', theme: '主题', themeHelp: '界面跟随系统,还是固定浅色或深色。', palette: '调色板', paletteHelp: '强调色与画布色调;切换会立即生效并保存在本地。', + appIcon: '应用图标', appIconHelp: 'Dock、任务栏和切换器里显示的 Maka 图标;切换会立即生效。', pets: '自定义宠物', petsHelp: '管理你自己导入的 PetPack。Maka 不预装、也不默认启用任何宠物。', }, appearance: { @@ -220,6 +235,26 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { paletteLabels: { default: '默认', onedark: 'One Dark', 'catppuccin-mocha': 'Catppuccin Mocha', 'tokyo-night': 'Tokyo Night', nord: 'Nord', coral: '珊瑚', azure: '湖蓝', forest: '森林', dusk: '暮光', sand: '沙金', mono: '极简灰' }, paletteHelp: { default: 'Maka 品牌蓝强调色', onedark: '编辑器经典深色', 'catppuccin-mocha': '紫调柔和深色', 'tokyo-night': '深蓝主题', nord: '北欧冷色', coral: '暖粉 / 珊瑚强调色', azure: '湖蓝强调色,干净冷静', forest: '深苔绿与暖蜂蜜强调色', dusk: '深紫罗兰与冷调画布', sand: '琥珀沙金与暖奶白', mono: '纯灰阶,无彩色干扰' }, paletteGroups: { editor: '编辑器主题', product: '产品色调' }, + appIconLabels: { default: '经典', mono: '单色', 'sky': '原色天蓝', 'cyan': '青蓝', 'ice': '冰蓝渐变', 'pale-inverted': '淡底深标', 'ink': '墨黑', 'paper': '纸白', 'graphite': '石墨', 'pencil-kraft': '铅笔・牛皮纸', 'pencil-sky': '铅笔・天蓝', 'pencil-navy': '铅笔・深蓝', 'alpine': '晴空雪山', 'dusk': '黄昏', 'night': '夜山', 'forest': '苍绿' }, + appIconHelp: { default: 'Maka 默认品牌图标', mono: '灰阶版本,Dock 里更安静', 'sky': '几何 M 标,品牌蓝', 'cyan': '偏青的蓝', 'ice': '由浅到深的蓝色渐变', 'pale-inverted': '淡蓝底配深蓝标', 'ink': '黑底白标,对比最强', 'paper': '白底黑标', 'graphite': '白底黑标,笔尖为灰', 'pencil-kraft': '铅笔意象,牛皮纸底', 'pencil-sky': '铅笔意象,天蓝底', 'pencil-navy': '铅笔意象,深蓝底', 'alpine': '雪顶山峰,晴空底', 'dusk': '雪顶山峰,黄昏底', 'night': '雪顶山峰,夜色底', 'forest': '雪顶山峰,苍绿底' }, + appIconGroups: { + mascot: '拟人', blue: '蓝色系', contrast: '黑白', pencil: '铅笔', mountain: '高山', + custom: '自定义', + }, + appIconCustom: '导入的图标', + appIconCustomHelp: '你自己导入的图片', + appIconImport: '导入图标…', + appIconImporting: '正在导入…', + appIconImportHelp: '方形 PNG 最好;四周留约 10% 透明边,Dock 里才会和其它应用一样大。', + appIconRemove: '删除', + appIconImportError: '导入图标失败', + appIconImportFailed: { + too_large: '文件太大,换一张小一点的图片', + unreadable: '这个文件读不出图像', + too_small: '图片太小,至少需要 128×128', + write_failed: '无法保存导入的图标', + }, + appIconUnavailable: '无法载入应用图标', }, pets: { import: '导入 PetPack', importing: '正在导入…', loading: '正在载入自定义宠物…', @@ -268,10 +303,11 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { network: 'Network', networkHelp: 'The network path AI model requests take.', theme: 'Theme', themeHelp: 'Follow the system appearance, or stay on light or dark.', palette: 'Color palette', paletteHelp: 'Accent and canvas colors. Changes apply immediately and are saved locally.', + appIcon: 'App icon', appIconHelp: 'The Maka icon shown in the dock, taskbar, and app switcher. Changes apply immediately.', pets: 'Custom pets', petsHelp: 'Manage PetPacks you import yourself. Maka does not bundle or enable any pet by default.', }, appearance: { - saveFailed: 'Could not save appearance settings', theme: 'Theme', palette: 'Color palette', themeOptions: { light: { label: 'Light', help: 'Always use the light interface.' }, dark: { label: 'Dark', help: 'Always use the dark interface.' }, auto: { label: 'Follow system', help: 'Match the current system appearance.' } }, paletteLabels: { default: 'Default', onedark: 'One Dark', 'catppuccin-mocha': 'Catppuccin Mocha', 'tokyo-night': 'Tokyo Night', nord: 'Nord', coral: 'Coral', azure: 'Azure', forest: 'Forest', dusk: 'Dusk', sand: 'Sand', mono: 'Monochrome' }, paletteHelp: { default: 'Maka brand-blue accent', onedark: 'Classic dark editor theme', 'catppuccin-mocha': 'Soft purple dark theme', 'tokyo-night': 'Deep-blue editor theme', nord: 'Cool Nordic colors', coral: 'Warm pink and coral accent', azure: 'Clean, calm blue accent', forest: 'Deep moss and warm honey', dusk: 'Deep violet on a cool canvas', sand: 'Amber sand and warm ivory', mono: 'Pure grayscale without color distraction' }, paletteGroups: { editor: 'Editor themes', product: 'Product colors' }, + saveFailed: 'Could not save appearance settings', theme: 'Theme', palette: 'Color palette', themeOptions: { light: { label: 'Light', help: 'Always use the light interface.' }, dark: { label: 'Dark', help: 'Always use the dark interface.' }, auto: { label: 'Follow system', help: 'Match the current system appearance.' } }, paletteLabels: { default: 'Default', onedark: 'One Dark', 'catppuccin-mocha': 'Catppuccin Mocha', 'tokyo-night': 'Tokyo Night', nord: 'Nord', coral: 'Coral', azure: 'Azure', forest: 'Forest', dusk: 'Dusk', sand: 'Sand', mono: 'Monochrome' }, paletteHelp: { default: 'Maka brand-blue accent', onedark: 'Classic dark editor theme', 'catppuccin-mocha': 'Soft purple dark theme', 'tokyo-night': 'Deep-blue editor theme', nord: 'Cool Nordic colors', coral: 'Warm pink and coral accent', azure: 'Clean, calm blue accent', forest: 'Deep moss and warm honey', dusk: 'Deep violet on a cool canvas', sand: 'Amber sand and warm ivory', mono: 'Pure grayscale without color distraction' }, paletteGroups: { editor: 'Editor themes', product: 'Product colors' }, appIconLabels: { default: 'Classic', mono: 'Monochrome', 'sky': 'Sky', 'cyan': 'Cyan', 'ice': 'Ice', 'pale-inverted': 'Inverted', 'ink': 'Ink', 'paper': 'Paper', 'graphite': 'Graphite', 'pencil-kraft': 'Pencil, kraft', 'pencil-sky': 'Pencil, sky', 'pencil-navy': 'Pencil, navy', 'alpine': 'Alpine', 'dusk': 'Dusk', 'night': 'Night', 'forest': 'Forest' }, appIconHelp: { default: 'The default Maka mark', mono: 'Grayscale, for a quieter dock', 'sky': 'The geometric M mark in brand blue', 'cyan': 'Blue leaning to cyan', 'ice': 'A pale-to-deep blue gradient', 'pale-inverted': 'A deep blue mark on a pale field', 'ink': 'White on black, the highest contrast', 'paper': 'Black on white', 'graphite': 'Black on white with a grey tip', 'pencil-kraft': 'The pencil reading, on kraft paper', 'pencil-sky': 'The pencil reading, on sky blue', 'pencil-navy': 'The pencil reading, on deep navy', 'alpine': 'A snow-capped peak under clear sky', 'dusk': 'A snow-capped peak at dusk', 'night': 'A snow-capped peak at night', 'forest': 'A snow-capped peak in green' }, appIconGroups: { mascot: 'Mascot', blue: 'Blues', contrast: 'Black & white', pencil: 'Pencil', mountain: 'Mountain', custom: 'Imported' }, appIconCustom: 'Imported icon', appIconCustomHelp: 'An image you imported', appIconImport: 'Import icon…', appIconImporting: 'Importing…', appIconImportHelp: 'A square PNG works best. Leave about 10% transparent margin so it sits the same size as other apps in the dock.', appIconRemove: 'Remove', appIconImportError: 'Could not import the icon', appIconImportFailed: { too_large: 'That file is too large; pick a smaller image', unreadable: 'No image could be read from that file', too_small: 'That image is too small; 128×128 is the minimum', write_failed: 'Could not store the imported icon' }, appIconUnavailable: 'Could not load the app icons', }, pets: { import: 'Import PetPack', importing: 'Importing…', loading: 'Loading custom pets…', diff --git a/apps/desktop/src/renderer/settings/appearance-settings-page.tsx b/apps/desktop/src/renderer/settings/appearance-settings-page.tsx index b2d3267a6d..e8531ef48c 100644 --- a/apps/desktop/src/renderer/settings/appearance-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/appearance-settings-page.tsx @@ -1,7 +1,14 @@ -import { useEffect, useRef } from 'react'; -import { Grid, HStack, SelectableCard, Text, VStack } from '@astryxdesign/core'; +import { useEffect, useRef, useState } from 'react'; +import { Button, Grid, HStack, SelectableCard, Text, VStack } from '@astryxdesign/core'; import { SettingsPage, SettingsSection } from './settings-section'; -import type { ThemePalette, ThemePreference, UpdateAppSettingsResult } from '@maka/core/settings'; +import { + isAppIcon, + type AppIcon, + type AppIconChoice, + type ThemePalette, + type ThemePreference, + type UpdateAppSettingsResult, +} from '@maka/core/settings'; import { useMountedRef, useToast, useUiLocale } from '@maka/ui'; import { settingsActionErrorMessage } from './settings-error-copy'; import { getSettingsPreferencesCopy } from '../locales/settings-preferences-copy.js'; @@ -58,6 +65,24 @@ function ThemePreviewPane(props: { mode: 'light' | 'dark' }) { * 产品色调. Order within each group is preserved for stable * keyboard navigation. */ +/** + * 19 shipped icons need grouping for the same reason 11 palettes did: an + * ungrouped wall gives the eye nowhere to start. The brand pair leads; + * everything after it is one drawing recoloured, split by what the colour is + * doing. Imported art is appended as its own group by the renderer, since the + * set is not known until the main process reads the directory. + */ +const APP_ICON_GROUPS: ReadonlyArray<{ + id: 'mascot' | 'blue' | 'contrast' | 'pencil' | 'mountain'; + icons: ReadonlyArray; +}> = [ + { id: 'mascot', icons: ['default', 'mono'] }, + { id: 'blue', icons: ['sky', 'cyan', 'ice', 'pale-inverted'] }, + { id: 'contrast', icons: ['ink', 'paper', 'graphite'] }, + { id: 'pencil', icons: ['pencil-kraft', 'pencil-sky', 'pencil-navy'] }, + { id: 'mountain', icons: ['alpine', 'dusk', 'night', 'forest'] }, +]; + const PALETTE_GROUPS: ReadonlyArray<{ id: 'editor' | 'product'; palettes: ReadonlyArray }> = [ { id: 'editor', palettes: ['default', 'onedark', 'catppuccin-mocha', 'tokyo-night', 'nord'] }, { id: 'product', palettes: ['coral', 'azure', 'forest', 'dusk', 'sand', 'mono'] }, @@ -70,12 +95,15 @@ const PALETTE_GROUPS: ReadonlyArray<{ id: 'editor' | 'product'; palettes: Readon // screen reader 14 loose option tiles with no statement of which set — 主题, // 编辑器主题, or 产品色调 — any one of them belongs to. const THEME_SECTION_HEADING_ID = 'settings-appearance-theme-heading'; +const APP_ICON_SECTION_HEADING_ID = 'settings-appearance-app-icon-heading'; const PALETTE_SECTION_HEADING_ID = 'settings-appearance-palette-heading'; const paletteGroupLabelId = (group: 'editor' | 'product') => `settings-appearance-palette-${group}-label`; +const appIconGroupLabelId = (group: string) => `settings-appearance-app-icon-${group}-label`; export function AppearanceSettingsPage(props: { themePref: ThemePreference; themePalette: ThemePalette; + appIcon: AppIconChoice; /* No `settings` prop: the page reads theme and palette from the two dedicated props above and writes through `onUpdate`. It used to accept the whole AppSettings object and pass it down one level, where nothing @@ -90,6 +118,70 @@ export function AppearanceSettingsPage(props: { const toast = useToast(); const themePageMountedRef = useMountedRef(); const themePersistTicketRef = useRef(0); + // The picker draws real artwork, so the option set arrives from the main + // process (ids plus thumbnails) rather than from a list held here: the icons + // are 1024px masters that only main can read, and the renderer is never + // handed a path. `undefined` is "still asking", not "none shipped". + const [appIconOptions, setAppIconOptions] = useState< + ReadonlyArray<{ id: AppIconChoice; dataUrl: string; removable?: boolean }> | undefined + >(undefined); + const [appIconLoadFailed, setAppIconLoadFailed] = useState(false); + const [appIconBusy, setAppIconBusy] = useState(false); + + async function refreshAppIcons() { + const options = await window.maka.app.iconPreviews().catch(() => undefined); + if (options) setAppIconOptions(options); + } + + async function importAppIcon() { + setAppIconBusy(true); + try { + const result = await window.maka.app.importIcon(); + if (!result.ok) { + // Closing the dialog is an answer, not a failure worth a toast. + if (result.reason !== 'cancelled') toast.error(copy.appIconImportFailed[result.reason]); + return; + } + await refreshAppIcons(); + await setAppIcon(result.icon); + } catch (error) { + // Reasons above describe the *file*; landing here instead means the call + // itself failed — a stale preload bundle with no `importIcon` on the + // bridge looks exactly like this — and calling that "unreadable image" + // would send the user off inspecting a file that was never the problem. + toast.error(copy.appIconImportError, settingsActionErrorMessage(error, locale)); + } finally { + setAppIconBusy(false); + } + } + + async function removeAppIcon(icon: AppIconChoice) { + setAppIconBusy(true); + try { + await window.maka.app.removeIcon(icon); + // Deleting the art under the current choice would leave the dock holding + // a file that no longer exists, so hand it back to the brand mark first. + if (props.appIcon === icon) await setAppIcon('default'); + await refreshAppIcons(); + } finally { + setAppIconBusy(false); + } + } + + useEffect(() => { + let cancelled = false; + void window.maka.app + .iconPreviews() + .then((options) => { + if (!cancelled) setAppIconOptions(options); + }) + .catch(() => { + if (!cancelled) setAppIconLoadFailed(true); + }); + return () => { + cancelled = true; + }; + }, []); useEffect(() => { return () => { @@ -123,6 +215,44 @@ export function AppearanceSettingsPage(props: { // the IPC round-trip would re-apply on its own, but main.tsx had no // listener for palette changes — only ran applyThemePalette once at // mount — so switches were invisible until the next app start. + // No optimistic local copy, unlike theme and palette above: those two paint + // the renderer, so a click has to show immediately. The app icon is an OS + // surface applied by the main process, and the tile follows the settings + // snapshot the write returns. + async function setAppIcon(next: AppIconChoice) { + await persistAppearance({ appIcon: next }); + } + + // Group membership is a renderer concern: the main process reports what + // artwork loaded, and the grouping is how the picker chooses to read it. + // Anything the main process reports that no group claims — imported art — + // falls into the trailing group rather than disappearing. + const appIconGroupsToRender = (() => { + const byId = new Map((appIconOptions ?? []).map((option) => [option.id, option])); + const claimed = new Set(); + const groups = APP_ICON_GROUPS.map((group) => { + const options = group.icons.flatMap((id) => { + const option = byId.get(id); + if (!option) return []; + claimed.add(id); + return [option]; + }); + return { id: group.id, options }; + }).filter((group) => group.options.length > 0); + const imported = (appIconOptions ?? []).filter((option) => !claimed.has(option.id)); + return imported.length > 0 + ? [...groups, { id: 'custom' as const, options: imported }] + : groups; + })(); + + function appIconLabel(id: AppIconChoice): string { + return isAppIcon(id) ? copy.appIconLabels[id] : copy.appIconCustom; + } + + function appIconHelpText(id: AppIconChoice): string { + return isAppIcon(id) ? copy.appIconHelp[id] : copy.appIconCustomHelp; + } + const currentPalette: ThemePalette = props.themePalette; async function setPalette(next: ThemePalette) { props.onThemePaletteChange(next); @@ -227,6 +357,81 @@ export function AppearanceSettingsPage(props: { ))} + + {appIconLoadFailed ? ( + {copy.appIconUnavailable} + ) : ( + + {appIconGroupsToRender.map((group) => ( + + + {copy.appIconGroups[group.id]} + + + {group.options.map((option) => ( + void setAppIcon(option.id)} + padding={2} + > + + {/* Decorative: the tile's own label already names the icon. */} + + + {appIconLabel(option.id)} + + {appIconHelpText(option.id)} + + + {option.removable ? ( +