fix(electron): show tray icon on versioned Windows builds (#7282) (#8470)

A Windows tray-icon GUID binds to the executable's full path unless the
binary is code-signed with an organization in the signature subject. The
Store (MSIX) and portable/scoop builds run from versioned directories
whose path changes on every update, so the GUID goes stale and
Shell_NotifyIcon(NIM_ADD) fails silently (Electron raises no JS error).
new Tray(image, guid) still returns a live Tray, so the try/catch and
ensureIndicator() truthiness guards never detected the failure — the
window hid to an invisible, unreachable tray.

Use the path-bound GUID only for the stable-path NSIS build; create the
tray without a GUID on Store and portable, so Windows identifies the icon
by window handle and it stays visible across updates. The NSIS path is
untouched, preserving its taskbar-pin persistence.

Refs: https://www.electronjs.org/docs/latest/api/tray (guid)
This commit is contained in:
Johannes Millan 2026-06-18 13:23:13 +02:00 committed by GitHub
parent 2f808a7a26
commit 31b7c3aac7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 85 additions and 29 deletions

View file

@ -164,9 +164,58 @@ test.beforeEach(() => {
test.afterEach(() => {
Module._load = originalModuleLoad;
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
// Windows distribution-channel signals read by getDistChannel(); clear so
// they never leak into the next test.
delete process.windowsStore;
delete process.env.PORTABLE_EXECUTABLE_DIR;
resetModule();
});
// Windows tray icon GUIDs are bound to the executable path. NSIS installs to a
// stable location, so it keeps its GUID; Store (MSIX) and portable/scoop run
// from versioned paths where a stale GUID makes the icon silently invisible
// (#7282), so those build the tray without a GUID.
const NSIS_TRAY_GUID = 'a2512177-8bee-4b70-a0a8-f3d18e0eab90';
const initIndicatorOnWindows = () => {
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32',
});
const { initIndicator } = loadIndicatorModule();
initIndicator({
showApp: () => {},
quitApp: () => {},
ICONS_FOLDER: '/icons/',
forceDarkTray: false,
app: { on: () => {} },
});
};
test('Windows NSIS build creates the tray with a stable GUID', () => {
// Neither windowsStore nor PORTABLE_EXECUTABLE_DIR set -> win-nsis.
initIndicatorOnWindows();
assert.equal(createdTrayArgs.length, 1);
assert.equal(createdTrayArgs[0][1], NSIS_TRAY_GUID);
});
test('Windows Store (MSIX) build creates the tray without a GUID', () => {
process.windowsStore = true;
initIndicatorOnWindows();
assert.equal(createdTrayArgs.length, 1);
assert.equal(createdTrayArgs[0][1], undefined);
});
test('Windows portable build creates the tray without a GUID', () => {
process.env.PORTABLE_EXECUTABLE_DIR = 'C:\\Users\\test\\sp-portable';
initIndicatorOnWindows();
assert.equal(createdTrayArgs.length, 1);
assert.equal(createdTrayArgs[0][1], undefined);
});
test('initIndicator uses NativeImage for Linux tray creation and updates', () => {
const { initIndicator } = loadIndicatorModule();

View file

@ -62,30 +62,27 @@ const IS_MAC = process.platform === 'darwin';
const IS_LINUX = process.platform === 'linux';
const IS_WINDOWS = process.platform === 'win32';
// Stable GUIDs per Windows distribution type.
// Windows ties tray icon GUIDs to the executable path (when unsigned).
// Different distribution types have different exe paths, so they need
// separate GUIDs to avoid silent tray creation failures.
// WARNING: These GUIDs must never change once deployed per distribution type.
// Changing a GUID makes Windows treat it as a new icon, resetting it to the
// overflow area. Users would have to manually re-show it on the taskbar.
// See: https://learn.microsoft.com/en-us/windows/win32/api/shellapi/ns-shellapi-notifyicondataa
const WINDOWS_TRAY_GUIDS = {
portable: 'f7c06d50-4d3e-4f8d-b9a0-2c8e7f5a1b3d',
nsis: 'a2512177-8bee-4b70-a0a8-f3d18e0eab90',
store: '19b9d3fe-aa50-4792-917e-60ada97f3088',
} as const;
// Stable GUID for the NSIS (installer) build only.
// Per Electron's Tray docs, a tray-icon GUID binds to the code-signing
// signature only when that signature carries an organization in its subject;
// otherwise it binds to the executable's full path, and changing the path
// breaks tray-icon creation until a new GUID is used. The GitHub NSIS build is
// signed and installs to a fixed path, so its GUID stays valid. The Store
// (MSIX) and portable/scoop builds run from versioned directories whose path
// changes on every update, so the GUID goes stale and Shell_NotifyIcon(NIM_ADD)
// fails silently (Electron raises no JS error) — leaving an invisible tray icon
// and an unreachable window (#7282). Those builds therefore create the tray
// without a GUID, so Windows identifies it by window handle and it stays visible.
// WARNING: This GUID must never change once deployed; Windows would treat a new
// value as a new icon and reset it to the overflow area.
// Retired GUIDs (shipped in v18.10.0, now GUID-less — never reuse for a new icon):
// portable f7c06d50-4d3e-4f8d-b9a0-2c8e7f5a1b3d, store 19b9d3fe-aa50-4792-917e-60ada97f3088
// See https://www.electronjs.org/docs/latest/api/tray (guid) and
// https://learn.microsoft.com/en-us/windows/win32/api/shellapi/ns-shellapi-notifyicondataa
const WINDOWS_TRAY_NSIS_GUID = 'a2512177-8bee-4b70-a0a8-f3d18e0eab90';
const getWindowsTrayGuid = (): string => {
const channel = getDistChannel();
if (channel === 'win-portable') {
return WINDOWS_TRAY_GUIDS.portable;
}
if (channel === 'win-store') {
return WINDOWS_TRAY_GUIDS.store;
}
return WINDOWS_TRAY_GUIDS.nsis;
};
const getWindowsTrayGuid = (): string | undefined =>
getDistChannel() === 'win-nsis' ? WINDOWS_TRAY_NSIS_GUID : undefined;
export const initIndicator = ({
showApp,
@ -155,13 +152,23 @@ const createTray = (): Tray => {
let nextTray: Tray;
if (IS_WINDOWS) {
const guid = getWindowsTrayGuid();
try {
nextTray = new Tray(trayIcon, guid);
log('Tray created on Windows with GUID:', guid);
} catch (e) {
log('Tray creation with GUID failed, retrying without GUID:', e);
if (guid) {
try {
nextTray = new Tray(trayIcon, guid);
log('Tray created on Windows with GUID:', guid);
} catch (e) {
// Log only the message, not the full error: log history is exportable
// and the error/stack can embed the install path (e.g. C:\Users\<name>\).
log(
'Tray creation with GUID failed, retrying without GUID:',
e instanceof Error ? e.message : e,
);
nextTray = new Tray(trayIcon);
log('Tray created on Windows without GUID');
}
} else {
nextTray = new Tray(trayIcon);
log('Tray created on Windows without GUID');
log('Tray created on Windows without GUID (versioned install path)');
}
} else {
nextTray = new Tray(trayIcon);