* fix: update Linux tray icon in place on OS theme change
Avoids a StatusNotifierItem re-registration race on KDE Plasma
where the old SNI remains registered when the new one appears,
resulting in two tray icons side by side until session logout.
`patch_tray_menu_handler` already bounds the race with a 250 ms
delay after `tray.destroy()`, but that's not enough on all setups
(reproduced on Fedora 43 KDE Plasma 6.6.4 + Wayland). Widening the
delay just moves the goalposts; the race is structural.
Fix: inject a fast-path before the existing destroy+recreate block
in the tray rebuild function. When the tray already exists and
isn't being disabled, update its icon and context menu in place
via `setImage` + `setContextMenu` — the existing StatusNotifierItem
stays registered, no DBus re-registration, no race. The slow path
(destroy + delay + re-create) is kept for the initial creation and
the tray-disable cases where it's unavoidable.
All five minified locals needed by the fast-path (tray function,
tray variable, electron module, menu function, icon path const,
menuBarEnabled flag) are extracted dynamically; the idempotency
guard re-keys off the post-rename `setImage(...)` sequence.
Triggered in KDE System Settings by any of Appearance → Colors /
Plasma Style / Global Theme, which all fire the same
`nativeTheme.on('updated')` signal.
Follow-up to #491. The broader submenu work from that PR stays
parked on features/change-icon-color pending the scope discussion
in #492; this PR ships only the duplicate-tray-icon fix that
@aaddrick asked to split out.
Co-Authored-By: Claude <claude@anthropic.com>
* fix(tray): tighten in-place patch extraction guards
Drop the redundant `electron_var_re_local` local — `electron_var_re`
is already a sourced global from `_common.sh` with the same value.
Replace the silent `head -1` on `enabled_var` extraction with an
explicit count-and-bail. The grep matches `const X=fn("menuBarEnabled")`
across the whole file; today there's exactly one site (inside the
tray function), but if upstream ever ships a second the previous
code would silently bind to whichever the minifier emitted first.
Bail loudly with a count diagnostic instead.
Verified on the live 1.3883.0 build asar: all five extractions
resolve (`Nh`/`wAt`/`t`/`e`) — note the symbol drift vs. the
build-reference's `fh`/`CZe`. Fast-path injects, JS validates,
idempotent re-run confirmed, duplicate-icon repro gone on Nobara
KDE Plasma 6 (Wayland) under Appearance → Colors / Plasma Style /
Global Theme.
Co-Authored-By: Claude <claude@anthropic.com>
* docs(readme): credit @IliyaBrook for tray duplicate-icon fix
Co-Authored-By: Claude <claude@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: aaddrick <aaddrick@gmail.com>
4.7 KiB
Tray icon rebuild race on OS theme change
Why destroy + delay + recreate isn't enough on KDE, and what the in-place fast-path does differently.
The bug
Claude Desktop's tray icon follows the OS theme via
nativeTheme.on('updated', ...) — every theme change re-runs the
tray rebuild function so the icon PNG can be switched. That rebuild
calls tray.destroy(), nulls the reference, sleeps 250 ms (added
earlier to bound DBus-teardown timing), then instantiates a fresh
new Tray(image).
Destroying the Tray deregisters the app's StatusNotifierItem from
the session bus (org.kde.StatusNotifierWatcher.UnregisterItem);
the new Tray() call registers a brand-new one. On KDE Plasma's
systemtray widget the window between "unregister signal emitted"
and "plasmoid observer reacts" can exceed 250 ms, during which both
the old SNI name and the new one coexist in the widget's internal
list — the user sees two Claude icons side by side until the
next session start.
250 ms is genuinely enough on some setups (the delay was landed because a larger gap was introducing a visible icon flash); it isn't enough on others. Timing depends on the compositor version, portal implementation, and presumably hardware speed, so widening the delay is just moving the goalposts — the race is structural.
Triggers
Any system-wide appearance change that makes Chromium emit
nativeTheme::updated trips the same code path. Verified triggers
in KDE System Settings:
- Appearance → Colors (application colour scheme dropdown)
- Appearance → Plasma Style (panel/widget theme)
- Appearance → Global Theme (look-and-feel package)
All three route through org.freedesktop.appearance /
KGlobalSettings signals that Chromium observes, so they all
re-enter the tray rebuild function and all reproduce the duplicate
icon.
The fix
patch_tray_inplace_update (in scripts/patches/tray.sh) injects
a fast-path at the top of the rebuild function:
if (Nh && e !== false) {
Nh.setImage(pA.nativeImage.createFromPath(t));
process.platform !== 'darwin' && Nh.setContextMenu(wAt());
return;
}
When the tray already exists and isn't being disabled, the patch
updates the icon and the context menu on the existing
StatusNotifierItem — setImage and setContextMenu don't
re-register the SNI on DBus, they emit NewIcon / LayoutUpdated
signals, which the host consumes in-place. No race.
The original destroy + recreate slow-path is kept intact for two cases that legitimately require it:
- Initial creation —
Nhisundefined, so the fast-path guard short-circuits and the slow path runs. - Disabling the tray —
e === false(user turned the tray off viamenuBarEnabledsetting) means the tray should be destroyed outright, not re-imaged.
Resilience to minifier churn
Variable names (Nh, pA, wAt, t, e) drift between upstream
releases. All five are extracted dynamically in tray.sh:
| Local | Extraction anchor |
|---|---|
tray_func |
on("menuBarEnabled",()=>{ … }) |
tray_var |
});let X=null;(async )?function ${tray_func} |
electron_var |
already extracted earlier in _common.sh |
menu_func |
${tray_var}.setContextMenu(X( |
path_var |
${tray_var}=new ${electron_var}.Tray(${electron_var}.nativeImage.createFromPath(X)) |
enabled_var |
const X = fn("menuBarEnabled") |
Idempotency guard keys on the distinctive
${tray_var}.setImage(${electron_var}.nativeImage.createFromPath(${path_var}))
sequence using post-rename extracted names, so re-running the patch
on an already-patched asar is a no-op even after the minifier
churns.
Verification
Reproduced on Fedora Linux 43 (KDE Plasma Desktop Edition) with
Plasma 6.6.4, xdg-desktop-portal-kde 6.6.4, Wayland session,
kernel 6.19.12.
Steps on pristine main (before this patch):
git clone https://github.com/aaddrick/claude-desktop-debian.git
cd claude-desktop-debian
./build.sh --build appimage --clean no
./claude-desktop-*-amd64.AppImage
# Then in KDE Settings → Appearance, flip any of Colors /
# Plasma Style / Global Theme. Two tray icons appear.
After the patch: one SNI stays registered for the app's lifetime, icon updates in place on every theme change.
Pitfalls to watch for
- Fast-path runs inside the 3 s startup window too. The
existing
_trayStartTime > 3e3guard only gates thenativeTheme.on('updated')→tray_func()call; oncetray_func()is running for any reason, our fast-path executes. Fine — it's cheaper than the slow path even at startup. - macOS path is left untouched. The condition
process.platform !== 'darwin' && …setContextMenukeeps the Electron macOS tray model (right-click pops up a menu viapopUpContextMenu(r)withrcaptured at creation time) intact.