Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added apps/desktop/assets/app-icons/alpine.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/assets/app-icons/cyan.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/assets/app-icons/dusk.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/assets/app-icons/forest.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/assets/app-icons/graphite.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/assets/app-icons/ice.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/assets/app-icons/ink.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/assets/app-icons/mono.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/assets/app-icons/night.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/assets/app-icons/pale-inverted.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/assets/app-icons/paper.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/assets/app-icons/pencil-kraft.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/assets/app-icons/pencil-navy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/assets/app-icons/pencil-sky.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/assets/app-icons/sky.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions apps/desktop/electron-builder.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
75 changes: 75 additions & 0 deletions apps/desktop/src/main/__tests__/app-icon.test.ts
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']);
});
33 changes: 33 additions & 0 deletions apps/desktop/src/main/__tests__/client-settings-effects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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;
Expand All @@ -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']);
});
71 changes: 71 additions & 0 deletions apps/desktop/src/main/__tests__/custom-app-icon-store.test.ts
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)), []);
});
18 changes: 18 additions & 0 deletions apps/desktop/src/main/__tests__/desktop-assets.test.ts
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`);
});
136 changes: 136 additions & 0 deletions apps/desktop/src/main/app-icon-surface.ts
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);

Copy link
Copy Markdown
Contributor

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. AppIconChoice is only a compile-time type: settings:client:update accepts renderer input at runtime, clientOwnedSettingsPatch() forwards appearance unchanged, and SettingsStore.update() merges without normalization. Therefore appearance.appIcon = "../../../../tmp/owned" reaches this cast and resolves outside the asset root (/tmp/owned.png in a direct reproduction), after which Electron's native decoder reads it. Use isAppIcon(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.

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;
}
28 changes: 28 additions & 0 deletions apps/desktop/src/main/app-icon.ts
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'];
}
Loading