feat(tray): Windows system tray with minimize-to-tray on close
Adds Windows system tray support and a minimize-to-tray setting for close behavior.
This commit is contained in:
parent
4a2dc069d5
commit
e7af3676f2
|
|
@ -78,6 +78,7 @@ import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit
|
|||
import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target'
|
||||
import { attachMainWindowServices } from './window/attach-main-window-services'
|
||||
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
|
||||
import { createSystemTray, destroySystemTray } from './tray/system-tray'
|
||||
import { focusExistingMainWindow } from './window/focus-existing-window'
|
||||
import { CodexAccountService } from './codex-accounts/service'
|
||||
import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service'
|
||||
|
|
@ -554,6 +555,23 @@ function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget):
|
|||
return runtimeHomePath
|
||||
}
|
||||
|
||||
// Why: tray "Open Orca" / left-click restores the window the close handler may
|
||||
// have hidden to the tray; if the window was fully torn down, reopen it the
|
||||
// same way macOS dock re-activation does (guarded against update relaunch).
|
||||
function showMainWindowFromTray(): void {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore()
|
||||
}
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
return
|
||||
}
|
||||
if (!isQuittingForUpdate()) {
|
||||
openMainWindow()
|
||||
}
|
||||
}
|
||||
|
||||
function openMainWindow(): BrowserWindow {
|
||||
logStartupMilestone('open-main-window-start')
|
||||
if (!store) {
|
||||
|
|
@ -757,6 +775,23 @@ function openMainWindow(): BrowserWindow {
|
|||
stopAllSyntheticTitleSpinners()
|
||||
})
|
||||
mainWindow = window
|
||||
// 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 the window to the tray.
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
window.on('show', resumeSyntheticTitleSpinnerTimer)
|
||||
window.on('restore', resumeSyntheticTitleSpinnerTimer)
|
||||
window.on('hide', stopSyntheticTitleSpinnerTimer)
|
||||
|
|
@ -1663,6 +1698,9 @@ app.on('before-quit', () => {
|
|||
// async work and let Electron exit.
|
||||
let daemonDisconnectDone = false
|
||||
app.on('will-quit', (e) => {
|
||||
// Why: before-quit can still be aborted by renderer beforeunload; wait until
|
||||
// the committed quit path before removing the Windows notification icon.
|
||||
destroySystemTray()
|
||||
// Why: stats.flush() must run before killAllPty() so it can read the
|
||||
// live agent state and emit synthetic agent_stop events for agents that
|
||||
// are still running. killAllPty() does not call runtime.onPtyExit(),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { handlers, appExitMock, appQuitMock, appRelaunchMock, execFileMock } = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
|
||||
appExitMock: vi.fn(),
|
||||
appQuitMock: vi.fn(),
|
||||
appRelaunchMock: vi.fn(),
|
||||
execFileMock: vi.fn()
|
||||
}))
|
||||
const { handlers, appExitMock, appQuitMock, appRelaunchMock, execFileMock, destroySystemTrayMock } =
|
||||
vi.hoisted(() => ({
|
||||
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
|
||||
appExitMock: vi.fn(),
|
||||
appQuitMock: vi.fn(),
|
||||
appRelaunchMock: vi.fn(),
|
||||
execFileMock: vi.fn(),
|
||||
destroySystemTrayMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: execFileMock
|
||||
|
|
@ -37,6 +39,10 @@ vi.mock('@electron-toolkit/utils', () => ({
|
|||
is: { dev: true }
|
||||
}))
|
||||
|
||||
vi.mock('../tray/system-tray', () => ({
|
||||
destroySystemTray: destroySystemTrayMock
|
||||
}))
|
||||
|
||||
import { registerAppHandlers } from './app'
|
||||
|
||||
describe('registerAppHandlers', () => {
|
||||
|
|
@ -49,6 +55,7 @@ describe('registerAppHandlers', () => {
|
|||
appQuitMock.mockReset()
|
||||
appRelaunchMock.mockReset()
|
||||
execFileMock.mockReset()
|
||||
destroySystemTrayMock.mockReset()
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
|
||||
})
|
||||
|
||||
|
|
@ -70,8 +77,12 @@ describe('registerAppHandlers', () => {
|
|||
await relaunchPromise
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(destroySystemTrayMock).toHaveBeenCalledTimes(1)
|
||||
expect(appRelaunchMock).toHaveBeenCalledTimes(1)
|
||||
expect(appExitMock).toHaveBeenCalledWith(0)
|
||||
expect(destroySystemTrayMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
appExitMock.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('waits for pre-relaunch cleanup before exiting', async () => {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { isPwshAvailable } from '../pwsh'
|
|||
import { isWslAvailable, listWslDistros } from '../wsl'
|
||||
import { isGitBashAvailable } from '../git-bash'
|
||||
import { setUnreadDockBadgeCount } from '../dock/unread-badge'
|
||||
import { destroySystemTray } from '../tray/system-tray'
|
||||
import { authorizeExternalPath } from './filesystem-auth'
|
||||
import {
|
||||
ensureDefaultFloatingWorkspacePath,
|
||||
|
|
@ -214,6 +215,9 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp
|
|||
// Mark shutdown first because app.exit() can bypass the usual quit latch.
|
||||
await runBeforeRelaunchCleanup(options.onBeforeRelaunch)
|
||||
setTimeout(() => {
|
||||
// Why: app.exit(0) skips before-quit/will-quit, so clean the Windows tray
|
||||
// explicitly before relaunching to avoid a stale notification-area icon.
|
||||
destroySystemTray()
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
}, 150)
|
||||
|
|
|
|||
|
|
@ -510,6 +510,56 @@ describe('Store', () => {
|
|||
expect(ui.setupGuideBrowserMilestoneLegacyComplete).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults minimizeToTrayOnClose to false when unset', async () => {
|
||||
const store = await createStore()
|
||||
expect(store.getSettings().minimizeToTrayOnClose).toBe(false)
|
||||
})
|
||||
|
||||
it('coerces loaded minimizeToTrayOnClose to false unless stored as true', async () => {
|
||||
writeDataFile({
|
||||
...getDefaultPersistedState(testState.dir),
|
||||
settings: {
|
||||
minimizeToTrayOnClose: 'true' as unknown as boolean
|
||||
}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getSettings().minimizeToTrayOnClose).toBe(false)
|
||||
})
|
||||
|
||||
it('persists minimizeToTrayOnClose true/false round-trip', async () => {
|
||||
const store = await createStore()
|
||||
store.updateSettings({ minimizeToTrayOnClose: true })
|
||||
expect(store.getSettings().minimizeToTrayOnClose).toBe(true)
|
||||
store.flush()
|
||||
expect((readDataFile() as PersistedState).settings.minimizeToTrayOnClose).toBe(true)
|
||||
store.updateSettings({ minimizeToTrayOnClose: false })
|
||||
expect(store.getSettings().minimizeToTrayOnClose).toBe(false)
|
||||
})
|
||||
|
||||
it('coerces non-boolean minimizeToTrayOnClose payloads to a strict boolean', async () => {
|
||||
const store = await createStore()
|
||||
// Why: a renderer-supplied non-bool must never persist as a truthy non-bool
|
||||
// that would later read as "tray-minimize on".
|
||||
store.updateSettings({ minimizeToTrayOnClose: 'true' as unknown as boolean })
|
||||
expect(store.getSettings().minimizeToTrayOnClose).toBe(false)
|
||||
store.updateSettings({ minimizeToTrayOnClose: 1 as unknown as boolean })
|
||||
expect(store.getSettings().minimizeToTrayOnClose).toBe(false)
|
||||
store.updateSettings({ minimizeToTrayOnClose: null as unknown as boolean })
|
||||
expect(store.getSettings().minimizeToTrayOnClose).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults trayMinimizeNoticeShown to false and persists it strictly', async () => {
|
||||
const store = await createStore()
|
||||
expect(store.getUI().trayMinimizeNoticeShown).toBe(false)
|
||||
store.updateUI({ trayMinimizeNoticeShown: true })
|
||||
expect(store.getUI().trayMinimizeNoticeShown).toBe(true)
|
||||
store.flush()
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getUI().trayMinimizeNoticeShown).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the setup guide sidebar entry for existing users backfilled as completed', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
|
|
|
|||
|
|
@ -2714,6 +2714,9 @@ export class Store {
|
|||
parsed.settings?.terminalCustomThemes
|
||||
),
|
||||
appIcon: normalizeAppIconId(parsed.settings?.appIcon),
|
||||
// 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,
|
||||
uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage),
|
||||
defaultTaskSource: taskProviderSettings.defaultTaskSource,
|
||||
visibleTaskProviders: taskProviderSettings.visibleTaskProviders,
|
||||
|
|
@ -4498,6 +4501,12 @@ export class Store {
|
|||
options: { notifyListeners?: boolean; originWebContentsId?: number } = {}
|
||||
): GlobalSettings {
|
||||
const sanitizedUpdates = { ...updates }
|
||||
// Why: coerce strictly to boolean here (not at the IPC edge) so every write
|
||||
// path is covered and a non-bool renderer payload can never persist a
|
||||
// truthy non-bool that later reads as "tray-minimize on".
|
||||
if ('minimizeToTrayOnClose' in updates) {
|
||||
sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true
|
||||
}
|
||||
if ('disabledTuiAgents' in updates) {
|
||||
sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents)
|
||||
}
|
||||
|
|
@ -4640,6 +4649,9 @@ export class Store {
|
|||
this.state.ui?.workspaceBoardColumnWidth
|
||||
),
|
||||
syncTaskStatusFromWorkspaceBoard: this.state.ui?.syncTaskStatusFromWorkspaceBoard === true,
|
||||
// Why: strict boolean coercion so a missing/legacy value reads as false
|
||||
// (first-run notice still fires) rather than leaking a non-bool through.
|
||||
trayMinimizeNoticeShown: this.state.ui?.trayMinimizeNoticeShown === true,
|
||||
markdownTocPanelWidth: clampMarkdownTocPanelWidth(this.state.ui?.markdownTocPanelWidth),
|
||||
visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds(
|
||||
this.state.ui?.visibleWorkspaceHostIds
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as SystemTrayModule from './system-tray'
|
||||
|
||||
const { trayInstances, menuFromTemplateMock, createAppIconImageMock, resizedImage } = vi.hoisted(
|
||||
() => {
|
||||
const resizedImage = { resized: true }
|
||||
return {
|
||||
trayInstances: [] as FakeTray[],
|
||||
menuFromTemplateMock: vi.fn((template: unknown) => ({ template })),
|
||||
createAppIconImageMock: vi.fn(),
|
||||
resizedImage
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
class FakeTray {
|
||||
setToolTip = vi.fn()
|
||||
setContextMenu = vi.fn()
|
||||
on = vi.fn()
|
||||
destroy = vi.fn()
|
||||
isDestroyed = vi.fn(() => false)
|
||||
constructor(public readonly image: unknown) {
|
||||
trayInstances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
Tray: FakeTray,
|
||||
Menu: { buildFromTemplate: menuFromTemplateMock }
|
||||
}))
|
||||
|
||||
vi.mock('../app-icon', () => ({
|
||||
createAppIconImage: createAppIconImageMock
|
||||
}))
|
||||
|
||||
type TrayModule = typeof SystemTrayModule
|
||||
|
||||
const originalPlatform = process.platform
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
|
||||
}
|
||||
|
||||
async function loadModule(): Promise<TrayModule> {
|
||||
vi.resetModules()
|
||||
return import('./system-tray')
|
||||
}
|
||||
|
||||
type MenuItem = { label?: string; type?: string; click?: () => void }
|
||||
|
||||
function builtMenuItems(): MenuItem[] {
|
||||
return menuFromTemplateMock.mock.calls.at(-1)?.[0] as MenuItem[]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
trayInstances.length = 0
|
||||
menuFromTemplateMock.mockClear()
|
||||
createAppIconImageMock.mockReset()
|
||||
createAppIconImageMock.mockReturnValue({ resize: vi.fn(() => resizedImage) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform)
|
||||
})
|
||||
|
||||
describe('createSystemTray', () => {
|
||||
it('creates a tray with an Orca tooltip and Open/Quit menu on win32', async () => {
|
||||
setPlatform('win32')
|
||||
const { createSystemTray } = await loadModule()
|
||||
const onOpen = vi.fn()
|
||||
const onQuit = vi.fn()
|
||||
|
||||
const tray = createSystemTray({ appIcon: 'classic', onOpen, onQuit })
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
setPlatform('darwin')
|
||||
const { createSystemTray } = await loadModule()
|
||||
|
||||
const tray = createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() })
|
||||
|
||||
expect(tray).toBeNull()
|
||||
expect(trayInstances).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('destroySystemTray', () => {
|
||||
it('destroys an existing tray and is safe to call without one', async () => {
|
||||
setPlatform('win32')
|
||||
const { createSystemTray, destroySystemTray } = await loadModule()
|
||||
createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() })
|
||||
const created = trayInstances[0]
|
||||
|
||||
destroySystemTray()
|
||||
expect(created.destroy).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second call with no live tray must not throw.
|
||||
expect(() => destroySystemTray()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { Menu, Tray } from 'electron'
|
||||
import { createAppIconImage } from '../app-icon'
|
||||
import { translateMain } from '../i18n/main-i18n'
|
||||
|
||||
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
|
||||
/** Quit Orca for real (caller must set the quitting latch before quitting). */
|
||||
onQuit: () => void
|
||||
}
|
||||
|
||||
// Why: Electron's Tray is GC-collected and its icon vanishes if no live
|
||||
// reference is kept, so hold it at module scope for the app's lifetime.
|
||||
let tray: Tray | 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
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function createSystemTray(opts: SystemTrayOptions): Tray | null {
|
||||
if (process.platform !== 'win32') {
|
||||
return null
|
||||
}
|
||||
if (tray && !tray.isDestroyed()) {
|
||||
return tray
|
||||
}
|
||||
const image = createAppIconImage(opts.appIcon).resize({
|
||||
width: TRAY_ICON_SIZE,
|
||||
height: TRAY_ICON_SIZE
|
||||
})
|
||||
tray = new Tray(image)
|
||||
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() }
|
||||
])
|
||||
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())
|
||||
return tray
|
||||
}
|
||||
|
||||
/** Destroys the tray icon if present. Safe to call repeatedly or with no tray. */
|
||||
export function destroySystemTray(): void {
|
||||
if (tray && !tray.isDestroyed()) {
|
||||
tray.destroy()
|
||||
}
|
||||
tray = null
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/* oxlint-disable max-lines */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
browserWindowMock,
|
||||
|
|
@ -7,15 +7,22 @@ const {
|
|||
attachGuestPoliciesMock,
|
||||
buildFromTemplateMock,
|
||||
menuPopupMock,
|
||||
notificationMock,
|
||||
notificationShowMock,
|
||||
isMock
|
||||
} = vi.hoisted(() => {
|
||||
const menuPopupMock = vi.fn()
|
||||
const notificationShowMock = vi.fn()
|
||||
return {
|
||||
browserWindowMock: vi.fn(),
|
||||
openExternalMock: vi.fn(),
|
||||
attachGuestPoliciesMock: vi.fn(),
|
||||
buildFromTemplateMock: vi.fn(() => ({ popup: menuPopupMock })),
|
||||
menuPopupMock,
|
||||
notificationMock: vi.fn(function () {
|
||||
return { show: notificationShowMock }
|
||||
}),
|
||||
notificationShowMock,
|
||||
isMock: { dev: false }
|
||||
}
|
||||
})
|
||||
|
|
@ -25,6 +32,7 @@ vi.mock('electron', () => ({
|
|||
BrowserWindow: browserWindowMock,
|
||||
ipcMain: { on: vi.fn(), removeListener: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() },
|
||||
Menu: { buildFromTemplate: buildFromTemplateMock },
|
||||
Notification: notificationMock,
|
||||
nativeTheme: { shouldUseDarkColors: false },
|
||||
screen: {
|
||||
getPrimaryDisplay: () => ({ workAreaSize: { width: 1440, height: 900 } })
|
||||
|
|
@ -57,6 +65,8 @@ describe('createMainWindow', () => {
|
|||
attachGuestPoliciesMock.mockReset()
|
||||
buildFromTemplateMock.mockClear()
|
||||
menuPopupMock.mockClear()
|
||||
notificationMock.mockClear()
|
||||
notificationShowMock.mockClear()
|
||||
isMock.dev = false
|
||||
vi.mocked(ipcMain.on).mockReset()
|
||||
vi.mocked(ipcMain.removeListener).mockReset()
|
||||
|
|
@ -2521,4 +2531,197 @@ describe('createMainWindow', () => {
|
|||
expect(browserWindowInstance.maximize).toHaveBeenCalledTimes(1)
|
||||
expect(browserWindowInstance.show).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
describe('minimize to tray on close (win32)', () => {
|
||||
const originalPlatform = process.platform
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
|
||||
}
|
||||
|
||||
type CloseFixture = {
|
||||
windowHandlers: Record<string, (...args: any[]) => void>
|
||||
webContents: { send: ReturnType<typeof vi.fn> }
|
||||
instance: { hide: ReturnType<typeof vi.fn>; isMinimized: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
function setupCloseWindow(): CloseFixture {
|
||||
const windowHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
const webContents = {
|
||||
on: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
setZoomLevel: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
invalidate: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
send: vi.fn(),
|
||||
isCrashed: vi.fn(() => false),
|
||||
id: 1
|
||||
}
|
||||
const instance = {
|
||||
webContents,
|
||||
on: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isMaximized: vi.fn(() => false),
|
||||
isFullScreen: vi.fn(() => false),
|
||||
isMinimized: vi.fn(() => false),
|
||||
getSize: vi.fn(() => [1200, 800]),
|
||||
setSize: vi.fn(),
|
||||
maximize: vi.fn(),
|
||||
show: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
loadFile: vi.fn(),
|
||||
loadURL: vi.fn()
|
||||
}
|
||||
browserWindowMock.mockImplementation(function () {
|
||||
return instance
|
||||
})
|
||||
return { windowHandlers, webContents, instance }
|
||||
}
|
||||
|
||||
function makeStore(minimizeToTrayOnClose: boolean, trayMinimizeNoticeShown: boolean) {
|
||||
return {
|
||||
getUI: vi.fn(() => ({ trayMinimizeNoticeShown })),
|
||||
getSettings: vi.fn(() => ({ windowBackgroundBlur: false, minimizeToTrayOnClose })),
|
||||
updateUI: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform)
|
||||
})
|
||||
|
||||
it('hides to the tray instead of closing when the setting is on', () => {
|
||||
setPlatform('win32')
|
||||
const { windowHandlers, webContents, instance } = setupCloseWindow()
|
||||
const store = makeStore(true, true)
|
||||
|
||||
createMainWindow(store as never, { getIsQuitting: () => false })
|
||||
const preventDefault = vi.fn()
|
||||
windowHandlers.close({ preventDefault } as never)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(instance.hide).toHaveBeenCalledTimes(1)
|
||||
expect(webContents.send).not.toHaveBeenCalledWith('window:close-requested', expect.anything())
|
||||
// Notice already shown, so it must not fire again.
|
||||
expect(notificationMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the normal close flow when the setting is off', () => {
|
||||
setPlatform('win32')
|
||||
const { windowHandlers, webContents, instance } = setupCloseWindow()
|
||||
const store = makeStore(false, true)
|
||||
|
||||
createMainWindow(store as never, { getIsQuitting: () => false })
|
||||
windowHandlers.close({ preventDefault: vi.fn() } as never)
|
||||
|
||||
expect(instance.hide).not.toHaveBeenCalled()
|
||||
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
|
||||
isQuitting: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not hide on a real quit even with the setting on', () => {
|
||||
setPlatform('win32')
|
||||
const { windowHandlers, webContents, instance } = setupCloseWindow()
|
||||
const store = makeStore(true, true)
|
||||
|
||||
createMainWindow(store as never, { getIsQuitting: () => true })
|
||||
windowHandlers.close({ preventDefault: vi.fn() } as never)
|
||||
|
||||
expect(instance.hide).not.toHaveBeenCalled()
|
||||
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
|
||||
isQuitting: true
|
||||
})
|
||||
})
|
||||
|
||||
it('does not hide when the renderer process is gone', () => {
|
||||
setPlatform('win32')
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { windowHandlers, instance } = setupCloseWindow()
|
||||
const store = makeStore(true, true)
|
||||
|
||||
createMainWindow(store as never, { getIsQuitting: () => false })
|
||||
windowHandlers['render-process-gone']?.(
|
||||
{} as never,
|
||||
{ reason: 'crashed', exitCode: 5 } as never
|
||||
)
|
||||
const preventDefault = vi.fn()
|
||||
windowHandlers.close({ preventDefault } as never)
|
||||
|
||||
expect(instance.hide).not.toHaveBeenCalled()
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('shows the first-run notification once and persists the flag', () => {
|
||||
setPlatform('win32')
|
||||
const { windowHandlers } = setupCloseWindow()
|
||||
const store = makeStore(true, false)
|
||||
|
||||
createMainWindow(store as never, { getIsQuitting: () => false })
|
||||
windowHandlers.close({ preventDefault: vi.fn() } as never)
|
||||
|
||||
expect(notificationMock).toHaveBeenCalledTimes(1)
|
||||
expect(notificationShowMock).toHaveBeenCalledTimes(1)
|
||||
expect(store.updateUI).toHaveBeenCalledWith({ trayMinimizeNoticeShown: true })
|
||||
})
|
||||
|
||||
it('leaves the close handler unchanged off win32', () => {
|
||||
setPlatform('darwin')
|
||||
const { windowHandlers, webContents, instance } = setupCloseWindow()
|
||||
const store = makeStore(true, true)
|
||||
|
||||
createMainWindow(store as never, { getIsQuitting: () => false })
|
||||
windowHandlers.close({ preventDefault: vi.fn() } as never)
|
||||
|
||||
expect(instance.hide).not.toHaveBeenCalled()
|
||||
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
|
||||
isQuitting: false
|
||||
})
|
||||
})
|
||||
|
||||
// Why: on Windows the renderer-drawn X routes through window:request-close,
|
||||
// not the native close event — regression guard for the bug where the app
|
||||
// quit instead of hiding because the guard only covered the native event.
|
||||
function captureIpcHandlers(): Record<string, (...args: any[]) => void> {
|
||||
const ipcHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
vi.mocked(ipcMain.on).mockImplementation((channel, handler) => {
|
||||
ipcHandlers[channel] = handler as (...args: any[]) => void
|
||||
return ipcMain
|
||||
})
|
||||
return ipcHandlers
|
||||
}
|
||||
|
||||
it('hides to the tray when the renderer-drawn X requests close', () => {
|
||||
setPlatform('win32')
|
||||
const ipcHandlers = captureIpcHandlers()
|
||||
const { webContents, instance } = setupCloseWindow()
|
||||
const store = makeStore(true, true)
|
||||
|
||||
createMainWindow(store as never, { getIsQuitting: () => false })
|
||||
ipcHandlers['window:request-close']?.()
|
||||
|
||||
expect(instance.hide).toHaveBeenCalledTimes(1)
|
||||
expect(webContents.send).not.toHaveBeenCalledWith('window:close-requested', expect.anything())
|
||||
})
|
||||
|
||||
it('forwards window:request-close to the renderer when the setting is off', () => {
|
||||
setPlatform('win32')
|
||||
const ipcHandlers = captureIpcHandlers()
|
||||
const { webContents, instance } = setupCloseWindow()
|
||||
const store = makeStore(false, true)
|
||||
|
||||
createMainWindow(store as never, { getIsQuitting: () => false })
|
||||
ipcHandlers['window:request-close']?.()
|
||||
|
||||
expect(instance.hide).not.toHaveBeenCalled()
|
||||
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
|
||||
isQuitting: false
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,21 @@
|
|||
/* oxlint-disable max-lines */
|
||||
import { app, BrowserWindow, ipcMain, Menu, nativeTheme, screen, shell } from 'electron'
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeTheme,
|
||||
Notification,
|
||||
screen,
|
||||
shell
|
||||
} from 'electron'
|
||||
import { join } from 'path'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import type { Store } from '../persistence'
|
||||
import { getAppIconPath } from '../app-icon'
|
||||
import { browserManager } from '../browser/browser-manager'
|
||||
import { browserSessionRegistry } from '../browser/browser-session-registry'
|
||||
import { translateMain } from '../i18n/main-i18n'
|
||||
import {
|
||||
normalizeBrowserNavigationUrl,
|
||||
normalizeExternalBrowserUrl
|
||||
|
|
@ -932,7 +942,50 @@ export function createMainWindow(
|
|||
let windowCloseConfirmed = false
|
||||
const confirmCloseChannel = 'window:confirm-close'
|
||||
|
||||
// Why: Windows minimize-to-tray. Hides the window instead of closing when the
|
||||
// setting is on, this isn't a real quit (Ctrl+Q / tray "Quit" set
|
||||
// getIsQuitting), and the renderer is alive. Returns true when it handled the
|
||||
// close by hiding, so callers skip their normal close path. Shared by BOTH the
|
||||
// renderer-drawn X (window:request-close) and the native close event (Alt+F4).
|
||||
const hideToTrayIfEnabled = (): boolean => {
|
||||
const isRendererCrashed = mainWindow.webContents.isCrashed?.() ?? false
|
||||
if (
|
||||
process.platform !== 'win32' ||
|
||||
rendererProcessGone ||
|
||||
isRendererCrashed ||
|
||||
opts?.getIsQuitting?.() === true ||
|
||||
store?.getSettings().minimizeToTrayOnClose !== true
|
||||
) {
|
||||
return false
|
||||
}
|
||||
mainWindow.hide()
|
||||
// Why: tell the user once that closing only hid the window; the persisted
|
||||
// flag stops the notice from repeating on every later minimize.
|
||||
if (store.getUI().trayMinimizeNoticeShown !== true) {
|
||||
try {
|
||||
new Notification({
|
||||
title: 'Orca',
|
||||
body: translateMain(
|
||||
'tray.minimizeNotice.body',
|
||||
'Orca is still running in the system tray'
|
||||
)
|
||||
}).show()
|
||||
} catch {
|
||||
// Notification is best-effort — never block hiding the window.
|
||||
}
|
||||
store.updateUI({ trayMinimizeNoticeShown: true })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
mainWindow.on('close', (e) => {
|
||||
// Why: Alt+F4 and programmatic closes reach the native event; apply the same
|
||||
// minimize-to-tray guard the renderer-drawn X uses via onRequestClose.
|
||||
if (!windowCloseConfirmed && hideToTrayIfEnabled()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
const isRendererCrashed = mainWindow.webContents.isCrashed?.() ?? false
|
||||
if (windowCloseConfirmed) {
|
||||
windowCloseConfirmed = false
|
||||
// Why: past this point Electron/OS may emit resize/move/unmaximize as
|
||||
|
|
@ -947,7 +1000,6 @@ export function createMainWindow(
|
|||
}
|
||||
return
|
||||
}
|
||||
const isRendererCrashed = mainWindow.webContents.isCrashed?.() ?? false
|
||||
if (rendererProcessGone || isRendererCrashed) {
|
||||
// Why: after a native renderer crash the renderer cannot answer
|
||||
// window:close-requested. Let Cmd+Q / OS close complete instead of
|
||||
|
|
@ -1016,9 +1068,16 @@ export function createMainWindow(
|
|||
// with windowCloseConfirmed = true.
|
||||
const requestCloseChannel = 'window:request-close'
|
||||
const onRequestClose = (): void => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('window:close-requested', { isQuitting: false })
|
||||
if (mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
// Why: the renderer-drawn X on Windows routes here (not the native close
|
||||
// event), so the minimize-to-tray guard must run on this path too — hide
|
||||
// instead of asking the renderer to close.
|
||||
if (hideToTrayIfEnabled()) {
|
||||
return
|
||||
}
|
||||
mainWindow.webContents.send('window:close-requested', { isQuitting: false })
|
||||
}
|
||||
// Why: the ··· button in the renderer-drawn title bar on Windows pops up
|
||||
// the application menu at the cursor position, replicating the Alt-key
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
getSidebarEntries,
|
||||
getStatusBarEntries,
|
||||
getStatusBarToggles,
|
||||
getSystemTrayEntries,
|
||||
getThemeEntries,
|
||||
getTitlebarEntries,
|
||||
getTypographyEntries,
|
||||
|
|
@ -41,7 +42,8 @@ import { TerminalAppearanceSection } from './TerminalAppearanceSection'
|
|||
import type { UseGhosttyImportReturn } from './useGhosttyImport'
|
||||
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
|
||||
import { AppIconSelector } from './AppIconSelector'
|
||||
import { isWebClientLocation } from '@/hooks/useSettingsNavigationMetadata'
|
||||
import { getRendererAppPlatform } from '@/lib/renderer-app-platform'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import {
|
||||
getUiLanguageChoiceLabel,
|
||||
SHOW_UI_LANGUAGE_SETTING,
|
||||
|
|
@ -99,6 +101,10 @@ export function AppearancePane({
|
|||
warpThemes
|
||||
}: AppearancePaneProps): React.JSX.Element {
|
||||
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
|
||||
const isWebClient = isWebClientLocation()
|
||||
// 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 zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in')
|
||||
const zoomOutKeyCombos = useShortcutKeyComboDetails('zoom.out')
|
||||
const statusBarItems = useAppStore((state) => state.statusBarItems)
|
||||
|
|
@ -106,8 +112,9 @@ export function AppearancePane({
|
|||
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
|
||||
const visibleStatusBarToggles = useAvailableStatusBarToggles(getStatusBarToggles())
|
||||
const terminalAppearanceSearchEntries = getTerminalAppearanceSearchEntries({
|
||||
showWarpImport: !isWebClientLocation()
|
||||
showWarpImport: !isWebClient
|
||||
})
|
||||
const systemTrayEntries = getSystemTrayEntries({ showSystemTray: isDesktopWindows })
|
||||
const leftSidebarAppearanceEntry = getLeftSidebarAppearanceEntry()
|
||||
const workspaceCardLayoutEntry = getWorkspaceCardLayoutEntry()
|
||||
const visibleSections = [
|
||||
|
|
@ -362,6 +369,42 @@ export function AppearancePane({
|
|||
</div>
|
||||
</section>
|
||||
) : null,
|
||||
isDesktopWindows && matchesSettingsSearch(searchQuery, systemTrayEntries) ? (
|
||||
<section key="system-tray" className="space-y-3">
|
||||
<SettingsSubsectionHeader
|
||||
title={translate('auto.components.settings.AppearancePane.872af9556e', 'System Tray')}
|
||||
/>
|
||||
|
||||
<div className="divide-y divide-border/40">
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.AppearancePane.2edf606c46',
|
||||
'Minimize to Tray on Close'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.b707773a0d',
|
||||
'When enabled, closing the window keeps Orca running in the system tray instead of quitting.'
|
||||
)}
|
||||
keywords={systemTrayEntries[0]?.keywords ?? ['tray', 'minimize', 'close']}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={translate(
|
||||
'auto.components.settings.AppearancePane.2edf606c46',
|
||||
'Minimize to Tray on Close'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.AppearancePane.b707773a0d',
|
||||
'When enabled, closing the window keeps Orca running in the system tray instead of quitting.'
|
||||
)}
|
||||
checked={settings.minimizeToTrayOnClose === true}
|
||||
onChange={() =>
|
||||
updateSettings({ minimizeToTrayOnClose: !settings.minimizeToTrayOnClose })
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, getStatusBarEntries()) ? (
|
||||
<section key="status-bar" className="space-y-3">
|
||||
<SettingsSubsectionHeader
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ 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'
|
||||
|
|
@ -184,8 +186,68 @@ 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() : []
|
||||
}
|
||||
|
||||
type AppearancePaneSearchOptions = {
|
||||
showWarpImport?: boolean
|
||||
showSystemTray?: boolean
|
||||
}
|
||||
|
||||
function buildAppearancePaneSearchEntries(
|
||||
|
|
@ -201,22 +263,16 @@ function buildAppearancePaneSearchEntries(
|
|||
...getTitlebarEntries(),
|
||||
...getStatusBarEntries(),
|
||||
...getSidebarEntries(),
|
||||
...getAppIconEntries()
|
||||
...getAppIconEntries(),
|
||||
...getSystemTrayEntries(options)
|
||||
]
|
||||
}
|
||||
|
||||
const getAppearancePaneSearchEntriesWithWarp = createLocalizedCatalog(() =>
|
||||
buildAppearancePaneSearchEntries({ showWarpImport: true })
|
||||
)
|
||||
|
||||
const getAppearancePaneSearchEntriesWithoutWarp = createLocalizedCatalog(() =>
|
||||
buildAppearancePaneSearchEntries({ showWarpImport: false })
|
||||
)
|
||||
|
||||
export function getAppearancePaneSearchEntries(
|
||||
options: AppearancePaneSearchOptions = {}
|
||||
): SettingsSearchEntry[] {
|
||||
return (options.showWarpImport ?? true)
|
||||
? getAppearancePaneSearchEntriesWithWarp()
|
||||
: getAppearancePaneSearchEntriesWithoutWarp()
|
||||
return buildAppearancePaneSearchEntries({
|
||||
showWarpImport: options.showWarpImport ?? true,
|
||||
showSystemTray: options.showSystemTray
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,16 @@ describe('getTerminalPaneSearchEntries', () => {
|
|||
expect(webEntries.some((entry) => entry.title === 'Import from Ghostty')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes the system tray appearance entry only when desktop tray controls are shown', () => {
|
||||
const desktopEntries = getAppearancePaneSearchEntries({ showSystemTray: true })
|
||||
const webEntries = getAppearancePaneSearchEntries({ showSystemTray: false })
|
||||
|
||||
expect(desktopEntries.some((entry) => entry.title === 'Minimize to Tray on Close')).toBe(true)
|
||||
expect(webEntries.some((entry) => entry.title === 'Minimize to Tray on Close')).toBe(false)
|
||||
expect(matchesSettingsSearch('tray', desktopEntries)).toBe(true)
|
||||
expect(matchesSettingsSearch('tray', webEntries)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps sidebar shortcut restore settings in the Appearance search index', () => {
|
||||
const automationsEntry = getSidebarEntries().find(
|
||||
(entry) => entry.title === 'Show Automations Button'
|
||||
|
|
|
|||
|
|
@ -70,18 +70,14 @@ import { getShortcutsPaneSearchEntries } from '@/components/settings/shortcuts-s
|
|||
import { getStatsPaneSearchEntries } from '@/components/stats/stats-search'
|
||||
import { getExperimentalPaneSearchEntries } from '@/components/settings/experimental-search'
|
||||
import { getRepositoryPaneSearchEntries } from '@/components/settings/repository-search'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import {
|
||||
getCachedWindowsTerminalCapabilities,
|
||||
getWindowsTerminalCapabilityOwnerKey
|
||||
} from '@/lib/windows-terminal-capabilities'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function isWebClientLocation(): boolean {
|
||||
return (
|
||||
Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) ||
|
||||
window.location.pathname.endsWith('/web-index.html')
|
||||
)
|
||||
}
|
||||
export { isWebClientLocation } from '@/lib/web-client-location'
|
||||
|
||||
export function buildSettingsNavigationMetadata({
|
||||
isMac,
|
||||
|
|
@ -333,7 +329,10 @@ export function buildSettingsNavigationMetadata({
|
|||
'Theme, zoom, app and terminal appearance, sidebars, and status bar.'
|
||||
),
|
||||
icon: Palette,
|
||||
searchEntries: getAppearancePaneSearchEntries(),
|
||||
searchEntries: getAppearancePaneSearchEntries({
|
||||
showWarpImport: showDesktopOnlySettings,
|
||||
showSystemTray: showDesktopOnlySettings && isWindows
|
||||
}),
|
||||
group: 'interface'
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -58,6 +58,13 @@
|
|||
"window": "Window",
|
||||
"help": "Help"
|
||||
},
|
||||
"tray": {
|
||||
"openOrca": "Open Orca",
|
||||
"quit": "Quit",
|
||||
"minimizeNotice": {
|
||||
"body": "Orca is still running in the system tray"
|
||||
}
|
||||
},
|
||||
"worktreeJumpPalette": {
|
||||
"matchLabel": {
|
||||
"comment": "Comment",
|
||||
|
|
@ -4383,6 +4390,9 @@
|
|||
"5f5142a62a": "Previous icon"
|
||||
},
|
||||
"AppearancePane": {
|
||||
"872af9556e": "System Tray",
|
||||
"2edf606c46": "Minimize to Tray on Close",
|
||||
"b707773a0d": "When enabled, closing the window keeps Orca running in the system tray instead of quitting.",
|
||||
"3057983501": "Unassigned",
|
||||
"0cd9b8228f": "Choose the app icon shown in the Dock and window switcher.",
|
||||
"ca1590d42f": "App Icon",
|
||||
|
|
@ -6463,6 +6473,8 @@
|
|||
},
|
||||
"appearance": {
|
||||
"search": {
|
||||
"9a115966d3": "Minimize to Tray on Close",
|
||||
"4d5b9427b5": "When enabled, closing the window keeps Orca running in the system tray instead of quitting.",
|
||||
"468448bba4": "watercolor",
|
||||
"f586abfa35": "blue",
|
||||
"651f35b2c6": "switcher",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,13 @@
|
|||
"window": "Ventana",
|
||||
"help": "Ayuda"
|
||||
},
|
||||
"tray": {
|
||||
"openOrca": "Abrir Orca",
|
||||
"quit": "Salir",
|
||||
"minimizeNotice": {
|
||||
"body": "Orca sigue ejecutándose en la bandeja del sistema"
|
||||
}
|
||||
},
|
||||
"worktreeJumpPalette": {
|
||||
"matchLabel": {
|
||||
"comment": "Comentario",
|
||||
|
|
@ -4426,7 +4433,10 @@
|
|||
"tintOpacity": "Intensidad del tinte",
|
||||
"tintOpacityDescription": "Controla con qué fuerza se mezcla el tinte en la barra lateral."
|
||||
},
|
||||
"workspaceCardLayoutGuidance": "Usa el menú de opciones de la barra lateral de espacios de trabajo > Diseño de tarjeta > Compacto."
|
||||
"workspaceCardLayoutGuidance": "Usa el menú de opciones de la barra lateral de espacios de trabajo > Diseño de tarjeta > Compacto.",
|
||||
"872af9556e": "Bandeja del sistema",
|
||||
"2edf606c46": "Minimizar a la bandeja al cerrar",
|
||||
"b707773a0d": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir."
|
||||
},
|
||||
"AutoRenameBranchFromWorkSetting": {
|
||||
"1626524572": "Nautilus",
|
||||
|
|
@ -6549,7 +6559,9 @@
|
|||
"cardLayout": "diseño de tarjeta",
|
||||
"workspaceOptions": "opciones de espacios de trabajo",
|
||||
"detailed": "detallado"
|
||||
}
|
||||
},
|
||||
"9a115966d3": "Minimizar a la bandeja al cerrar",
|
||||
"4d5b9427b5": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir."
|
||||
}
|
||||
},
|
||||
"auto": {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,13 @@
|
|||
"window": "ウィンドウ",
|
||||
"help": "ヘルプ"
|
||||
},
|
||||
"tray": {
|
||||
"openOrca": "Orcaを開く",
|
||||
"quit": "終了",
|
||||
"minimizeNotice": {
|
||||
"body": "Orcaはシステムトレイで実行中です"
|
||||
}
|
||||
},
|
||||
"worktreeJumpPalette": {
|
||||
"matchLabel": {
|
||||
"comment": "コメント",
|
||||
|
|
@ -4411,7 +4418,10 @@
|
|||
"tintOpacity": "色合いの強さ",
|
||||
"tintOpacityDescription": "サイドバーに色合いをどの程度強く混ぜるかを調整します。"
|
||||
},
|
||||
"workspaceCardLayoutGuidance": "ワークスペースサイドバーのオプションメニュー > カードレイアウト > コンパクト を使用します。"
|
||||
"workspaceCardLayoutGuidance": "ワークスペースサイドバーのオプションメニュー > カードレイアウト > コンパクト を使用します。",
|
||||
"872af9556e": "システムトレイ",
|
||||
"2edf606c46": "閉じるときにトレイへ最小化",
|
||||
"b707773a0d": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。"
|
||||
},
|
||||
"AutoRenameBranchFromWorkSetting": {
|
||||
"1626524572": "Nautilus",
|
||||
|
|
@ -6571,7 +6581,9 @@
|
|||
"cardLayout": "カードレイアウト",
|
||||
"workspaceOptions": "ワークスペースオプション",
|
||||
"detailed": "詳細"
|
||||
}
|
||||
},
|
||||
"9a115966d3": "閉じるときにトレイへ最小化",
|
||||
"4d5b9427b5": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。"
|
||||
}
|
||||
},
|
||||
"auto": {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,13 @@
|
|||
"window": "창",
|
||||
"help": "도움말"
|
||||
},
|
||||
"tray": {
|
||||
"openOrca": "Orca 열기",
|
||||
"quit": "종료",
|
||||
"minimizeNotice": {
|
||||
"body": "Orca가 시스템 트레이에서 계속 실행 중입니다"
|
||||
}
|
||||
},
|
||||
"worktreeJumpPalette": {
|
||||
"matchLabel": {
|
||||
"comment": "댓글",
|
||||
|
|
@ -4368,6 +4375,9 @@
|
|||
"5f5142a62a": "이전 아이콘"
|
||||
},
|
||||
"AppearancePane": {
|
||||
"872af9556e": "시스템 트레이",
|
||||
"2edf606c46": "닫을 때 트레이로 최소화",
|
||||
"b707773a0d": "활성화하면 창을 닫아도 Orca가 종료되지 않고 시스템 트레이에서 계속 실행됩니다.",
|
||||
"3057983501": "할당되지 않음",
|
||||
"0cd9b8228f": "Dock 및 창 전환기에 표시된 앱 아이콘을 선택합니다.",
|
||||
"ca1590d42f": "앱 아이콘",
|
||||
|
|
@ -6524,6 +6534,8 @@
|
|||
"title": "왼쪽 사이드바 모양",
|
||||
"description": "왼쪽 사이드바를 terminal에 맞추거나 기본값을 유지하거나 은은한 색조를 적용합니다."
|
||||
},
|
||||
"9a115966d3": "닫을 때 트레이로 최소화",
|
||||
"4d5b9427b5": "활성화하면 창을 닫아도 Orca가 종료되지 않고 시스템 트레이에서 계속 실행됩니다.",
|
||||
"workspaceCardLayout": {
|
||||
"title": "워크스페이스 카드 레이아웃",
|
||||
"description": "워크스페이스 사이드바 옵션 메뉴에서 워크스페이스 카드를 컴팩트 또는 상세 보기로 전환합니다.",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,13 @@
|
|||
"window": "窗口",
|
||||
"help": "帮助"
|
||||
},
|
||||
"tray": {
|
||||
"openOrca": "打开 Orca",
|
||||
"quit": "退出",
|
||||
"minimizeNotice": {
|
||||
"body": "Orca 仍在系统托盘中运行"
|
||||
}
|
||||
},
|
||||
"worktreeJumpPalette": {
|
||||
"matchLabel": {
|
||||
"comment": "评论",
|
||||
|
|
@ -4411,7 +4418,10 @@
|
|||
"tintOpacity": "色调强度",
|
||||
"tintOpacityDescription": "控制色调混入边栏的强度。"
|
||||
},
|
||||
"workspaceCardLayoutGuidance": "使用工作区侧边栏选项菜单 > 卡片布局 > 紧凑。"
|
||||
"workspaceCardLayoutGuidance": "使用工作区侧边栏选项菜单 > 卡片布局 > 紧凑。",
|
||||
"872af9556e": "系统托盘",
|
||||
"2edf606c46": "关闭时最小化到托盘",
|
||||
"b707773a0d": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。"
|
||||
},
|
||||
"AutoRenameBranchFromWorkSetting": {
|
||||
"1626524572": "Nautilus",
|
||||
|
|
@ -6534,7 +6544,9 @@
|
|||
"cardLayout": "卡片布局",
|
||||
"workspaceOptions": "工作区选项",
|
||||
"detailed": "详细"
|
||||
}
|
||||
},
|
||||
"9a115966d3": "关闭时最小化到托盘",
|
||||
"4d5b9427b5": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。"
|
||||
}
|
||||
},
|
||||
"auto": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
export function isWebClientLocation(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) ||
|
||||
window.location.pathname.endsWith('/web-index.html')
|
||||
)
|
||||
}
|
||||
|
|
@ -250,6 +250,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
|||
// focus-follows-mouse never happens unexpectedly.
|
||||
terminalFocusFollowsMouse: false,
|
||||
windowBackgroundBlur: false,
|
||||
minimizeToTrayOnClose: false,
|
||||
terminalClipboardOnSelect: false,
|
||||
terminalAllowOsc52Clipboard: false,
|
||||
claudeAgentTeamsMode: 'off',
|
||||
|
|
@ -472,6 +473,7 @@ export function getDefaultUIState(): PersistedUIState {
|
|||
setupGuideBrowserMilestoneMigrated: true,
|
||||
setupGuideBrowserMilestoneLegacyComplete: false,
|
||||
browserImportHintHidden: false,
|
||||
trayMinimizeNoticeShown: false,
|
||||
mobileEmulatorTabIntroDismissed: false,
|
||||
mobileEmulatorAgentSetupDismissed: false,
|
||||
// Why: brand-new profiles never saw recent project ordering; only upgraded
|
||||
|
|
|
|||
|
|
@ -2419,6 +2419,10 @@ export type GlobalSettings = {
|
|||
terminalCursorOpacity?: number
|
||||
terminalQuickCommands?: TerminalQuickCommand[]
|
||||
windowBackgroundBlur?: boolean
|
||||
/** Why: Windows-only. When on, the close (X) button hides the window to the
|
||||
* 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: Windows terminals conventionally use right-click as a paste gesture.
|
||||
* The setting stays Windows-only so macOS/Linux keep their existing context
|
||||
* menu behavior and users can still reach the menu with Ctrl+right-click. */
|
||||
|
|
@ -3102,6 +3106,9 @@ export type PersistedUIState = {
|
|||
/** User-dismissed browser import hint in the browser toolbar. Import remains
|
||||
* available from Settings > Browser and the toolbar overflow menu. */
|
||||
browserImportHintHidden?: boolean
|
||||
/** Why: Windows-only. Set once after the window first hides to the system
|
||||
* tray, so the "Orca is still running" notification shows only on first use. */
|
||||
trayMinimizeNoticeShown?: boolean
|
||||
/** User dismissed the first-run Mobile Emulator intro (Keep, Hide, or close).
|
||||
* Reversible only by re-enabling the feature in Settings. */
|
||||
mobileEmulatorTabIntroDismissed?: boolean
|
||||
|
|
|
|||
Loading…
Reference in New Issue