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