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
105 changes: 105 additions & 0 deletions apps/desktop/src/main/__tests__/windows-taskbar-icon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import type { NativeImage } from 'electron';
import {
applyWindowsTaskbarAppDetails,
encodePngAsIco,
persistWindowsTaskbarIcon,
windowsTaskbarAppDetails,
WINDOWS_APP_USER_MODEL_ID,
} from '../windows-taskbar-icon.js';

const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');

test('wraps the selected PNG in one valid 256px ICO directory entry', () => {
const ico = encodePngAsIco(PNG);
assert.equal(ico.readUInt16LE(0), 0);
assert.equal(ico.readUInt16LE(2), 1);
assert.equal(ico.readUInt16LE(4), 1);
assert.equal(ico.readUInt8(6), 0);
assert.equal(ico.readUInt8(7), 0);
assert.equal(ico.readUInt16LE(10), 1);
assert.equal(ico.readUInt16LE(12), 32);
assert.equal(ico.readUInt32LE(14), PNG.length);
assert.equal(ico.readUInt32LE(18), 22);
assert.deepEqual(ico.subarray(22), PNG);
});

test('persists a stable content-addressed taskbar resource under userData', () => {
const root = mkdtempSync(join(tmpdir(), 'maka-taskbar-icon-'));
const userData = join(root, 'Maka, Profile 中文');
const resizeCalls: unknown[] = [];
const image = {
resize(options: unknown) {
resizeCalls.push(options);
return { toPNG: () => PNG };
},
} as unknown as NativeImage;
try {
const first = persistWindowsTaskbarIcon(userData, image);
const second = persistWindowsTaskbarIcon(userData, image);
assert.equal(first, second);
assert.equal(resizeCalls.length, 2);
assert.match(first, /taskbar-icons[/\\][a-f0-9]{64}\.ico$/);
assert.deepEqual(readFileSync(first), encodePngAsIco(PNG));
} finally {
rmSync(root, { recursive: true, force: true });
}
});

test('uses the installed application identity with the persisted ICO resource', () => {
const root = mkdtempSync(join(tmpdir(), 'maka-taskbar-details-'));
const image = {
resize: () => ({ toPNG: () => PNG }),
} as unknown as NativeImage;
try {
const details = windowsTaskbarAppDetails(root, image);
assert.equal(details.appId, WINDOWS_APP_USER_MODEL_ID);
assert.equal(details.appIconIndex, 0);
assert.match(details.appIconPath ?? '', /\.ico$/);
assert.ok(readFileSync(details.appIconPath ?? '').length > PNG.length);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

test('writes the taskbar details through the window property store boundary', () => {
const root = mkdtempSync(join(tmpdir(), 'maka-taskbar-window-'));
const image = {
resize: () => ({ toPNG: () => PNG }),
} as unknown as NativeImage;
const calls: unknown[] = [];
try {
applyWindowsTaskbarAppDetails(
{ setAppDetails: (details) => calls.push(details) },
root,
image,
);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0], windowsTaskbarAppDetails(root, image));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
35 changes: 34 additions & 1 deletion apps/desktop/src/main/app-icon-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from './custom-app-icon-store.js';
import { appIconLoadOrder, pickReadableAppIconPath, resolveAppIconPath } from './app-icon.js';
import { desktopAssetRoot } from './desktop-assets.js';
import { applyWindowsTaskbarAppDetails } from './windows-taskbar-icon.js';

/**
* One choice's artwork path — shipped art under the asset root, imported art
Expand Down Expand Up @@ -110,7 +111,39 @@ export function applyAppIcon(value: unknown, onIconError: (error: unknown) => vo
app.dock.setIcon(image);
return;
}
for (const window of BrowserWindow.getAllWindows()) window.setIcon(image);
for (const window of BrowserWindow.getAllWindows()) applyWindowAppIcon(window, image);
} catch (error) {
onIconError(error);
}
}

/**
* Apply both icon surfaces owned by one Windows window. `setIcon` changes the
* HICON returned by WM_GETICON; Explorer can still render the installed
* shortcut/executable icon for the taskbar group unless the window's
* AppUserModel property store names the selected artwork too.
*/
function applyWindowAppIcon(window: BrowserWindow, image: Electron.NativeImage): void {
window.setIcon(image);
if (process.platform !== 'win32') return;
applyWindowsTaskbarAppDetails(window, app.getPath('userData'), image);
}

/** Update a newly created window before it is ever revealed. */
export function applyInitialWindowAppIcon(
window: BrowserWindow,
value: unknown,
onIconError: (error: unknown) => void,
): void {
if (process.platform !== 'win32') return;
try {
const icon = toAppIconChoice(value);
const image = loadAppIcon(icon);
if (!image) {
onIconError(new Error(`no readable artwork for app icon "${icon}"`));
return;
}
applyWindowAppIcon(window, image);
} catch (error) {
onIconError(error);
}
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/main/main-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { app, BrowserWindow, dialog, nativeTheme, screen, shell, webFrameMain }
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { appIconForTheme, type AppSettings } from '@maka/core/settings';
import { readableAppIconPath } from './app-icon-surface.js';
import { applyInitialWindowAppIcon, readableAppIconPath } 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';
Expand Down Expand Up @@ -432,6 +432,11 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main
allowRunningInsecureContent: false,
},
});
applyInitialWindowAppIcon(
mainWindow,
appIconForTheme(persistedAppearance ?? {}, isDark),
(error) => console.error('[icon] failed to set the initial app icon:', error),
);
mainWindowShutdownSignal = signal;
observeRendererProcess(mainWindow, signal);
installMainWindowPermissionPolicy(mainWindow.webContents, rendererEntry.url);
Expand Down
99 changes: 99 additions & 0 deletions apps/desktop/src/main/windows-taskbar-icon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { AppDetailsOptions, BaseWindow, NativeImage } from 'electron';

/** Must stay aligned with electron-builder's appId and installed shortcut. */
export const WINDOWS_APP_USER_MODEL_ID = 'com.maka.desktop';

const TASKBAR_ICON_SIZE = 256;

/**
* Windows accepts a PNG image inside an ICO directory entry. Keeping this
* tiny encoder here avoids adding a native image-conversion dependency merely
* to give the taskbar property store a resource it can consume.
*/
export function encodePngAsIco(png: Buffer): Buffer {
const header = Buffer.alloc(22);
header.writeUInt16LE(0, 0); // reserved
header.writeUInt16LE(1, 2); // icon
header.writeUInt16LE(1, 4); // one image
header.writeUInt8(0, 6); // 0 means 256 pixels
header.writeUInt8(0, 7);
header.writeUInt8(0, 8); // palette size: true colour
header.writeUInt8(0, 9);
header.writeUInt16LE(1, 10);
header.writeUInt16LE(32, 12);
header.writeUInt32LE(png.length, 14);
header.writeUInt32LE(header.length, 18);
return Buffer.concat([header, png]);
}

/**
* Persist the selected artwork as a content-addressed ICO. Explorer may read
* the relaunch icon after this process exits, so a temporary file is not a
* valid taskbar resource. Content addressing also makes rapid changes
* last-write-wins without an older async conversion overwriting a newer one.
*/
export function persistWindowsTaskbarIcon(userData: string, image: NativeImage): string {
const png = image.resize({
width: TASKBAR_ICON_SIZE,
height: TASKBAR_ICON_SIZE,
quality: 'better',
}).toPNG();
const digest = createHash('sha256').update(png).digest('hex');
const directory = join(userData, 'taskbar-icons');
const destination = join(directory, `${digest}.ico`);
if (existsSync(destination)) return destination;

mkdirSync(directory, { recursive: true });
const temporary = `${destination}.${process.pid}.tmp`;
writeFileSync(temporary, encodePngAsIco(png));
try {
renameSync(temporary, destination);
} catch (error) {
// Another window in this process may have materialized the same digest.
// Keep that complete file and discard only our private temporary file.
if (!existsSync(destination)) throw error;
rmSync(temporary, { force: true });
}
return destination;
}

export function windowsTaskbarAppDetails(
userData: string,
image: NativeImage,
): AppDetailsOptions {
return {
appId: WINDOWS_APP_USER_MODEL_ID,
appIconPath: persistWindowsTaskbarIcon(userData, image),
appIconIndex: 0,
};
}

export function applyWindowsTaskbarAppDetails(
window: Pick<BaseWindow, 'setAppDetails'>,
userData: string,
image: NativeImage,
): void {
window.setAppDetails(windowsTaskbarAppDetails(userData, image));
}
17 changes: 17 additions & 0 deletions scripts/verify-windows-harness.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
import {
deleteUninstallRegistrationForInstall,
readUninstallDisplayVersionsForInstall,
verifyRegistryMismatchRecoveryEvidence,
verifyRestoredWindowsInstallation,
} from './verify-windows-installer-rollback.mjs';
import {
Expand Down Expand Up @@ -327,6 +328,22 @@ it('reuses the packaged renderer smoke without widening rollback verification',
]);
});

it('accepts recoverable exit 103 evidence when no failed-upgrade aside was created', async () => {
const root = await makeTree({
'installed/Maka.exe': 'new version',
'installed.pre-upgrade-backup/.maka-backup-complete': 'version=1.2.3',
'installed.pre-upgrade-backup/RECOVERY-README.txt': 'rerun the installer',
});
const installDirectory = join(root, 'installed');
const backupDirectory = `${installDirectory}.pre-upgrade-backup`;

await verifyRegistryMismatchRecoveryEvidence(installDirectory, backupDirectory, '1.2.4', {
run: async () => ({ stdout: '1.2.4.0', stderr: '' }),
});

await assert.rejects(access(`${installDirectory}.failed-upgrade`), { code: 'ENOENT' });
});

async function makeTree(shape) {
const root = await mkdtemp(join(tmpdir(), 'maka-harness-test-'));
temporaryRoots.push(root);
Expand Down
25 changes: 20 additions & 5 deletions scripts/verify-windows-installer-rollback.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,19 @@ export async function verifyRestoredWindowsInstallation(
assertWindowsProductVersion(actualVersion, expectedVersion);
}

export async function verifyRegistryMismatchRecoveryEvidence(
installDirectory,
backupDirectory,
expectedVersion,
{ run } = {},
) {
const installedExecutable = join(installDirectory, executableName);
const mismatchVersion = await readInstalledProductVersion(installedExecutable, { run });
assertWindowsProductVersion(mismatchVersion, expectedVersion);
await access(join(backupDirectory, backupMarkerName));
await access(join(backupDirectory, 'RECOVERY-README.txt'));
}

/**
* Exercises the Abort-path rollback contract of
* apps/desktop/build/installer.nsh, scenario by scenario:
Expand Down Expand Up @@ -381,11 +394,13 @@ export async function verifyWindowsInstallerRollback(
`${registryMismatch.stderr.trim() ? `\nstderr: ${registryMismatch.stderr.trim()}` : ''}`,
);
}
const mismatchVersion = await readInstalledProductVersion(installedExecutable, { run });
assertWindowsProductVersion(mismatchVersion, nextVersion);
await access(join(backupDirectory, backupMarkerName));
await access(join(backupDirectory, 'RECOVERY-README.txt'));
await access(join(`${installDirectory}.failed-upgrade`, executableName));
// Exit 103 also covers a transient failure to move the extracted tree
// aside. In that valid state the launchable new tree remains at INSTDIR,
// so no failed-upgrade sibling exists; the complete backup and persisted
// snapshot still make the following recovery rerun safe.
await verifyRegistryMismatchRecoveryEvidence(installDirectory, backupDirectory, nextVersion, {
run,
});

step('registry mismatch recovery: rerun without the failpoint');
await run(nextInstaller, ['/S', `/D=${installDirectory}`], {
Expand Down