diff --git a/apps/desktop/src/main/__tests__/windows-taskbar-icon.test.ts b/apps/desktop/src/main/__tests__/windows-taskbar-icon.test.ts new file mode 100644 index 0000000000..77fe366b14 --- /dev/null +++ b/apps/desktop/src/main/__tests__/windows-taskbar-icon.test.ts @@ -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 }); + } +}); diff --git a/apps/desktop/src/main/app-icon-surface.ts b/apps/desktop/src/main/app-icon-surface.ts index b3f9f5b2fe..25bd5734bf 100644 --- a/apps/desktop/src/main/app-icon-surface.ts +++ b/apps/desktop/src/main/app-icon-surface.ts @@ -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 @@ -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); } diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index e0f5dedfdb..dd349e9baf 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -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'; @@ -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); diff --git a/apps/desktop/src/main/windows-taskbar-icon.ts b/apps/desktop/src/main/windows-taskbar-icon.ts new file mode 100644 index 0000000000..1856a311e6 --- /dev/null +++ b/apps/desktop/src/main/windows-taskbar-icon.ts @@ -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, + userData: string, + image: NativeImage, +): void { + window.setAppDetails(windowsTaskbarAppDetails(userData, image)); +} diff --git a/scripts/verify-windows-harness.test.mjs b/scripts/verify-windows-harness.test.mjs index 2dabb8d7f0..01f7957e53 100644 --- a/scripts/verify-windows-harness.test.mjs +++ b/scripts/verify-windows-harness.test.mjs @@ -47,6 +47,7 @@ import { import { deleteUninstallRegistrationForInstall, readUninstallDisplayVersionsForInstall, + verifyRegistryMismatchRecoveryEvidence, verifyRestoredWindowsInstallation, } from './verify-windows-installer-rollback.mjs'; import { @@ -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); diff --git a/scripts/verify-windows-installer-rollback.mjs b/scripts/verify-windows-installer-rollback.mjs index d0db35da5c..d23a88e0f0 100644 --- a/scripts/verify-windows-installer-rollback.mjs +++ b/scripts/verify-windows-installer-rollback.mjs @@ -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: @@ -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}`], {