mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
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:
Binary file not shown.
|
After Width: | Height: | Size: 324 B |
Binary file not shown.
|
After Width: | Height: | Size: 625 B |
+103
-31
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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]))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: () => () => {},
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user