Fix Retina blur, race conditions, and menu-label duplication in tray Set

- Rebuild the tray attention icon's @2x representation since toBitmap only
  read 1x pixels, blurring the glyph on Retina displays
- Fix premultiplied-alpha math so light-glyph tinting uses per-pixel alpha
  instead of a flat 0xff, keeping antialiased edges valid
- Always push ui:openSettings and leave a longer-lived pending flag, since
  there was no reliable signal that a renderer's listener was attached
- Preserve tray attention state across macOS menu-bar hide/show toggles
  instead of resetting it on tray destroy
- Route macOS tray creation through syncMacMenuBarIcon so startup and the
  live toggle share one visibility policy
- Reuse app-menu translation keys for tray Settings/Check for Updates and
  drop the now-duplicate tray-scoped locale strings
This commit is contained in:
Jinjing
2026-07-16 11:44:59 -07:00
parent 0f3f600ccd
commit 9dbbd65f0c
11 changed files with 158 additions and 98 deletions
+33 -40
View File
@@ -5,7 +5,7 @@
import { existsSync, statSync } from 'node:fs'
import { isAbsolute, join } from 'node:path'
import os from 'node:os'
import { app, BrowserWindow, dialog, ipcMain, nativeTheme } from 'electron'
import { app, BrowserWindow, dialog, ipcMain, nativeTheme, type Tray } from 'electron'
import { electronApp, is } from '@electron-toolkit/utils'
import * as QRCode from 'qrcode'
import {
@@ -54,6 +54,7 @@ import {
rebuildAppMenu
} from './menu/register-app-menu'
import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater'
import type { UpdateCheckOptions } from '../shared/types'
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
import {
configureElectronNetworkCompatibility,
@@ -246,9 +247,8 @@ let keybindings: KeybindingService | null = null
// sweep spare live sessions across that one reload (#5787).
const expectedRendererReload = createWebContentsTimedFlag()
const recoveryReloadInFlight = createWebContentsTimedFlag()
// Why: the tray/menu-bar "Settings…" item can fire before a freshly created
// renderer has attached its ui:openSettings listener, so leave a one-shot
// intent the renderer pulls on mount instead of racing a time-based push.
// Why: a tray/menu-bar "Settings…" click can precede the renderer attaching
// its ui:openSettings listener; the renderer pulls this one-shot on mount.
const pendingOpenSettings = createWebContentsTimedFlag()
let firstWindowStartupServicesReady: Promise<void> = Promise.resolve()
let managedWslCliReconciliationReady: Promise<void> = Promise.resolve()
@@ -806,7 +806,6 @@ function showMainWindowFromTray(): void {
}
function openSettingsFromSystemMenu(): void {
const existingWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null
showMainWindowFromTray()
const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null
if (!targetWindow) {
@@ -814,16 +813,13 @@ function openSettingsFromSystemMenu(): void {
}
recordCrashBreadcrumb('settings_opened')
// Why: an already-mounted renderer has its ui:openSettings listener attached,
// so a direct push lands. A freshly recreated or still-loading renderer has
// not subscribed yet — a time-based push would race its useEffect and could
// be dropped (with no fallback if the load fails), so leave a one-shot intent
// it consumes on mount instead.
if (existingWindow === targetWindow && !targetWindow.webContents.isLoadingMainFrame()) {
targetWindow.webContents.send('ui:openSettings')
return
}
pendingOpenSettings.mark(targetWindow.webContents.id)
// Why: no main-side signal proves the renderer listener is attached, so push
// and leave a one-shot intent — a mounted renderer acts on the push, an
// unmounted one pulls the intent at mount; only one fires per renderer life.
targetWindow.webContents.send('ui:openSettings')
// Why: 60s outlives the default flag TTL, which a cold renderer start can
// exceed — an expired flag silently drops the Settings click.
pendingOpenSettings.mark(targetWindow.webContents.id, 60_000)
}
function quitFromSystemTray(): void {
@@ -838,6 +834,13 @@ function quitFromSystemTray(): void {
app.quit()
}
// Why: a manual check must run against a configured updater; the menu and
// tray are both clickable before anything else configures it.
function runUserInitiatedUpdateCheck(options?: UpdateCheckOptions): void {
ensureAutoUpdaterConfigured()
checkForUpdatesFromMenu(options)
}
function getSystemTrayOptions(): SystemTrayOptions | null {
if (!store) {
return null
@@ -847,21 +850,21 @@ function getSystemTrayOptions(): SystemTrayOptions | null {
onOpen: showMainWindowFromTray,
onOpenSettings: openSettingsFromSystemMenu,
onCheckForUpdates: () => {
ensureAutoUpdaterConfigured()
checkForUpdatesFromMenu()
// Why: updater status renders in the main window; with every window
// closed a bare check would complete invisibly.
showMainWindowFromTray()
runUserInitiatedUpdateCheck()
},
onQuit: quitFromSystemTray
}
}
function syncMacMenuBarIcon(showMenuBarIcon: boolean): void {
function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null {
if (process.platform !== 'darwin' || isServeMode) {
return
return null
}
const options = getSystemTrayOptions()
if (options) {
setMacMenuBarIconVisible(showMenuBarIcon, options)
}
return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null
}
function openMainWindow(): BrowserWindow {
@@ -976,26 +979,21 @@ function openMainWindow(): BrowserWindow {
recordCrashBreadcrumb('main_window_created')
logStartupMilestone('window-created')
// Why: Windows Tray construction can synchronously block on Shell_NotifyIcon,
// so both desktop status-item platforms share post-paint creation. The timer
// fallback also covers windows revealed without ready-to-show ever firing;
// Windows still needs its close target and macOS still needs its entry point.
// so both desktop status-item platforms defer creation to after first paint.
let trayCreated = false
const createSystemTrayDeferred = (): void => {
if (trayCreated || window.isDestroyed() || isQuitting || !store) {
return
}
trayCreated = true
// Why: keep the two macOS visibility paths in agreement — syncMacMenuBarIcon
// refuses serve mode, so the deferred creator must too, or an activated
// headless host would show an icon the live toggle can't manage.
if (
process.platform === 'darwin' &&
(isServeMode || store.getSettings().showMenuBarIcon === false)
) {
if (process.platform === 'darwin') {
// Why: route through syncMacMenuBarIcon so startup and the live toggle
// share one serve-mode/visibility policy.
if (syncMacMenuBarIcon(store.getSettings().showMenuBarIcon !== false)) {
logStartupMilestone('tray-created')
}
return
}
// Why: Windows notification-area creation can block before first paint;
// macOS shares the deferred path to keep startup ordering identical.
const options = getSystemTrayOptions()
if (options && createSystemTray(options)) {
logStartupMilestone('tray-created')
@@ -2077,12 +2075,7 @@ app.whenReady().then(async () => {
logStartupMilestone('i18n-ready')
registerAppMenu({
onCheckForUpdates: (options) => {
// Why: the menu is clickable before first paint; a manual check must
// run against a configured updater (see attach-main-window-services).
ensureAutoUpdaterConfigured()
return checkForUpdatesFromMenu(options)
},
onCheckForUpdates: (options) => runUserInitiatedUpdateCheck(options),
onBeforeReload: ({ ignoreCache, webContentsId }) => {
if (mainWindow?.webContents.id === webContentsId) {
markExpectedRendererReload(webContentsId)
+39 -3
View File
@@ -19,6 +19,7 @@ const {
const makeImage = (name: string) => ({
name,
addRepresentation: vi.fn(),
getScaleFactors: vi.fn(() => [1, 2]),
getSize: vi.fn(() => ({ width: 16, height: 16 })),
setTemplateImage: vi.fn(),
toBitmap: vi.fn(() => Buffer.alloc(16 * 16 * 4)),
@@ -135,6 +136,7 @@ beforeEach(() => {
)
for (const image of [baseMacImage, retinaMacImage, tintedMacImage, attentionImage]) {
image.addRepresentation.mockClear()
image.getScaleFactors.mockReset().mockReturnValue([1, 2])
image.getSize.mockReset().mockReturnValue({ width: 16, height: 16 })
image.setTemplateImage.mockClear()
image.toBitmap.mockClear()
@@ -190,7 +192,7 @@ describe('createSystemTray', () => {
expect(builtMenuItems().map((item) => item.label)).toEqual([
'Open Orca',
undefined,
'Settings...',
'Settings',
'Check for Updates...',
undefined,
'Quit'
@@ -200,7 +202,7 @@ describe('createSystemTray', () => {
for (const [label, callback] of [
['Open Orca', options.onOpen],
['Settings...', options.onOpenSettings],
['Settings', options.onOpenSettings],
['Check for Updates...', options.onCheckForUpdates],
['Quit', options.onQuit]
] as const) {
@@ -278,6 +280,13 @@ describe('setTrayAttention', () => {
setTrayAttention(true)
expect(tintTemplateMock).toHaveBeenCalledWith(baseMacImage, false)
expect(composeAttentionMock).toHaveBeenCalledWith(tintedMacImage)
// Why: the attention image is rebuilt from 1x pixels, so the @2x
// representation must be re-added or the glyph blurs on Retina.
expect(tintTemplateMock).toHaveBeenCalledWith(baseMacImage, false, 2)
expect(attentionImage.addRepresentation).toHaveBeenCalledWith({
scaleFactor: 2,
dataURL: 'data:image/png;base64,attention'
})
expect(attentionImage.setTemplateImage).toHaveBeenCalledWith(false)
expect(created.setImage).toHaveBeenCalledWith(attentionImage)
expect(created.setToolTip).toHaveBeenCalledWith('Orca - activity waiting')
@@ -309,6 +318,33 @@ describe('setTrayAttention', () => {
expect(trayInstances[0].setImage).toHaveBeenCalledWith(attentionImage)
})
it('ignores repeated same-state attention calls', async () => {
setPlatform('win32')
const { createSystemTray, setTrayAttention } = await loadModule()
createSystemTray(createOptions())
const created = trayInstances[0]
created.setImage.mockClear()
setTrayAttention(true)
setTrayAttention(true)
expect(created.setImage).toHaveBeenCalledTimes(1)
})
it('keeps a pending attention dot across a macOS hide/show toggle', async () => {
setPlatform('darwin')
const { setMacMenuBarIconVisible, setTrayAttention } = await loadModule()
const options = createOptions()
setMacMenuBarIconVisible(true, options)
setTrayAttention(true)
setMacMenuBarIconVisible(false, options)
setMacMenuBarIconVisible(true, options)
expect(trayInstances).toHaveLength(2)
expect(trayInstances[1].setImage).toHaveBeenCalledWith(attentionImage)
})
})
describe('destroySystemTray', () => {
@@ -372,7 +408,7 @@ describe('macOS hardening', () => {
})
createSystemTray(options)
const settingsItem = builtMenuItems().find((item) => item.label === 'Settings...')
const settingsItem = builtMenuItems().find((item) => item.label === 'Settings')
expect(() => settingsItem?.click?.()).not.toThrow()
expect(error).toHaveBeenCalledWith('[system-tray] menu action failed', expect.any(Error))
})
+32 -27
View File
@@ -49,11 +49,21 @@ function applyTrayImage(): void {
try {
// Why: disabling template tinting makes the amber dot possible, but the
// glyph then needs literal pixels chosen for the current menu-bar theme.
const tintedGlyph = tintTrayTemplateForAttention(
baseTrayImage,
nativeTheme.shouldUseDarkColors
const useLightGlyph = nativeTheme.shouldUseDarkColors
const attentionImage = composeTrayAttentionIcon(
tintTrayTemplateForAttention(baseTrayImage, useLightGlyph)
)
const attentionImage = composeTrayAttentionIcon(tintedGlyph)
if (baseTrayImage.getScaleFactors().includes(2)) {
// Why: toBitmap reads only the 1x pixels, so rebuild the @2x
// representation or the glyph blurs on Retina during attention.
const retinaAttentionImage = composeTrayAttentionIcon(
tintTrayTemplateForAttention(baseTrayImage, useLightGlyph, 2)
)
attentionImage.addRepresentation({
scaleFactor: 2,
dataURL: retinaAttentionImage.toDataURL()
})
}
attentionImage.setTemplateImage(false)
tray.setImage(attentionImage)
tray.setToolTip(translateMain('tray.activityWaiting', 'Orca - activity waiting'))
@@ -168,34 +178,28 @@ export function createSystemTray(opts: SystemTrayOptions): Tray | null {
// Why: reflect any attention event that fired before the tray existed.
applyTrayImage()
const menu = Menu.buildFromTemplate(
process.platform === 'darwin'
? [
const menu = Menu.buildFromTemplate([
{
label: translateMain('tray.openOrca', 'Open Orca'),
click: safeMenuAction(() => opts.onOpen())
},
{ type: 'separator' },
// Why: reuse the app menu's keys so the two entry points never drift.
...(process.platform === 'darwin'
? ([
{
label: translateMain('tray.openOrca', 'Open Orca'),
click: safeMenuAction(() => opts.onOpen())
},
{ type: 'separator' },
{
label: translateMain('tray.settings', 'Settings...'),
label: translateMain('menu.settings', 'Settings'),
click: safeMenuAction(() => opts.onOpenSettings())
},
{
label: translateMain('tray.checkForUpdates', 'Check for Updates...'),
label: translateMain('menu.checkForUpdates', 'Check for Updates...'),
click: safeMenuAction(() => opts.onCheckForUpdates())
},
{ type: 'separator' },
{ label: translateMain('tray.quit', 'Quit'), click: safeMenuAction(() => opts.onQuit()) }
]
: [
{
label: translateMain('tray.openOrca', 'Open Orca'),
click: safeMenuAction(() => opts.onOpen())
},
{ type: 'separator' },
{ label: translateMain('tray.quit', 'Quit'), click: safeMenuAction(() => opts.onQuit()) }
]
)
{ type: 'separator' }
] as Electron.MenuItemConstructorOptions[])
: []),
{ label: translateMain('tray.quit', 'Quit'), click: safeMenuAction(() => opts.onQuit()) }
])
tray.setContextMenu(menu)
if (process.platform === 'win32') {
tray.setToolTip('Orca')
@@ -245,5 +249,6 @@ export function destroySystemTray(): void {
}
tray = null
baseTrayImage = null
attentionActive = false
// Why: attention is owned by the notification/visibility flow, and must
// survive the macOS hide/show toggle so a re-shown icon keeps its dot.
}
+27
View File
@@ -114,4 +114,31 @@ describe('tintTrayTemplateForAttention', () => {
expect(createFromBitmapMock.mock.calls[0][0]).toEqual(Buffer.from([0xff, 0xff, 0xff, 0xff]))
})
it('keeps white edge pixels premultiplied-valid at partial alpha', () => {
createFromBitmapMock.mockClear()
const base = {
getSize: () => ({ width: 1, height: 1 }),
toBitmap: () => Buffer.from([0x00, 0x00, 0x00, 0x80])
}
tintTrayTemplateForAttention(base as never, true)
// Why: the bitmap is premultiplied — white at 50% coverage is 0x80, not 0xff.
expect(createFromBitmapMock.mock.calls[0][0]).toEqual(Buffer.from([0x80, 0x80, 0x80, 0x80]))
})
it('reads and sizes the requested scale factor', () => {
createFromBitmapMock.mockClear()
const toBitmap = vi.fn(() => Buffer.from([0x00, 0x00, 0x00, 0xff]))
const base = {
getSize: () => ({ width: 1, height: 1 }),
toBitmap
}
tintTrayTemplateForAttention(base as never, true, 2)
expect(toBitmap).toHaveBeenCalledWith({ scaleFactor: 2 })
expect(createFromBitmapMock.mock.calls[0][1]).toEqual({ width: 2, height: 2 })
})
})
+10 -4
View File
@@ -13,22 +13,28 @@ const RING_RGB = { r: 0xff, g: 0xff, b: 0xff }
*/
export function tintTrayTemplateForAttention(
base: NativeImage,
useLightGlyph: boolean
useLightGlyph: boolean,
scaleFactor = 1
): NativeImage {
const { width, height } = base.getSize()
if (width <= 0 || height <= 0) {
return base
}
const bitmap = Buffer.from(base.toBitmap())
const channel = useLightGlyph ? 0xff : 0x00
const bitmap = Buffer.from(base.toBitmap({ scaleFactor }))
for (let offset = 0; offset < bitmap.length; offset += 4) {
// Why: the bitmap is premultiplied, so a light glyph must write the pixel's
// alpha (not 0xff) or antialiased edges become invalid over-bright pixels.
const channel = useLightGlyph ? bitmap[offset + 3] : 0x00
bitmap[offset] = channel
bitmap[offset + 1] = channel
bitmap[offset + 2] = channel
}
return nativeImage.createFromBitmap(bitmap, { width, height })
return nativeImage.createFromBitmap(bitmap, {
width: width * scaleFactor,
height: height * scaleFactor
})
}
/**
+11 -8
View File
@@ -1214,14 +1214,17 @@ export function useIpcEvents(): void {
})
)
// Why: the tray/menu-bar "Settings…" item can fire before this listener
// attaches on a freshly created window; pull any one-shot intent left for
// us so the request is not lost to that race.
void window.api.ui.consumePendingOpenSettings().then((open) => {
if (open) {
useAppStore.getState().openSettingsPage()
}
})
// Why: a tray/menu-bar "Settings…" click can fire before this listener
// attaches on a fresh window; consume any intent queued for us. Guarded
// with `?.` so a stale preload bundle doesn't crash the listener set.
void window.api.ui
.consumePendingOpenSettings?.()
.then((open) => {
if (open) {
useAppStore.getState().openSettingsPage()
}
})
.catch(() => {})
unsubs.push(
window.api.ui.onOpenSetupGuide?.(() => {
+1 -3
View File
@@ -72,9 +72,7 @@
"minimizeNotice": {
"body": "Orca is still running in the system tray"
},
"activityWaiting": "Orca - activity waiting",
"settings": "Settings...",
"checkForUpdates": "Check for Updates..."
"activityWaiting": "Orca - activity waiting"
},
"worktreeJumpPalette": {
"matchLabel": {
+2 -4
View File
@@ -34,7 +34,7 @@
},
"menuBarIcon": {
"title": "Mostrar icono en la barra de menús",
"description": "Manten un acceso directo de Orca e indicador de actividad en la barra de menús de macOS."
"description": "Mantén un acceso directo de Orca e indicador de actividad en la barra de menús de macOS."
}
}
},
@@ -72,9 +72,7 @@
"minimizeNotice": {
"body": "Orca sigue ejecutándose en la bandeja del sistema"
},
"activityWaiting": "Orca: actividad en espera",
"settings": "Ajustes...",
"checkForUpdates": "Buscar actualizaciones..."
"activityWaiting": "Orca: actividad en espera"
},
"worktreeJumpPalette": {
"matchLabel": {
+1 -3
View File
@@ -72,9 +72,7 @@
"minimizeNotice": {
"body": "Orcaはシステムトレイで実行中です"
},
"activityWaiting": "Orca - アクティビティ待機中",
"settings": "設定...",
"checkForUpdates": "アップデートを確認..."
"activityWaiting": "Orca - アクティビティ待機中"
},
"worktreeJumpPalette": {
"matchLabel": {
+1 -3
View File
@@ -72,9 +72,7 @@
"minimizeNotice": {
"body": "Orca가 시스템 트레이에서 계속 실행 중입니다"
},
"activityWaiting": "Orca - 활동 대기 중",
"settings": "설정...",
"checkForUpdates": "업데이트 확인..."
"activityWaiting": "Orca - 활동 대기 중"
},
"worktreeJumpPalette": {
"matchLabel": {
+1 -3
View File
@@ -72,9 +72,7 @@
"minimizeNotice": {
"body": "Orca 仍在系统托盘中运行"
},
"activityWaiting": "Orca - 活动等待中",
"settings": "设置...",
"checkForUpdates": "检查更新..."
"activityWaiting": "Orca - 活动等待中"
},
"worktreeJumpPalette": {
"matchLabel": {