Add native macOS menu bar status item with settings toggle

Extend the Windows-only system tray into a shared status-item module
so macOS gets a template menu bar icon (Open/Settings/Check for
Updates/Quit), a theme-aware attention dot, and a "Show Menu Bar
Icon" appearance setting. Also fixes a startup race where a tray
"Settings…" click could fire before the renderer's ui:openSettings
listener attached, by queuing a one-shot pending-open-settings intent
the renderer consumes on mount.
This commit is contained in:
Jinjing
2026-07-16 10:16:02 -07:00
parent 69c14fadce
commit 0f3f600ccd
30 changed files with 938 additions and 229 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 324 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 B

+103 -31
View File
@@ -118,7 +118,13 @@ import {
ensureAutoUpdaterConfigured
} from './window/attach-main-window-services'
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
import { createSystemTray, destroySystemTray, setTrayAttention } from './tray/system-tray'
import {
createSystemTray,
destroySystemTray,
setMacMenuBarIconVisible,
setTrayAttention,
type SystemTrayOptions
} from './tray/system-tray'
import { focusExistingMainWindow } from './window/focus-existing-window'
import { notifyMainWindowBecameVisible } from './window/main-window-visibility'
import { CodexAccountService } from './codex-accounts/service'
@@ -240,6 +246,10 @@ 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.
const pendingOpenSettings = createWebContentsTimedFlag()
let firstWindowStartupServicesReady: Promise<void> = Promise.resolve()
let managedWslCliReconciliationReady: Promise<void> = Promise.resolve()
let managedWslCliStartupBarrierReady: Promise<void> = Promise.resolve()
@@ -667,6 +677,12 @@ ipcMain.handle('app:awaitFirstWindowStartupServices', async () => {
await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady])
})
// Why: the renderer pulls this once its ui:openSettings listener is attached so
// a tray/menu-bar Settings request queued before mount is not lost to a race.
ipcMain.handle('ui:consumePendingOpenSettings', (event) =>
pendingOpenSettings.matches(event.sender.id, { consume: true })
)
ipcMain.handle(
'app:startupDiagnostic',
(_event, event: string, details?: Record<string, unknown>) => {
@@ -789,6 +805,65 @@ function showMainWindowFromTray(): void {
}
}
function openSettingsFromSystemMenu(): void {
const existingWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null
showMainWindowFromTray()
const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null
if (!targetWindow) {
return
}
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)
}
function quitFromSystemTray(): void {
if (mainWindow && !mainWindow.isDestroyed()) {
// Why: a real quit can still surface renderer save/discard prompts; the
// window must be visible if a hidden session vetoes shutdown.
showMainWindowFromTray()
}
// Why: set the quit latch before app.quit() so the window 'close' handler
// proceeds to teardown instead of re-hiding to the Windows tray.
isQuitting = true
app.quit()
}
function getSystemTrayOptions(): SystemTrayOptions | null {
if (!store) {
return null
}
return {
appIcon: store.getSettings().appIcon,
onOpen: showMainWindowFromTray,
onOpenSettings: openSettingsFromSystemMenu,
onCheckForUpdates: () => {
ensureAutoUpdaterConfigured()
checkForUpdatesFromMenu()
},
onQuit: quitFromSystemTray
}
}
function syncMacMenuBarIcon(showMenuBarIcon: boolean): void {
if (process.platform !== 'darwin' || isServeMode) {
return
}
const options = getSystemTrayOptions()
if (options) {
setMacMenuBarIconVisible(showMenuBarIcon, options)
}
}
function openMainWindow(): BrowserWindow {
logStartupMilestone('open-main-window-start')
if (!store) {
@@ -900,38 +975,31 @@ function openMainWindow(): BrowserWindow {
})
recordCrashBreadcrumb('main_window_created')
logStartupMilestone('window-created')
// Why: new Tray() is a synchronous Shell_NotifyIcon call that can block for
// seconds while explorer.exe's notification area is busy (part of issue
// #7225's pre-paint stall), so create it after first paint. The timer
// fallback covers windows revealed without ready-to-show ever firing
// (createMainWindow's 10s reveal fallback) — those can still be
// hidden to the tray on close, so the icon must exist by then.
// 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.
let trayCreated = false
const createSystemTrayDeferred = (): void => {
if (trayCreated || window.isDestroyed() || isQuitting || !store) {
return
}
trayCreated = true
// Why: Windows-only system tray. createSystemTray is idempotent and a
// no-op off win32, so calling it on each window open keeps exactly one
// live icon.
createSystemTray({
appIcon: store.getSettings().appIcon,
onOpen: showMainWindowFromTray,
onQuit: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
// Why: a real quit can still surface renderer save/discard prompts;
// the window must be visible if a hidden-to-tray session vetoes
// shutdown.
showMainWindowFromTray()
}
// Why: set the quit latch before app.quit() so the window 'close'
// handler proceeds to teardown instead of re-hiding to the tray.
isQuitting = true
app.quit()
}
})
logStartupMilestone('tray-created')
// 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)
) {
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')
}
}
window.once('ready-to-show', () => {
logStartupMilestone('ready-to-show')
@@ -1681,6 +1749,13 @@ app.whenReady().then(async () => {
const activeOrcaProfile = ensureActiveOrcaProfile()
store = new Store({ dataFile: activeOrcaProfile.dataFile })
logStartupMilestone('store-loaded')
store.onSettingsChanged((updates, settings) => {
if ('showMenuBarIcon' in updates) {
// Why: Store is the mutation authority for renderer, RPC, and future
// settings writes, so every macOS toggle updates the native item live.
syncMacMenuBarIcon(settings.showMenuBarIcon !== false)
}
})
// Why: must run before ClaudeRuntimeAuthService's constructor sync — a Claude
// CLI that survived the restart inside the daemon still holds the current
// single-use refresh token, and an unguarded early refresh would rotate it
@@ -2014,10 +2089,7 @@ app.whenReady().then(async () => {
}
recordCrashBreadcrumb('manual_reload_requested', { ignoreCache })
},
onOpenSettings: () => {
recordCrashBreadcrumb('settings_opened')
mainWindow?.webContents.send('ui:openSettings')
},
onOpenSettings: openSettingsFromSystemMenu,
onOpenSetupGuide: (targetWindow) => {
recordCrashBreadcrumb('setup_guide_opened')
const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null
+2 -2
View File
@@ -436,8 +436,8 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
// hold back the dot. It rides the notification dispatch, so it follows the
// renderer's per-source decision to notify: bells always reach here, while
// an agent completion is suppressed upstream when its notification is
// disabled. Tray exists only on Windows, so setTrayAttention no-ops
// elsewhere.
// disabled. The status item exists on Windows and macOS, so
// setTrayAttention lights its attention dot there and no-ops on Linux.
if (args.source === 'agent-task-complete' || args.source === 'terminal-bell') {
const activeWindow = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null
if (!isMainWindowVisible(activeWindow)) {
+52
View File
@@ -21,6 +21,7 @@ import type {
ProjectGroup,
ProjectHostSetup,
Repo,
GlobalSettings,
TerminalPaneLayoutNode,
TerminalTab,
WorktreeLineage,
@@ -648,6 +649,57 @@ describe('Store', () => {
expect(store.getSettings().minimizeToTrayOnClose).toBe(false)
})
it('defaults the menu bar icon on only for macOS', async () => {
await withPlatform('darwin', async () => {
const store = await createStore()
expect(store.getSettings().showMenuBarIcon).toBe(true)
})
await withPlatform('linux', async () => {
const store = await createStore()
expect(store.getSettings().showMenuBarIcon).toBe(false)
})
})
it('enables the menu bar icon when an existing macOS profile has no stored value', async () => {
await withPlatform('darwin', async () => {
const persisted = getDefaultPersistedState(testState.dir)
delete (persisted.settings as Partial<GlobalSettings>).showMenuBarIcon
writeDataFile(persisted)
const store = await createStore()
expect(store.getSettings().showMenuBarIcon).toBe(true)
})
})
it('persists an explicit macOS menu bar opt-out', async () => {
await withPlatform('darwin', async () => {
const store = await createStore()
store.updateSettings({ showMenuBarIcon: false })
store.flush()
expect((readDataFile() as PersistedState).settings.showMenuBarIcon).toBe(false)
expect((await createStore()).getSettings().showMenuBarIcon).toBe(false)
})
})
it('normalizes menu bar icon writes to a platform-scoped boolean', async () => {
await withPlatform('darwin', async () => {
const store = await createStore()
store.updateSettings({ showMenuBarIcon: 'true' as unknown as boolean })
expect(store.getSettings().showMenuBarIcon).toBe(false)
store.updateSettings({ showMenuBarIcon: true })
expect(store.getSettings().showMenuBarIcon).toBe(true)
})
await withPlatform('win32', async () => {
const store = await createStore()
store.updateSettings({ showMenuBarIcon: true })
expect(store.getSettings().showMenuBarIcon).toBe(false)
})
})
it('defaults trayMinimizeNoticeShown to false and persists it strictly', async () => {
const store = await createStore()
expect(store.getUI().trayMinimizeNoticeShown).toBe(false)
+8
View File
@@ -3200,6 +3200,10 @@ export class Store {
// Why: persisted settings can be user-edited or written by older
// builds; keep tray-minimize false unless the stored value is true.
minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true,
// Why: missing means default-on for existing macOS profiles, while
// other platforms must never gain a local menu-bar side effect.
showMenuBarIcon:
process.platform === 'darwin' && parsed.settings?.showMenuBarIcon !== false,
uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage),
defaultTaskSource: taskProviderSettings.defaultTaskSource,
visibleTaskProviders: taskProviderSettings.visibleTaskProviders,
@@ -5265,6 +5269,10 @@ export class Store {
if ('minimizeToTrayOnClose' in updates) {
sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true
}
if ('showMenuBarIcon' in updates) {
sanitizedUpdates.showMenuBarIcon =
process.platform === 'darwin' && updates.showMenuBarIcon === true
}
if ('disabledTuiAgents' in updates) {
sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents)
}
+275 -100
View File
@@ -2,19 +2,59 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as SystemTrayModule from './system-tray'
const {
trayInstances,
menuFromTemplateMock,
createAppIconImageMock,
attentionImage,
baseMacImage,
composeAttentionMock,
resizedImage
createAppIconImageMock,
createFromPathMock,
menuFromTemplateMock,
nativeThemeMock,
resizedImage,
retinaMacImage,
themeState,
tintedMacImage,
tintTemplateMock,
trayInstances
} = vi.hoisted(() => {
const resizedImage = { resized: true }
const makeImage = (name: string) => ({
name,
addRepresentation: vi.fn(),
getSize: vi.fn(() => ({ width: 16, height: 16 })),
setTemplateImage: vi.fn(),
toBitmap: vi.fn(() => Buffer.alloc(16 * 16 * 4)),
toDataURL: vi.fn(() => `data:image/png;base64,${name}`)
})
const resizedImage = makeImage('windows')
const baseMacImage = makeImage('mac-base')
const retinaMacImage = makeImage('mac-retina')
const tintedMacImage = makeImage('mac-tinted')
const attentionImage = makeImage('attention')
const themeState = { updatedListener: null as (() => void) | null }
const nativeThemeMock = {
shouldUseDarkColors: false,
on: vi.fn((_event: string, listener: () => void) => {
themeState.updatedListener = listener
}),
removeListener: vi.fn((_event: string, listener: () => void) => {
if (themeState.updatedListener === listener) {
themeState.updatedListener = null
}
})
}
return {
trayInstances: [] as FakeTray[],
menuFromTemplateMock: vi.fn((template: unknown) => ({ template })),
attentionImage,
baseMacImage,
composeAttentionMock: vi.fn(() => attentionImage),
createAppIconImageMock: vi.fn(),
composeAttentionMock: vi.fn((image: unknown) => ({ dotted: image })),
resizedImage
createFromPathMock: vi.fn(),
menuFromTemplateMock: vi.fn((template: unknown) => ({ template })),
nativeThemeMock,
resizedImage,
retinaMacImage,
themeState,
tintedMacImage,
tintTemplateMock: vi.fn(() => tintedMacImage),
trayInstances: [] as FakeTray[]
}
})
@@ -32,7 +72,17 @@ class FakeTray {
vi.mock('electron', () => ({
Tray: FakeTray,
Menu: { buildFromTemplate: menuFromTemplateMock }
Menu: { buildFromTemplate: menuFromTemplateMock },
nativeImage: { createFromPath: createFromPathMock },
nativeTheme: nativeThemeMock
}))
vi.mock('../../../resources/tray/orca-menu-barTemplate.png?asset&asarUnpack', () => ({
default: '/assets/orca-menu-barTemplate.png'
}))
vi.mock('../../../resources/tray/orca-menu-barTemplate@2x.png?asset&asarUnpack', () => ({
default: '/assets/orca-menu-barTemplate@2x.png'
}))
vi.mock('../app-icon', () => ({
@@ -40,10 +90,12 @@ vi.mock('../app-icon', () => ({
}))
vi.mock('./tray-attention-icon', () => ({
composeTrayAttentionIcon: composeAttentionMock
composeTrayAttentionIcon: composeAttentionMock,
tintTrayTemplateForAttention: tintTemplateMock
}))
type TrayModule = typeof SystemTrayModule
type MenuItem = { label?: string; type?: string; click?: () => void }
const originalPlatform = process.platform
@@ -56,7 +108,15 @@ async function loadModule(): Promise<TrayModule> {
return import('./system-tray')
}
type MenuItem = { label?: string; type?: string; click?: () => void }
function createOptions() {
return {
appIcon: 'classic',
onOpen: vi.fn(),
onOpenSettings: vi.fn(),
onCheckForUpdates: vi.fn(),
onQuit: vi.fn()
}
}
function builtMenuItems(): MenuItem[] {
return menuFromTemplateMock.mock.calls.at(-1)?.[0] as MenuItem[]
@@ -66,139 +126,254 @@ beforeEach(() => {
trayInstances.length = 0
menuFromTemplateMock.mockClear()
composeAttentionMock.mockClear()
tintTemplateMock.mockClear()
createAppIconImageMock.mockReset()
createAppIconImageMock.mockReturnValue({ resize: vi.fn(() => resizedImage) })
createFromPathMock.mockReset()
createFromPathMock.mockImplementation((path: string) =>
path.includes('@2x') ? retinaMacImage : baseMacImage
)
for (const image of [baseMacImage, retinaMacImage, tintedMacImage, attentionImage]) {
image.addRepresentation.mockClear()
image.getSize.mockReset().mockReturnValue({ width: 16, height: 16 })
image.setTemplateImage.mockClear()
image.toBitmap.mockClear()
image.toDataURL.mockClear().mockReturnValue(`data:image/png;base64,${image.name}`)
}
nativeThemeMock.shouldUseDarkColors = false
nativeThemeMock.on.mockClear()
nativeThemeMock.removeListener.mockClear()
themeState.updatedListener = null
})
afterEach(() => {
setPlatform(originalPlatform)
vi.restoreAllMocks()
})
describe('createSystemTray', () => {
it('creates a tray with an Orca tooltip and Open/Quit menu on win32', async () => {
it('keeps the Windows icon, Open/Quit menu, and left-click behavior', async () => {
setPlatform('win32')
const { createSystemTray } = await loadModule()
const onOpen = vi.fn()
const onQuit = vi.fn()
const options = createOptions()
const tray = createSystemTray({ appIcon: 'classic', onOpen, onQuit })
createSystemTray(options)
expect(tray).not.toBeNull()
expect(trayInstances).toHaveLength(1)
expect(trayInstances[0].image).toBe(resizedImage)
expect(trayInstances[0].setToolTip).toHaveBeenCalledWith('Orca')
const items = builtMenuItems()
expect(items.map((i) => i.label)).toEqual(['Open Orca', undefined, 'Quit'])
expect(items[1].type).toBe('separator')
expect(builtMenuItems().map((item) => item.label)).toEqual(['Open Orca', undefined, 'Quit'])
const clickHandler = trayInstances[0].on.mock.calls.find((call) => call[0] === 'click')?.[1]
expect(clickHandler).toBeTypeOf('function')
builtMenuItems()[0].click?.()
;(clickHandler as () => void)()
builtMenuItems()[2].click?.()
expect(options.onOpen).toHaveBeenCalledTimes(2)
expect(options.onQuit).toHaveBeenCalledOnce()
})
it('wires Open Orca, the tray click, and Quit to their callbacks', async () => {
setPlatform('win32')
const { createSystemTray } = await loadModule()
const onOpen = vi.fn()
const onQuit = vi.fn()
createSystemTray({ appIcon: 'classic', onOpen, onQuit })
const items = builtMenuItems()
items.find((i) => i.label === 'Open Orca')?.click?.()
expect(onOpen).toHaveBeenCalledTimes(1)
const clickHandler = trayInstances[0].on.mock.calls.find((c) => c[0] === 'click')?.[1] as
| (() => void)
| undefined
clickHandler?.()
expect(onOpen).toHaveBeenCalledTimes(2)
items.find((i) => i.label === 'Quit')?.click?.()
expect(onQuit).toHaveBeenCalledTimes(1)
})
it('is idempotent: a second call does not create a duplicate tray', async () => {
setPlatform('win32')
const { createSystemTray } = await loadModule()
const opts = { appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }
const first = createSystemTray(opts)
const second = createSystemTray(opts)
expect(trayInstances).toHaveLength(1)
expect(second).toBe(first)
})
it('is a no-op on non-win32 platforms', async () => {
it('creates a macOS template status item with the full native menu', async () => {
setPlatform('darwin')
const { createSystemTray } = await loadModule()
const options = createOptions()
const tray = createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() })
createSystemTray(options)
expect(tray).toBeNull()
expect(trayInstances).toHaveLength(1)
expect(trayInstances[0].image).toBe(baseMacImage)
expect(baseMacImage.setTemplateImage).toHaveBeenCalledWith(true)
expect(baseMacImage.addRepresentation).toHaveBeenCalledWith({
scaleFactor: 2,
dataURL: 'data:image/png;base64,mac-retina'
})
expect(builtMenuItems().map((item) => item.label)).toEqual([
'Open Orca',
undefined,
'Settings...',
'Check for Updates...',
undefined,
'Quit'
])
expect(trayInstances[0].on).not.toHaveBeenCalled()
expect(nativeThemeMock.on).toHaveBeenCalledWith('updated', expect.any(Function))
for (const [label, callback] of [
['Open Orca', options.onOpen],
['Settings...', options.onOpenSettings],
['Check for Updates...', options.onCheckForUpdates],
['Quit', options.onQuit]
] as const) {
builtMenuItems()
.find((item) => item.label === label)
?.click?.()
expect(callback).toHaveBeenCalledOnce()
}
})
it('does not create a blank macOS item when the template asset fails to load', async () => {
setPlatform('darwin')
baseMacImage.getSize.mockReturnValue({ width: 0, height: 0 })
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { createSystemTray } = await loadModule()
expect(createSystemTray(createOptions())).toBeNull()
expect(trayInstances).toHaveLength(0)
expect(warn).toHaveBeenCalledWith('[system-tray] macOS menu bar icon could not be loaded')
})
it('is idempotent and remains disabled on Linux', async () => {
setPlatform('darwin')
const { createSystemTray } = await loadModule()
const options = createOptions()
expect(createSystemTray(options)).toBe(createSystemTray(options))
expect(trayInstances).toHaveLength(1)
setPlatform('linux')
const linuxModule = await loadModule()
expect(linuxModule.createSystemTray(options)).toBeNull()
})
})
describe('macOS visibility', () => {
it('creates and destroys the item as the setting toggles', async () => {
setPlatform('darwin')
const { setMacMenuBarIconVisible } = await loadModule()
const options = createOptions()
expect(setMacMenuBarIconVisible(false, options)).toBeNull()
expect(trayInstances).toHaveLength(0)
expect(setMacMenuBarIconVisible(true, options)).not.toBeNull()
expect(trayInstances).toHaveLength(1)
setMacMenuBarIconVisible(false, options)
setMacMenuBarIconVisible(false, options)
expect(trayInstances[0].destroy).toHaveBeenCalledOnce()
})
})
describe('setTrayAttention', () => {
it('swaps in the dotted icon when active and restores the base when cleared', async () => {
it('preserves the Windows attention swap behavior', async () => {
setPlatform('win32')
const { createSystemTray, setTrayAttention } = await loadModule()
createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() })
const tray = trayInstances[0]
tray.setImage.mockClear()
createSystemTray(createOptions())
const created = trayInstances[0]
created.setImage.mockClear()
setTrayAttention(true)
expect(composeAttentionMock).toHaveBeenCalledWith(resizedImage)
expect(tray.setImage).toHaveBeenCalledWith({ dotted: resizedImage })
tray.setImage.mockClear()
expect(created.setImage).toHaveBeenCalledWith(attentionImage)
setTrayAttention(false)
expect(tray.setImage).toHaveBeenCalledWith(resizedImage)
expect(created.setImage).toHaveBeenLastCalledWith(resizedImage)
})
it('ignores repeated same-state calls', async () => {
setPlatform('win32')
const { createSystemTray, setTrayAttention } = await loadModule()
createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() })
const tray = trayInstances[0]
tray.setImage.mockClear()
setTrayAttention(true)
setTrayAttention(true)
expect(tray.setImage).toHaveBeenCalledTimes(1)
})
it('reflects attention that was requested before the tray was created', async () => {
setPlatform('win32')
const { createSystemTray, setTrayAttention } = await loadModule()
// Fire the event before the (deferred) tray exists.
setTrayAttention(true)
createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() })
const tray = trayInstances[0]
expect(tray.setImage).toHaveBeenCalledWith({ dotted: resizedImage })
})
it('is a safe no-op on non-win32 platforms', async () => {
it('uses a literal theme-aware glyph for macOS attention, then restores template mode', async () => {
setPlatform('darwin')
const { setTrayAttention } = await loadModule()
const { createSystemTray, setTrayAttention } = await loadModule()
createSystemTray(createOptions())
const created = trayInstances[0]
created.setImage.mockClear()
created.setToolTip.mockClear()
expect(() => setTrayAttention(true)).not.toThrow()
expect(composeAttentionMock).not.toHaveBeenCalled()
setTrayAttention(true)
expect(tintTemplateMock).toHaveBeenCalledWith(baseMacImage, false)
expect(composeAttentionMock).toHaveBeenCalledWith(tintedMacImage)
expect(attentionImage.setTemplateImage).toHaveBeenCalledWith(false)
expect(created.setImage).toHaveBeenCalledWith(attentionImage)
expect(created.setToolTip).toHaveBeenCalledWith('Orca - activity waiting')
setTrayAttention(false)
expect(baseMacImage.setTemplateImage).toHaveBeenLastCalledWith(true)
expect(created.setImage).toHaveBeenLastCalledWith(baseMacImage)
expect(created.setToolTip).toHaveBeenLastCalledWith('Orca')
})
it('recomposes active macOS attention when the system appearance changes', async () => {
setPlatform('darwin')
const { createSystemTray, setTrayAttention } = await loadModule()
createSystemTray(createOptions())
setTrayAttention(true)
tintTemplateMock.mockClear()
nativeThemeMock.shouldUseDarkColors = true
themeState.updatedListener?.()
expect(tintTemplateMock).toHaveBeenCalledWith(baseMacImage, true)
})
it('reflects attention requested before deferred macOS creation', async () => {
setPlatform('darwin')
const { createSystemTray, setTrayAttention } = await loadModule()
setTrayAttention(true)
createSystemTray(createOptions())
expect(trayInstances[0].setImage).toHaveBeenCalledWith(attentionImage)
})
})
describe('destroySystemTray', () => {
it('destroys an existing tray and is safe to call without one', async () => {
setPlatform('win32')
it('destroys idempotently and removes the macOS appearance listener', async () => {
setPlatform('darwin')
const { createSystemTray, destroySystemTray } = await loadModule()
createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() })
createSystemTray(createOptions())
const created = trayInstances[0]
const listener = themeState.updatedListener
destroySystemTray()
expect(created.destroy).toHaveBeenCalledTimes(1)
destroySystemTray()
// Second call with no live tray must not throw.
expect(() => destroySystemTray()).not.toThrow()
expect(created.destroy).toHaveBeenCalledOnce()
expect(nativeThemeMock.removeListener).toHaveBeenCalledWith('updated', listener)
})
})
describe('macOS hardening', () => {
it('warns but still creates the item when only the @2x asset fails to load', async () => {
setPlatform('darwin')
retinaMacImage.getSize.mockReturnValue({ width: 0, height: 0 })
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { createSystemTray } = await loadModule()
expect(createSystemTray(createOptions())).not.toBeNull()
expect(trayInstances).toHaveLength(1)
expect(baseMacImage.addRepresentation).not.toHaveBeenCalled()
expect(warn).toHaveBeenCalledWith(
'[system-tray] macOS retina menu bar icon could not be loaded'
)
})
it('degrades to the plain icon instead of throwing when attention composition fails', async () => {
setPlatform('darwin')
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { createSystemTray, setTrayAttention } = await loadModule()
createSystemTray(createOptions())
const created = trayInstances[0]
created.setImage.mockClear()
composeAttentionMock.mockImplementationOnce(() => {
throw new Error('native image failure')
})
expect(() => setTrayAttention(true)).not.toThrow()
expect(created.setImage).toHaveBeenLastCalledWith(baseMacImage)
expect(created.setToolTip).toHaveBeenLastCalledWith('Orca')
expect(warn).toHaveBeenCalledWith(
'[system-tray] macOS attention icon failed; showing plain icon',
expect.any(Error)
)
})
it('contains a throwing menu-item callback instead of crashing', async () => {
setPlatform('darwin')
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const { createSystemTray } = await loadModule()
const options = createOptions()
options.onOpenSettings.mockImplementation(() => {
throw new Error('boom')
})
createSystemTray(options)
const settingsItem = builtMenuItems().find((item) => item.label === 'Settings...')
expect(() => settingsItem?.click?.()).not.toThrow()
expect(error).toHaveBeenCalledWith('[system-tray] menu action failed', expect.any(Error))
})
})
+176 -22
View File
@@ -1,13 +1,19 @@
import { Menu, Tray, type NativeImage } from 'electron'
import { Menu, Tray, nativeImage, nativeTheme, type NativeImage } from 'electron'
import menuBarIconPath from '../../../resources/tray/orca-menu-barTemplate.png?asset&asarUnpack'
import menuBarIconRetinaPath from '../../../resources/tray/orca-menu-barTemplate@2x.png?asset&asarUnpack'
import { createAppIconImage } from '../app-icon'
import { translateMain } from '../i18n/main-i18n'
import { composeTrayAttentionIcon } from './tray-attention-icon'
import { composeTrayAttentionIcon, tintTrayTemplateForAttention } from './tray-attention-icon'
type SystemTrayOptions = {
export type SystemTrayOptions = {
/** App icon id from settings; the tray reuses the app icon image. */
appIcon: unknown
/** Restore + show + focus the main window (recreating it if needed). */
onOpen: () => void
/** Restore the main window and open its Settings surface. */
onOpenSettings: () => void
/** Run the existing user-initiated update check. */
onCheckForUpdates: () => void
/** Quit Orca for real (caller must set the quitting latch before quitting). */
onQuit: () => void
}
@@ -25,6 +31,8 @@ let baseTrayImage: NativeImage | null = null
// freshly created tray reflects it immediately.
let attentionActive = false
let nativeThemeUpdatedListener: (() => void) | null = null
// Why: on Windows the notification area expects a 16px icon; the app icon PNG
// is larger, so downscale to avoid a cropped/blurry tray glyph.
const TRAY_ICON_SIZE = 16
@@ -35,46 +43,191 @@ function applyTrayImage(): void {
if (!tray || tray.isDestroyed() || !baseTrayImage) {
return
}
if (process.platform === 'darwin') {
if (attentionActive) {
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 attentionImage = composeTrayAttentionIcon(tintedGlyph)
attentionImage.setTemplateImage(false)
tray.setImage(attentionImage)
tray.setToolTip(translateMain('tray.activityWaiting', 'Orca - activity waiting'))
return
} catch (error) {
// Why: this path runs inside unguarded callbacks (nativeTheme 'updated',
// the notification bell), so a NativeImage failure must degrade to the
// plain template icon rather than throw an uncaught exception into them.
console.warn('[system-tray] macOS attention icon failed; showing plain icon', error)
}
}
baseTrayImage.setTemplateImage(true)
tray.setImage(baseTrayImage)
tray.setToolTip('Orca')
return
}
tray.setImage(attentionActive ? composeTrayAttentionIcon(baseTrayImage) : baseTrayImage)
}
function createMacMenuBarImage(): NativeImage | null {
const image = nativeImage.createFromPath(menuBarIconPath)
const { width, height } = image.getSize()
if (width <= 0 || height <= 0) {
console.warn('[system-tray] macOS menu bar icon could not be loaded')
return null
}
const retinaImage = nativeImage.createFromPath(menuBarIconRetinaPath)
const retinaSize = retinaImage.getSize()
if (retinaSize.width > 0 && retinaSize.height > 0) {
try {
// Why: importing the @2x asset guarantees it is packaged; adding the
// representation explicitly avoids relying on bundler-renamed siblings.
image.addRepresentation({
scaleFactor: 2,
dataURL: retinaImage.toDataURL()
})
} catch (error) {
// Why: a bad @2x representation must not abort tray creation; the 1x image
// still renders (just blurrier on Retina).
console.warn('[system-tray] macOS retina menu bar icon could not be added', error)
}
} else {
// Why: surface the silently-degraded (non-Retina) icon so a blurry menu-bar
// glyph on a modern display is diagnosable, matching the base-image warning.
console.warn('[system-tray] macOS retina menu bar icon could not be loaded')
}
image.setTemplateImage(true)
return image
}
function watchMacAppearance(): void {
if (nativeThemeUpdatedListener) {
return
}
nativeThemeUpdatedListener = () => {
if (attentionActive) {
applyTrayImage()
}
}
nativeTheme.on('updated', nativeThemeUpdatedListener)
}
function stopWatchingMacAppearance(): void {
if (!nativeThemeUpdatedListener) {
return
}
nativeTheme.removeListener('updated', nativeThemeUpdatedListener)
nativeThemeUpdatedListener = null
}
// Why: Electron Menu/Tray click callbacks are plain event listeners (not
// promise-wrapped like ipcMain.handle), so an uncaught throw here is fatal to
// the main process; contain it to a logged, failed menu action instead.
function safeMenuAction(action: () => void): () => void {
return () => {
try {
action()
} catch (error) {
console.error('[system-tray] menu action failed', error)
}
}
}
/**
* Creates the Windows system tray icon. No-op on macOS/Linux. Idempotent: a
* second call while a tray is alive returns the existing one instead of
* stacking a duplicate ghost icon.
* Creates the Windows notification icon or macOS menu bar status item. No-op
* on Linux. Idempotent: repeated calls never stack duplicate icons.
*/
export function createSystemTray(opts: SystemTrayOptions): Tray | null {
if (process.platform !== 'win32') {
if (process.platform !== 'win32' && process.platform !== 'darwin') {
return null
}
if (tray && !tray.isDestroyed()) {
return tray
}
baseTrayImage = createAppIconImage(opts.appIcon).resize({
width: TRAY_ICON_SIZE,
height: TRAY_ICON_SIZE
})
if (process.platform === 'darwin') {
baseTrayImage = createMacMenuBarImage()
if (!baseTrayImage) {
return null
}
} else {
baseTrayImage = createAppIconImage(opts.appIcon).resize({
width: TRAY_ICON_SIZE,
height: TRAY_ICON_SIZE
})
}
tray = new Tray(baseTrayImage)
// Why: reflect any attention event that fired before the tray existed.
applyTrayImage()
tray.setToolTip('Orca')
const menu = Menu.buildFromTemplate([
{ label: translateMain('tray.openOrca', 'Open Orca'), click: () => opts.onOpen() },
{ type: 'separator' },
{ label: translateMain('tray.quit', 'Quit'), click: () => opts.onQuit() }
])
const menu = Menu.buildFromTemplate(
process.platform === 'darwin'
? [
{
label: translateMain('tray.openOrca', 'Open Orca'),
click: safeMenuAction(() => opts.onOpen())
},
{ type: 'separator' },
{
label: translateMain('tray.settings', 'Settings...'),
click: safeMenuAction(() => opts.onOpenSettings())
},
{
label: translateMain('tray.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()) }
]
)
tray.setContextMenu(menu)
// Why: a left-click on the tray icon is the conventional Windows gesture to
// restore a minimized-to-tray app.
tray.on('click', () => opts.onOpen())
if (process.platform === 'win32') {
tray.setToolTip('Orca')
// Why: a left-click on the tray icon is the conventional Windows gesture to
// restore a minimized-to-tray app; macOS opens the attached menu instead.
tray.on(
'click',
safeMenuAction(() => opts.onOpen())
)
} else {
watchMacAppearance()
}
return tray
}
/** Applies the persisted macOS visibility preference without affecting Windows. */
export function setMacMenuBarIconVisible(visible: boolean, opts: SystemTrayOptions): Tray | null {
if (process.platform !== 'darwin') {
return null
}
if (!visible) {
destroySystemTray()
return null
}
return createSystemTray(opts)
}
/**
* Shows or hides a red/amber attention dot on the tray icon. Call with `true`
* when a terminal bell or agent completion fires while the window is
* minimized/hidden, and `false` once the window is shown again. No-op on
* macOS/Linux (no tray) and safe to call before the tray is created.
* minimized/hidden, and `false` once the window is shown again. Safe to call
* before the tray is created or on Linux where no tray is available.
*/
export function setTrayAttention(active: boolean): void {
if (attentionActive === active) {
@@ -86,6 +239,7 @@ export function setTrayAttention(active: boolean): void {
/** Destroys the tray icon if present. Safe to call repeatedly or with no tray. */
export function destroySystemTray(): void {
stopWatchingMacAppearance()
if (tray && !tray.isDestroyed()) {
tray.destroy()
}
+30 -1
View File
@@ -12,7 +12,7 @@ vi.mock('electron', () => ({
nativeImage: { createFromBitmap: createFromBitmapMock }
}))
import { composeTrayAttentionIcon } from './tray-attention-icon'
import { composeTrayAttentionIcon, tintTrayTemplateForAttention } from './tray-attention-icon'
type FakeImage = {
getSize: () => { width: number; height: number }
@@ -86,3 +86,32 @@ describe('composeTrayAttentionIcon', () => {
expect(paintedInBottomLeft).toBe(0) // the opposite corner is never touched
})
})
describe('tintTrayTemplateForAttention', () => {
it('preserves alpha while selecting literal black pixels for a light menu bar', () => {
createFromBitmapMock.mockClear()
const bitmap = Buffer.from([0x22, 0x33, 0x44, 0x80, 0xaa, 0xbb, 0xcc, 0x00])
const base = {
getSize: () => ({ width: 2, height: 1 }),
toBitmap: () => bitmap
}
tintTrayTemplateForAttention(base as never, false)
expect(createFromBitmapMock.mock.calls[0][0]).toEqual(
Buffer.from([0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00])
)
})
it('selects literal white pixels for a dark menu bar', () => {
createFromBitmapMock.mockClear()
const base = {
getSize: () => ({ width: 1, height: 1 }),
toBitmap: () => Buffer.from([0x00, 0x00, 0x00, 0xff])
}
tintTrayTemplateForAttention(base as never, true)
expect(createFromBitmapMock.mock.calls[0][0]).toEqual(Buffer.from([0xff, 0xff, 0xff, 0xff]))
})
})
+24
View File
@@ -7,6 +7,30 @@ const DOT_RGB = { r: 0xf5, g: 0x9e, b: 0x0b }
// A near-white halo separates the dot from the icon glyph on any tray theme.
const RING_RGB = { r: 0xff, g: 0xff, b: 0xff }
/**
* Converts a template mask into a literal glyph for the attention state. A
* non-template image needs real RGB values because macOS no longer tints it.
*/
export function tintTrayTemplateForAttention(
base: NativeImage,
useLightGlyph: boolean
): NativeImage {
const { width, height } = base.getSize()
if (width <= 0 || height <= 0) {
return base
}
const bitmap = Buffer.from(base.toBitmap())
const channel = useLightGlyph ? 0xff : 0x00
for (let offset = 0; offset < bitmap.length; offset += 4) {
bitmap[offset] = channel
bitmap[offset + 1] = channel
bitmap[offset + 2] = channel
}
return nativeImage.createFromBitmap(bitmap, { width, height })
}
/**
* Returns a copy of `base` with a small amber attention dot composited into the
* top-right corner. Electron's NativeImage has no compositing API, so we merge
+2
View File
@@ -2752,6 +2752,8 @@ export type PreloadApi = {
recordFeatureInteraction: (id: FeatureInteractionId) => Promise<PersistedUIState>
onStateChanged: (callback: (ui: PersistedUIState) => void) => () => void
onOpenSettings: (callback: () => void) => () => void
/** Consumes a one-shot tray/menu-bar "open settings" intent queued before mount. */
consumePendingOpenSettings: () => Promise<boolean>
onOpenSetupGuide: (callback: () => void) => () => void
onOpenFeatureTour: (callback: () => void) => () => void
onOpenCrashReport: (callback: () => void) => () => void
+2
View File
@@ -3167,6 +3167,8 @@ const api = {
ipcRenderer.on('ui:openSettings', listener)
return () => ipcRenderer.removeListener('ui:openSettings', listener)
},
consumePendingOpenSettings: (): Promise<boolean> =>
ipcRenderer.invoke('ui:consumePendingOpenSettings'),
onOpenSetupGuide: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('ui:openSetupGuide', listener)
@@ -17,6 +17,7 @@ import {
import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
import {
getLanguageEntries,
getMenuBarIconEntries,
getSystemTrayEntries,
getThemeEntries,
getTitlebarEntries,
@@ -37,6 +38,7 @@ type AppearanceInterfaceSectionProps = {
updateSettings: (updates: Partial<GlobalSettings>) => void
applyTheme: (theme: 'system' | 'dark' | 'light') => void
fontSuggestions: string[]
isDesktopMac: boolean
isDesktopWindows: boolean
onRequestFontSuggestions?: () => void
forceVisiblePrimary?: boolean
@@ -47,6 +49,7 @@ export function AppearanceInterfaceSection({
updateSettings,
applyTheme,
fontSuggestions,
isDesktopMac,
isDesktopWindows,
onRequestFontSuggestions,
forceVisiblePrimary = false
@@ -56,6 +59,7 @@ export function AppearanceInterfaceSection({
const zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in')
const zoomOutKeyCombos = useShortcutKeyComboDetails('zoom.out')
const languageEntry = getLanguageEntries()[0]
const menuBarIconEntry = getMenuBarIconEntries({ showMenuBarIcon: true })[0]
const systemTrayEntry = getSystemTrayEntries({ showSystemTray: true })[0]
const themeEntry = getThemeEntries()[0]
const themeLabel = translate('auto.components.settings.AppearancePane.932ff1fbff', 'Theme')
@@ -65,7 +69,8 @@ export function AppearanceInterfaceSection({
const advancedEntries = [
...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []),
...getTitlebarEntries(),
...getSystemTrayEntries({ showSystemTray: isDesktopWindows })
...getSystemTrayEntries({ showSystemTray: isDesktopWindows }),
...getMenuBarIconEntries({ showMenuBarIcon: isDesktopMac })
]
const showAdvanced = !isSearching || matchesSettingsSearch(searchQuery, advancedEntries)
@@ -236,6 +241,28 @@ export function AppearanceInterfaceSection({
/>
</SearchableSetting>
) : null}
{isDesktopMac ? (
<SearchableSetting
title={translate('settings.appearance.menuBarIcon.title', 'Show Menu Bar Icon')}
description={menuBarIconEntry?.description}
keywords={menuBarIconEntry?.keywords ?? ['menu bar', 'status item', 'activity']}
>
<SettingsSwitchRow
label={translate('settings.appearance.menuBarIcon.title', 'Show Menu Bar Icon')}
// Why: this opt-out removes only the status item; macOS Dock
// activation and the close-keeps-running lifecycle stay intact.
description={translate(
'settings.appearance.menuBarIcon.description',
'Keep an Orca shortcut and activity indicator in the macOS menu bar.'
)}
checked={settings.showMenuBarIcon !== false}
onChange={() =>
updateSettings({ showMenuBarIcon: settings.showMenuBarIcon === false })
}
/>
</SearchableSetting>
) : null}
</div>
</AppearanceAdvancedDisclosure>
) : null}
@@ -10,6 +10,7 @@ import type { GlobalSettings, StatusBarItem } from '../../../../shared/types'
const mocks = vi.hoisted(() => ({
state: {
appPlatform: 'linux' as NodeJS.Platform,
availableStatusBarToggles: [] as {
description: string
id: StatusBarItem
@@ -179,12 +180,16 @@ describe('AppearancePane', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.state.availableStatusBarToggles = []
mocks.state.appPlatform = 'linux'
mocks.state.settingsSearchQuery = 'automations'
mocks.state.usagePercentageDisplay = 'used'
// UIZoomControl reads window.api.ui on mount; the inline-expansion pane can
// render the full Interface section, so provide a minimal renderer bridge
// without clobbering happy-dom's window.location.
;(window as unknown as { api: unknown }).api = {
platform: {
get: () => ({ platform: mocks.state.appPlatform })
},
ui: {
getZoomLevel: () => 0,
onTerminalZoom: () => () => {},
@@ -359,6 +364,26 @@ describe('AppearancePane', () => {
).not.toBeNull()
})
it('shows and updates the menu bar icon preference only on desktop macOS', async () => {
mocks.state.appPlatform = 'darwin'
mocks.state.settingsSearchQuery = 'menu bar'
const updateSettings = vi.fn()
const container = await renderAppearancePane(
getDefaultSettings('/tmp', 'darwin'),
updateSettings
)
const toggle = container.querySelector<HTMLButtonElement>(
'button[role="switch"][aria-label="Show Menu Bar Icon"]'
)
expect(toggle).not.toBeNull()
expect(container.textContent).not.toContain('Minimize to Tray on Close')
await act(async () => {
toggle?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(updateSettings).toHaveBeenCalledWith({ showMenuBarIcon: false })
})
it('keeps description-only search matches visible after helper text is hidden', async () => {
mocks.state.settingsSearchQuery = 'app window'
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
@@ -16,6 +16,7 @@ import {
getAppearancePaneSearchEntries,
getLanguageEntries,
getLayoutEntries,
getMenuBarIconEntries,
getSidebarEntries,
getStatusBarEntries,
getSystemTrayEntries,
@@ -85,6 +86,7 @@ export function AppearancePane({
// Why: the system tray behavior is desktop-Electron Windows-only; a Windows
// browser web client has no local tray to control.
const isDesktopWindows = getRendererAppPlatform() === 'win32' && !isWebClient
const isDesktopMac = getRendererAppPlatform() === 'darwin' && !isWebClient
const [manuallyOpenSection, setManuallyOpenSection] = useState<AppearanceSectionKey | null>(
'interface'
@@ -134,7 +136,8 @@ export function AppearancePane({
...getTypographyEntries(),
...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []),
...getTitlebarEntries(),
...getSystemTrayEntries({ showSystemTray: isDesktopWindows })
...getSystemTrayEntries({ showSystemTray: isDesktopWindows }),
...getMenuBarIconEntries({ showMenuBarIcon: isDesktopMac })
]
const terminalSearchEntries = [
{ title: terminalTitle },
@@ -207,6 +210,7 @@ export function AppearancePane({
applyTheme={applyTheme}
fontSuggestions={fontSuggestions}
onRequestFontSuggestions={onRequestFontSuggestions}
isDesktopMac={isDesktopMac}
isDesktopWindows={isDesktopWindows}
forceVisiblePrimary={interfaceLabelMatches}
/>
@@ -2,15 +2,19 @@ import type { SettingsSearchEntry } from './settings-search'
import { getTerminalAppearanceSearchEntries } from './terminal-search'
import { getLeftSidebarAppearanceEntry, getSidebarEntries } from './appearance-sidebar-search'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { getRendererAppPlatform } from '@/lib/renderer-app-platform'
import { isWebClientLocation } from '@/lib/web-client-location'
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
import { SHOW_UI_LANGUAGE_SETTING } from '@/i18n/supported-languages'
import { getStatusBarToggles } from './appearance-status-bar-search'
import { getUsagePercentageDisplayEntry } from './appearance-usage-percentage-search'
import { getMenuBarIconEntries, getSystemTrayEntries } from './appearance-system-presence-search'
export { getStatusBarToggles, getUsagePercentageDisplayEntry }
export {
getMenuBarIconEntries,
getStatusBarToggles,
getSystemTrayEntries,
getUsagePercentageDisplayEntry
}
export const getThemeEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
{
@@ -188,65 +192,6 @@ export const getAppIconEntries = createLocalizedCatalog((): SettingsSearchEntry[
}
])
const getSystemTrayEntryCatalog = createLocalizedCatalog((): SettingsSearchEntry[] => [
{
title: translate(
'auto.components.settings.appearance.search.9a115966d3',
'Minimize to Tray on Close'
),
description: translate(
'auto.components.settings.appearance.search.4d5b9427b5',
'When enabled, closing the window keeps Orca running in the system tray instead of quitting.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.appearance.search.tray.tray', 'tray', {
englishOnly: true
}),
...translateSearchKeyword(
'auto.components.settings.appearance.search.tray.system',
'system tray',
{ englishOnly: true }
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.tray.minimize',
'minimize',
{ englishOnly: true }
),
...translateSearchKeyword('auto.components.settings.appearance.search.tray.close', 'close', {
englishOnly: true
}),
...translateSearchKeyword('auto.components.settings.appearance.search.e5bc35d59e', 'window'),
...translateSearchKeyword(
'auto.components.settings.appearance.search.tray.notification',
'notification area',
{ englishOnly: true }
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.tray.background',
'background',
{ englishOnly: true }
)
]
}
])
type SystemTraySearchOptions = {
showSystemTray?: boolean
}
function shouldShowSystemTrayEntries(options: SystemTraySearchOptions): boolean {
return (
options.showSystemTray ??
// Why: this setting controls Electron's Windows tray only. A Windows web
// browser can report win32, but it has no local tray to affect.
(getRendererAppPlatform() === 'win32' && !isWebClientLocation())
)
}
export function getSystemTrayEntries(options: SystemTraySearchOptions = {}): SettingsSearchEntry[] {
return shouldShowSystemTrayEntries(options) ? getSystemTrayEntryCatalog() : []
}
const getAppearanceSectionEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
{
title: translate('auto.components.settings.AppearancePane.interfaceTitle', 'Interface')
@@ -269,6 +214,7 @@ const getAppearanceSectionEntries = createLocalizedCatalog((): SettingsSearchEnt
type AppearancePaneSearchOptions = {
showWarpImport?: boolean
showSystemTray?: boolean
showMenuBarIcon?: boolean
}
function buildAppearancePaneSearchEntries(
@@ -286,7 +232,8 @@ function buildAppearancePaneSearchEntries(
...getStatusBarEntries(),
...getSidebarEntries(),
...getAppIconEntries(),
...getSystemTrayEntries(options)
...getSystemTrayEntries(options),
...getMenuBarIconEntries(options)
]
}
@@ -295,6 +242,7 @@ export function getAppearancePaneSearchEntries(
): SettingsSearchEntry[] {
return buildAppearancePaneSearchEntries({
showWarpImport: options.showWarpImport ?? true,
showSystemTray: options.showSystemTray
showSystemTray: options.showSystemTray,
showMenuBarIcon: options.showMenuBarIcon
})
}
@@ -0,0 +1,103 @@
import { translate } from '@/i18n/i18n'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { getRendererAppPlatform } from '@/lib/renderer-app-platform'
import { isWebClientLocation } from '@/lib/web-client-location'
import type { SettingsSearchEntry } from './settings-search'
import { translateSearchKeyword } from './settings-search-keywords'
const getSystemTrayEntryCatalog = createLocalizedCatalog((): SettingsSearchEntry[] => [
{
title: translate(
'auto.components.settings.appearance.search.9a115966d3',
'Minimize to Tray on Close'
),
description: translate(
'auto.components.settings.appearance.search.4d5b9427b5',
'When enabled, closing the window keeps Orca running in the system tray instead of quitting.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.appearance.search.tray.tray', 'tray', {
englishOnly: true
}),
...translateSearchKeyword(
'auto.components.settings.appearance.search.tray.system',
'system tray',
{ englishOnly: true }
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.tray.minimize',
'minimize',
{ englishOnly: true }
),
...translateSearchKeyword('auto.components.settings.appearance.search.tray.close', 'close', {
englishOnly: true
}),
...translateSearchKeyword('auto.components.settings.appearance.search.e5bc35d59e', 'window'),
...translateSearchKeyword(
'auto.components.settings.appearance.search.tray.notification',
'notification area',
{ englishOnly: true }
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.tray.background',
'background',
{ englishOnly: true }
)
]
}
])
const getMenuBarIconEntryCatalog = createLocalizedCatalog((): SettingsSearchEntry[] => [
{
title: translate('settings.appearance.menuBarIcon.title', 'Show Menu Bar Icon'),
description: translate(
'settings.appearance.menuBarIcon.description',
'Keep an Orca shortcut and activity indicator in the macOS menu bar.'
),
keywords: [
...translateSearchKeyword('settings.appearance.menuBarIcon.keyword.menuBar', 'menu bar', {
englishOnly: true
}),
...translateSearchKeyword('auto.components.settings.appearance.search.tray.tray', 'tray', {
englishOnly: true
}),
...translateSearchKeyword('auto.components.settings.appearance.search.1f2880a9d5', 'orca'),
...translateSearchKeyword(
'settings.appearance.menuBarIcon.keyword.statusItem',
'status item',
{ englishOnly: true }
),
...translateSearchKeyword('settings.appearance.menuBarIcon.keyword.activity', 'activity', {
englishOnly: true
}),
...translateSearchKeyword(
'auto.components.settings.appearance.search.tray.background',
'background',
{ englishOnly: true }
)
]
}
])
type SystemPresenceSearchOptions = {
showMenuBarIcon?: boolean
showSystemTray?: boolean
}
export function getSystemTrayEntries(
options: SystemPresenceSearchOptions = {}
): SettingsSearchEntry[] {
const show =
options.showSystemTray ??
// Why: a Windows web client can report win32, but it has no local tray.
(getRendererAppPlatform() === 'win32' && !isWebClientLocation())
return show ? getSystemTrayEntryCatalog() : []
}
export function getMenuBarIconEntries(
options: SystemPresenceSearchOptions = {}
): SettingsSearchEntry[] {
const show =
options.showMenuBarIcon ?? (getRendererAppPlatform() === 'darwin' && !isWebClientLocation())
return show ? getMenuBarIconEntryCatalog() : []
}
@@ -172,6 +172,15 @@ describe('getTerminalPaneSearchEntries', () => {
expect(matchesSettingsSearch('tray', webEntries)).toBe(false)
})
it('includes the macOS menu bar entry only when its desktop control is shown', () => {
const macEntries = getAppearancePaneSearchEntries({ showMenuBarIcon: true })
const otherEntries = getAppearancePaneSearchEntries({ showMenuBarIcon: false })
expect(macEntries.some((entry) => entry.title === 'Show Menu Bar Icon')).toBe(true)
expect(otherEntries.some((entry) => entry.title === 'Show Menu Bar Icon')).toBe(false)
expect(matchesSettingsSearch('status item', macEntries)).toBe(true)
})
it('keeps sidebar shortcut restore settings in the Appearance search index', () => {
const automationsEntry = getSidebarEntries().find(
(entry) => entry.title === 'Show Automations Button'
@@ -313,6 +313,7 @@ describe('useIpcEvents zoom routing', () => {
},
agentStatus: { onSet: () => () => {} },
ui: makeEvents({
consumePendingOpenSettings: () => Promise.resolve(false),
onTerminalZoom: (listener: (direction: 'in' | 'out' | 'reset') => void) => {
terminalZoomListenerRef.current = listener
return () => {}
@@ -455,6 +456,7 @@ describe('useIpcEvents zoom routing', () => {
},
agentStatus: { onSet: () => () => {} },
ui: makeEvents({
consumePendingOpenSettings: () => Promise.resolve(false),
onTerminalZoom: (listener: (direction: 'in' | 'out' | 'reset') => void) => {
terminalZoomListenerRef.current = listener
return () => {}
@@ -666,6 +668,7 @@ describe('useIpcEvents rate-limit hydration', () => {
},
agentStatus: { onSet: () => () => {} },
ui: makeEvents({
consumePendingOpenSettings: () => Promise.resolve(false),
getZoomLevel: vi.fn(() => 0)
})
}
@@ -954,6 +957,7 @@ describe('useIpcEvents browser tab create routing', () => {
ui: {
onStateChanged: () => () => {},
onOpenSettings: () => () => {},
consumePendingOpenSettings: () => Promise.resolve(false),
onOpenFeatureTour: () => () => {},
onToggleLeftSidebar: () => () => {},
onToggleRightSidebar: () => () => {},
@@ -1176,6 +1180,7 @@ describe('useIpcEvents updater integration', () => {
ui: {
onStateChanged: () => () => {},
onOpenSettings: () => () => {},
consumePendingOpenSettings: () => Promise.resolve(false),
onOpenFeatureTour: () => () => {},
onToggleLeftSidebar: () => () => {},
onToggleRightSidebar: () => () => {},
@@ -1421,6 +1426,7 @@ describe('useIpcEvents updater integration', () => {
ui: {
onStateChanged: () => () => {},
onOpenSettings: () => () => {},
consumePendingOpenSettings: () => Promise.resolve(false),
onOpenFeatureTour: () => () => {},
onToggleLeftSidebar: () => () => {},
onToggleRightSidebar: () => () => {},
@@ -1862,6 +1868,7 @@ describe('useIpcEvents updater integration', () => {
ui: {
onStateChanged: () => () => {},
onOpenSettings: () => () => {},
consumePendingOpenSettings: () => Promise.resolve(false),
onOpenFeatureTour: () => () => {},
onToggleLeftSidebar: () => () => {},
onToggleRightSidebar: () => () => {},
@@ -2796,6 +2803,7 @@ describe('useIpcEvents browser tab close routing', () => {
ui: {
onStateChanged: () => () => {},
onOpenSettings: () => () => {},
consumePendingOpenSettings: () => Promise.resolve(false),
onOpenFeatureTour: () => () => {},
onToggleLeftSidebar: () => () => {},
onToggleRightSidebar: () => () => {},
@@ -3286,6 +3294,7 @@ describe('useIpcEvents browser tab close routing', () => {
ui: {
onStateChanged: () => () => {},
onOpenSettings: () => () => {},
consumePendingOpenSettings: () => Promise.resolve(false),
onOpenFeatureTour: () => () => {},
onToggleLeftSidebar: () => () => {},
onToggleRightSidebar: () => () => {},
@@ -3504,6 +3513,7 @@ describe('useIpcEvents browser tab close routing', () => {
ui: {
onStateChanged: () => () => {},
onOpenSettings: () => () => {},
consumePendingOpenSettings: () => Promise.resolve(false),
onOpenFeatureTour: () => () => {},
onToggleLeftSidebar: () => () => {},
onToggleRightSidebar: () => () => {},
@@ -3717,6 +3727,7 @@ describe('useIpcEvents browser tab close routing', () => {
ui: {
onStateChanged: () => () => {},
onOpenSettings: () => () => {},
consumePendingOpenSettings: () => Promise.resolve(false),
onOpenFeatureTour: () => () => {},
onToggleLeftSidebar: () => () => {},
onToggleRightSidebar: () => () => {},
@@ -3948,6 +3959,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
ui: {
onStateChanged: () => () => {},
onOpenSettings: () => () => {},
consumePendingOpenSettings: () => Promise.resolve(false),
onOpenFeatureTour: () => () => {},
onToggleLeftSidebar: () => () => {},
onToggleRightSidebar: () => () => {},
@@ -4205,6 +4217,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
ui: {
onStateChanged: () => () => {},
onOpenSettings: () => () => {},
consumePendingOpenSettings: () => Promise.resolve(false),
onOpenFeatureTour: () => () => {},
onToggleLeftSidebar: () => () => {},
onToggleRightSidebar: () => () => {},
@@ -4439,6 +4452,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
ui: {
onStateChanged: () => () => {},
onOpenSettings: () => () => {},
consumePendingOpenSettings: () => Promise.resolve(false),
onOpenFeatureTour: () => () => {},
onToggleLeftSidebar: () => () => {},
onToggleRightSidebar: () => () => {},
+9
View File
@@ -1214,6 +1214,15 @@ 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()
}
})
unsubs.push(
window.api.ui.onOpenSetupGuide?.(() => {
useAppStore.getState().openModal('setup-guide', { telemetrySource: 'help_menu' })
@@ -382,7 +382,8 @@ export function buildSettingsNavigationMetadata({
icon: Palette,
searchEntries: getAppearancePaneSearchEntries({
showWarpImport: showDesktopOnlySettings,
showSystemTray: showDesktopOnlySettings && isWindows
showSystemTray: showDesktopOnlySettings && isWindows,
showMenuBarIcon: showDesktopOnlySettings && isMac
}),
group: 'interface'
},
+8 -1
View File
@@ -31,6 +31,10 @@
"portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners.",
"antigravityToggleDescription": "Show Antigravity subscription usage for the active workspace.",
"grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI."
},
"menuBarIcon": {
"title": "Show Menu Bar Icon",
"description": "Keep an Orca shortcut and activity indicator in the macOS menu bar."
}
}
},
@@ -67,7 +71,10 @@
"quit": "Quit",
"minimizeNotice": {
"body": "Orca is still running in the system tray"
}
},
"activityWaiting": "Orca - activity waiting",
"settings": "Settings...",
"checkForUpdates": "Check for Updates..."
},
"worktreeJumpPalette": {
"matchLabel": {
+8 -1
View File
@@ -31,6 +31,10 @@
"portsToggleDescription": "Muestra los puertos activos de los espacios de trabajo. Haz clic para ver los puertos de cada espacio de trabajo y los puertos externos.",
"antigravityToggleDescription": "Muestra el uso de suscripción de Antigravity para el espacio de trabajo activo.",
"grokToggleDescription": "Muestra el uso de créditos de suscripción de Grok cuando has iniciado sesión con Grok CLI."
},
"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."
}
}
},
@@ -67,7 +71,10 @@
"quit": "Salir",
"minimizeNotice": {
"body": "Orca sigue ejecutándose en la bandeja del sistema"
}
},
"activityWaiting": "Orca: actividad en espera",
"settings": "Ajustes...",
"checkForUpdates": "Buscar actualizaciones..."
},
"worktreeJumpPalette": {
"matchLabel": {
+8 -1
View File
@@ -31,6 +31,10 @@
"portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。",
"antigravityToggleDescription": "アクティブなワークスペースの Antigravity サブスクリプション使用量を表示します。",
"grokToggleDescription": "Grok CLI でサインインしている場合に Grok サブスクリプションのクレジット使用量を表示します。"
},
"menuBarIcon": {
"title": "メニューバーアイコンを表示",
"description": "macOS メニューバーに Orca のショートカットとアクティビティインジケーターを表示します。"
}
}
},
@@ -67,7 +71,10 @@
"quit": "終了",
"minimizeNotice": {
"body": "Orcaはシステムトレイで実行中です"
}
},
"activityWaiting": "Orca - アクティビティ待機中",
"settings": "設定...",
"checkForUpdates": "アップデートを確認..."
},
"worktreeJumpPalette": {
"matchLabel": {
+8 -1
View File
@@ -31,6 +31,10 @@
"portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요.",
"antigravityToggleDescription": "활성 워크스페이스에 대한 Antigravity 구독 사용량을 표시합니다.",
"grokToggleDescription": "Grok CLI로 로그인한 경우 Grok 구독 크레딧 사용량을 표시합니다."
},
"menuBarIcon": {
"title": "메뉴 바 아이콘 표시",
"description": "macOS 메뉴 바에 Orca 바로가기 및 활동 표시기를 유지합니다."
}
}
},
@@ -67,7 +71,10 @@
"quit": "종료",
"minimizeNotice": {
"body": "Orca가 시스템 트레이에서 계속 실행 중입니다"
}
},
"activityWaiting": "Orca - 활동 대기 중",
"settings": "설정...",
"checkForUpdates": "업데이트 확인..."
},
"worktreeJumpPalette": {
"matchLabel": {
+8 -1
View File
@@ -31,6 +31,10 @@
"portsToggleDescription": "显示实时工作区端口。单击可查看工作区范围的端口和外部监听器。",
"antigravityToggleDescription": "显示当前工作区的 Antigravity 订阅使用量。",
"grokToggleDescription": "通过 Grok CLI 登录后显示 Grok 订阅额度使用量。"
},
"menuBarIcon": {
"title": "显示菜单栏图标",
"description": "在 macOS 菜单栏中保留 Orca 快捷方式和活动指示器。"
}
}
},
@@ -67,7 +71,10 @@
"quit": "退出",
"minimizeNotice": {
"body": "Orca 仍在系统托盘中运行"
}
},
"activityWaiting": "Orca - 活动等待中",
"settings": "设置...",
"checkForUpdates": "检查更新..."
},
"worktreeJumpPalette": {
"matchLabel": {
+3
View File
@@ -2376,6 +2376,9 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
},
isMaximized: () => Promise.resolve(false),
onOpenSettings: () => noopUnsubscribe,
// Why: the web client has no native tray/menu bar, so there is never a
// queued open-settings intent to consume.
consumePendingOpenSettings: () => Promise.resolve(false),
onOpenSetupGuide: () => noopUnsubscribe,
onOpenFeatureTour: () => noopUnsubscribe,
onOpenCrashReport: () => noopUnsubscribe,
+6
View File
@@ -48,6 +48,12 @@ describe('getDefaultSettings', () => {
expect(getDefaultSettings('/tmp').uiLanguage).toBe('system')
})
it('defaults the menu bar icon on only for macOS', () => {
expect(getDefaultSettings('/tmp', 'darwin').showMenuBarIcon).toBe(true)
expect(getDefaultSettings('/tmp', 'win32').showMenuBarIcon).toBe(false)
expect(getDefaultSettings('/tmp', 'linux').showMenuBarIcon).toBe(false)
})
it('confirms before closing pinned tabs by default', () => {
expect(getDefaultSettings('/tmp').confirmClosePinnedTab).toBe(true)
})
+5 -1
View File
@@ -186,7 +186,10 @@ function getDefaultWorkspaceDir(homeDir: string): string {
return [trimmedHomeDir, 'orca', 'workspaces'].join(separator)
}
export function getDefaultSettings(homedir: string): GlobalSettings {
export function getDefaultSettings(
homedir: string,
platform: NodeJS.Platform = typeof process === 'undefined' ? 'win32' : process.platform
): GlobalSettings {
return {
workspaceDir: getDefaultWorkspaceDir(homedir),
nestWorkspaces: true,
@@ -266,6 +269,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
terminalFocusFollowsMouse: false,
windowBackgroundBlur: false,
minimizeToTrayOnClose: false,
showMenuBarIcon: platform === 'darwin',
terminalClipboardOnSelect: false,
// Why: OSC 52 is a classic data-exfiltration vector (any process piping
// untrusted output into the terminal can rewrite the clipboard). Keep the
+3
View File
@@ -2597,6 +2597,9 @@ export type GlobalSettings = {
* system tray instead of quitting Orca; off keeps the default quit-on-close.
* The tray icon itself is always present on Windows regardless of this flag. */
minimizeToTrayOnClose?: boolean
/** Why: macOS keeps Orca running after its last window closes, so this
* controls the additive menu-bar entry without changing Dock behavior. */
showMenuBarIcon?: boolean
/** Why: Windows terminals conventionally use right-click as a paste gesture,
* while macOS/Linux default to their existing context menu behavior. */
terminalRightClickToPaste: boolean