fix: update Linux tray icon in place on OS theme change (#515)

* 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>
This commit is contained in:
IliyaBrook
2026-04-27 16:19:36 +03:00
committed by GitHub
parent 31c557acca
commit cf2b0fc357
5 changed files with 269 additions and 1 deletions

View File

@@ -12,6 +12,7 @@ The [`docs/learnings/`](docs/learnings/) directory contains hard-won technical k
- [`cowork-vm-daemon.md`](docs/learnings/cowork-vm-daemon.md) — Cowork VM daemon lifecycle, respawn logic, crash diagnosis
- [`plugin-install.md`](docs/learnings/plugin-install.md) — Anthropic & Partners plugin install flow, gate logic, backend endpoints, and DevTools recipes
- [`apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md) — APT/DNF binary distribution via Cloudflare Worker + GitHub Releases, redirect chain, credential ownership, heartbeat runbook
- [`tray-rebuild-race.md`](docs/learnings/tray-rebuild-race.md) — why destroy + recreate on `nativeTheme` updates briefly duplicates the tray icon on KDE Plasma, and the in-place `setImage` + `setContextMenu` fast-path that avoids the SNI re-registration race
## Code Style

View File

@@ -207,7 +207,9 @@ Special thanks to:
- Diagnosing the session-start hook sudo blocking issue with three solution approaches
- **[chukfinley](https://github.com/chukfinley)** for experimental Cowork mode support on Linux
- **[CyPack](https://github.com/CyPack)** for orphaned cowork daemon cleanup on startup
- **[IliyaBrook](https://github.com/IliyaBrook)** for fixing the platform patch for Claude Desktop >= 1.1.3541 arm64 refactor
- **[IliyaBrook](https://github.com/IliyaBrook)**
- Fixing the platform patch for Claude Desktop >= 1.1.3541 arm64 refactor
- Fixing the duplicate tray icon on OS theme change with an in-place `setImage`/`setContextMenu` fast-path that avoids the KDE Plasma SNI re-registration race
- **[MichaelMKenny](https://github.com/MichaelMKenny)**
- Diagnosing the `$`-prefixed electron variable bug
- Root cause analysis and workaround

View File

@@ -0,0 +1,123 @@
# 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:
```js
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** — `Nh` is `undefined`, so the fast-path
guard short-circuits and the slow path runs.
- **Disabling the tray** — `e === false` (user turned the tray off
via `menuBarEnabled` setting) 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):
```bash
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 > 3e3` guard only gates the
`nativeTheme.on('updated')``tray_func()` call; once
`tray_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' && …setContextMenu` keeps the
Electron macOS tray model (right-click pops up a menu via
`popUpContextMenu(r)` with `r` captured at creation time) intact.

View File

@@ -77,6 +77,10 @@ console.log('Updated package.json: main entry and node-pty dependency');
# Patch tray icon selection
patch_tray_icon_selection
# Inject fast-path that updates the tray icon in place on theme
# changes (avoids the KDE duplicate-SNI race on destroy+recreate)
patch_tray_inplace_update
# Patch menuBarEnabled to default to true when unset
patch_menu_bar_default

View File

@@ -92,6 +92,144 @@ patch_tray_icon_selection() {
echo '##############################################################'
}
patch_tray_inplace_update() {
echo 'Patching tray rebuild to update in-place on theme change...'
local index_js='app.asar.contents/.vite/build/index.js'
# Re-extract the tray variable name — `patch_tray_menu_handler`
# declares it `local` so it's not visible here. Same grep pattern.
local tray_func local_tray_var tray_var_re
local menu_func path_var enabled_var enabled_count
tray_func=$(grep -oP \
'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' "$index_js")
if [[ -z $tray_func ]]; then
echo ' Could not find tray function — skipping'
echo '##############################################################'
return
fi
local_tray_var=$(grep -oP \
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func})" \
"$index_js")
if [[ -z $local_tray_var ]]; then
echo ' Could not extract tray variable name — skipping'
echo '##############################################################'
return
fi
echo " Found tray variable: $local_tray_var"
tray_var_re="${local_tray_var//\$/\\$}"
menu_func=$(grep -oP "${tray_var_re}\.setContextMenu\(\K\w+(?=\(\))" \
"$index_js" | head -1)
if [[ -z $menu_func ]]; then
echo ' Could not extract menu function name — skipping'
echo '##############################################################'
return
fi
echo " Found menu function: $menu_func"
# Extract the icon-path local used in the original
# Nh = new pA.Tray(pA.nativeImage.createFromPath(X))
# call. That `X` is the `const` assigned `path.join(resourcesDir(),
# suffix)` earlier in the function; minifier renames it between
# releases, so it needs to be extracted (not hardcoded).
path_var=$(grep -oP \
"${tray_var_re}=new ${electron_var_re}\.Tray\(${electron_var_re}\.nativeImage\.createFromPath\(\K\w+(?=\))" \
"$index_js" | head -1)
if [[ -z $path_var ]]; then
echo ' Could not extract icon-path var — skipping'
echo '##############################################################'
return
fi
echo " Found icon-path var: $path_var"
# Extract the menuBarEnabled local. The injected fast-path needs to
# read the same local that the slow-path destroy/recreate block
# tests, so binding to the wrong site is silently broken. Bail if
# upstream ever ships >1 declaration site instead of taking the
# first one.
enabled_count=$(grep -cE \
'const \w+\s*=\s*\w+\("menuBarEnabled"\)' "$index_js")
if [[ $enabled_count -ne 1 ]]; then
echo " Expected 1 menuBarEnabled declaration, found" \
"${enabled_count} — skipping"
echo '##############################################################'
return
fi
enabled_var=$(grep -oP \
'const \K\w+(?=\s*=\s*\w+\("menuBarEnabled"\))' "$index_js")
if [[ -z $enabled_var ]]; then
echo ' Could not extract menuBarEnabled var — skipping'
echo '##############################################################'
return
fi
echo " Found menuBarEnabled var: $enabled_var"
# Idempotency guard: re-running the patch is a no-op once our
# fast-path is in place. Key on the distinctive
# "setImage(EL.nativeImage.createFromPath(PATH_VAR))" sequence
# using the (post-rename) extracted names — the destroy+recreate
# slow-path still exists below, so we can't just count occurrences
# of setImage.
local fast_path_marker
fast_path_marker="${local_tray_var}.setImage(${electron_var}.nativeImage.createFromPath(${path_var}))"
if grep -qF "$fast_path_marker" "$index_js"; then
echo ' In-place fast-path already present (idempotent)'
echo '##############################################################'
return
fi
# Inject a fast-path before the existing destroy+recreate block:
# when the tray already exists and isn't being disabled, update it
# in place with setImage + setContextMenu. Skips the DBus race
# where Plasma briefly shows both the old (not yet unregistered)
# and the new StatusNotifierItem. Slow path is kept for initial
# creation and tray-disable.
if ! TRAY_VAR="$local_tray_var" EL_VAR="$electron_var" \
MENU_FUNC="$menu_func" PATH_VAR="$path_var" \
ENABLED_VAR="$enabled_var" \
node -e "
const fs = require('fs');
const p = 'app.asar.contents/.vite/build/index.js';
const T = process.env.TRAY_VAR;
const E = process.env.EL_VAR;
const M = process.env.MENU_FUNC;
const P = process.env.PATH_VAR;
const V = process.env.ENABLED_VAR;
let code = fs.readFileSync(p, 'utf8');
// Anchor at the start of the existing destroy+recreate block,
// tolerating optional inner whitespace.
const reEsc = (s) => s.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\\$&');
const anchor = new RegExp(
';if\\\\(' + reEsc(T) + '&&\\\\(' + reEsc(T) + '\\\\.destroy\\\\(\\\\)'
);
if (!anchor.test(code)) {
console.error(' [FAIL] destroy-recreate anchor not found');
process.exit(1);
}
const fastPath =
'if(' + T + '&&' + V + '!==false){' +
T + '.setImage(' + E + '.nativeImage.createFromPath(' + P + '));' +
'process.platform!==\"darwin\"&&' + T + '.setContextMenu(' + M + '());' +
'return' +
'}';
// Prefix the destroy block with the fast-path, keeping the matched
// portion ';if(TRAY&&(TRAY.destroy()' intact.
code = code.replace(anchor, (m) => ';' + fastPath + m.slice(1));
fs.writeFileSync(p, code);
console.log(' [OK] Fast-path injected before destroy-recreate');
"; then
echo 'Failed to inject tray in-place fast-path' >&2
cd "$project_root" || exit 1
exit 1
fi
echo '##############################################################'
}
patch_menu_bar_default() {
echo 'Patching menuBarEnabled to default to true when unset...'
local index_js='app.asar.contents/.vite/build/index.js'