You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/10/11 12:33:12 UTC
(maka) branch main updated: fix(desktop): update Windows taskbar app icon (#5202)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new b544d3b4a8 fix(desktop): update Windows taskbar app icon (#5202)
b544d3b4a8 is described below
commit b544d3b4a834d6f05970d787b2359ecb9e230f2b
Author: Dante <du...@gmail.com>
AuthorDate: Sun Oct 11 20:33:08 2026 +0800
fix(desktop): update Windows taskbar app icon (#5202)
Generated-by: Codex
Signed-off-by: Dante <du...@gmail.com>
fix(ci): accept recoverable rollback state
Generated-by: Codex
---
.../main/__tests__/windows-taskbar-icon.test.ts | 105 +++++++++++++++++++++
apps/desktop/src/main/app-icon-surface.ts | 35 ++++++-
apps/desktop/src/main/main-window.ts | 7 +-
apps/desktop/src/main/windows-taskbar-icon.ts | 99 +++++++++++++++++++
scripts/verify-windows-harness.test.mjs | 17 ++++
scripts/verify-windows-installer-rollback.mjs | 25 ++++-
6 files changed, 281 insertions(+), 7 deletions(-)
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 d84f86971c..a7043d9998 100644
--- a/apps/desktop/src/main/main-window.ts
+++ b/apps/desktop/src/main/main-window.ts
@@ -22,7 +22,7 @@ import { app, BrowserWindow, dialog, nativeTheme, screen, shell, type View, webF
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';
@@ -418,6 +418,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);
deps.onWindowConstructed?.();
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<BaseWindow, 'setAppDetails'>,
+ 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}`], {
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org