diff --git a/src/main/index.ts b/src/main/index.ts index 41fa5436492..676fa2308c5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 = Promise.resolve() let managedWslCliReconciliationReady: Promise = 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) diff --git a/src/main/tray/system-tray.test.ts b/src/main/tray/system-tray.test.ts index b85526f0273..71c94f84e74 100644 --- a/src/main/tray/system-tray.test.ts +++ b/src/main/tray/system-tray.test.ts @@ -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)) }) diff --git a/src/main/tray/system-tray.ts b/src/main/tray/system-tray.ts index 3cda04cac7f..27d2487a3c5 100644 --- a/src/main/tray/system-tray.ts +++ b/src/main/tray/system-tray.ts @@ -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. } diff --git a/src/main/tray/tray-attention-icon.test.ts b/src/main/tray/tray-attention-icon.test.ts index 1bc00daa77c..090847e284e 100644 --- a/src/main/tray/tray-attention-icon.test.ts +++ b/src/main/tray/tray-attention-icon.test.ts @@ -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 }) + }) }) diff --git a/src/main/tray/tray-attention-icon.ts b/src/main/tray/tray-attention-icon.ts index 5a7041f111e..ffdf4528d47 100644 --- a/src/main/tray/tray-attention-icon.ts +++ b/src/main/tray/tray-attention-icon.ts @@ -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 + }) } /** diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 1f64369bc88..159926a9993 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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?.(() => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3bd44870cf3..8a9ed738a87 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 3ce19041a4f..4bf70ef388e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 36e001bfc42..05f995d671d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -72,9 +72,7 @@ "minimizeNotice": { "body": "Orcaはシステムトレイで実行中です" }, - "activityWaiting": "Orca - アクティビティ待機中", - "settings": "設定...", - "checkForUpdates": "アップデートを確認..." + "activityWaiting": "Orca - アクティビティ待機中" }, "worktreeJumpPalette": { "matchLabel": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2a8d947cb8f..e0d9b4c1e9d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -72,9 +72,7 @@ "minimizeNotice": { "body": "Orca가 시스템 트레이에서 계속 실행 중입니다" }, - "activityWaiting": "Orca - 활동 대기 중", - "settings": "설정...", - "checkForUpdates": "업데이트 확인..." + "activityWaiting": "Orca - 활동 대기 중" }, "worktreeJumpPalette": { "matchLabel": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0e72c1d4808..05a7617ccc2 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -72,9 +72,7 @@ "minimizeNotice": { "body": "Orca 仍在系统托盘中运行" }, - "activityWaiting": "Orca - 活动等待中", - "settings": "设置...", - "checkForUpdates": "检查更新..." + "activityWaiting": "Orca - 活动等待中" }, "worktreeJumpPalette": { "matchLabel": {