diff --git a/resources/app-icons/orca-blue.png b/resources/app-icons/orca-blue.png new file mode 100644 index 000000000..8d4a6e6b7 Binary files /dev/null and b/resources/app-icons/orca-blue.png differ diff --git a/resources/app-icons/orca-watercolor.png b/resources/app-icons/orca-watercolor.png new file mode 100644 index 000000000..b51e2c90e Binary files /dev/null and b/resources/app-icons/orca-watercolor.png differ diff --git a/src/main/app-icon.test.ts b/src/main/app-icon.test.ts new file mode 100644 index 000000000..ccef86672 --- /dev/null +++ b/src/main/app-icon.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + browserWindowGetAllWindowsMock, + createFromPathMock, + dockSetIconMock, + isMock, + windowSetIconMock +} = vi.hoisted(() => ({ + browserWindowGetAllWindowsMock: vi.fn(), + createFromPathMock: vi.fn(), + dockSetIconMock: vi.fn(), + isMock: { dev: false }, + windowSetIconMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { dock: { setIcon: dockSetIconMock } }, + BrowserWindow: { getAllWindows: browserWindowGetAllWindowsMock }, + nativeImage: { createFromPath: createFromPathMock } +})) + +vi.mock('@electron-toolkit/utils', () => ({ + is: isMock +})) + +vi.mock('../../resources/icon.png?asset', () => ({ + default: 'classic-icon' +})) + +vi.mock('../../resources/icon-dev.png?asset', () => ({ + default: 'classic-dev-icon' +})) + +vi.mock('../../resources/app-icons/orca-watercolor.png?asset', () => ({ + default: 'watercolor-icon' +})) + +vi.mock('../../resources/app-icons/orca-blue.png?asset', () => ({ + default: 'blue-icon' +})) + +import { applyAppIcon, getAppIconPath } from './app-icon' + +describe('app icon selection', () => { + beforeEach(() => { + browserWindowGetAllWindowsMock.mockReset() + createFromPathMock.mockReset() + dockSetIconMock.mockReset() + windowSetIconMock.mockReset() + isMock.dev = false + }) + + it('resolves classic, watercolor, blue, and invalid icon ids', () => { + expect(getAppIconPath('classic')).toBe('classic-icon') + expect(getAppIconPath('watercolor')).toBe('watercolor-icon') + expect(getAppIconPath('blue')).toBe('blue-icon') + expect(getAppIconPath('missing')).toBe('classic-icon') + }) + + it('applies the selected icon to the dock and live windows', () => { + const image = { isEmpty: () => false } + createFromPathMock.mockReturnValue(image) + browserWindowGetAllWindowsMock.mockReturnValue([ + { isDestroyed: () => false, setIcon: windowSetIconMock }, + { isDestroyed: () => true, setIcon: vi.fn() } + ]) + + applyAppIcon('watercolor') + + expect(createFromPathMock).toHaveBeenCalledWith('watercolor-icon') + if (process.platform === 'darwin') { + expect(dockSetIconMock).toHaveBeenCalledWith(image) + } else { + expect(dockSetIconMock).not.toHaveBeenCalled() + } + expect(windowSetIconMock).toHaveBeenCalledWith(image) + }) +}) diff --git a/src/main/app-icon.ts b/src/main/app-icon.ts new file mode 100644 index 000000000..aa52133c6 --- /dev/null +++ b/src/main/app-icon.ts @@ -0,0 +1,36 @@ +import { app, BrowserWindow, nativeImage } from 'electron' +import { is } from '@electron-toolkit/utils' +import classicIcon from '../../resources/icon.png?asset' +import classicDevIcon from '../../resources/icon-dev.png?asset' +import watercolorIcon from '../../resources/app-icons/orca-watercolor.png?asset' +import blueIcon from '../../resources/app-icons/orca-blue.png?asset' +import { normalizeAppIconId, type AppIconId } from '../shared/app-icon' + +const APP_ICON_PATHS = { + classic: is.dev ? classicDevIcon : classicIcon, + watercolor: watercolorIcon, + blue: blueIcon +} satisfies Record + +export function getAppIconPath(value: unknown): string { + return APP_ICON_PATHS[normalizeAppIconId(value)] +} + +export function createAppIconImage(value: unknown): Electron.NativeImage { + return nativeImage.createFromPath(getAppIconPath(value)) +} + +export function applyAppIcon(value: unknown): void { + const image = createAppIconImage(value) + if (image.isEmpty()) { + return + } + if (process.platform === 'darwin') { + app.dock?.setIcon(image) + } + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.setIcon(image) + } + } +} diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index b3f9fa4f4..5266aa80e 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -49,6 +49,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings branchPrefix: 'git-username', branchPrefixCustom: '', theme: 'system', + appIcon: overrides.appIcon ?? 'classic', editorAutoSave: false, editorAutoSaveDelayMs: 1000, editorMinimapEnabled: false, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index bf915a989..ef4c16824 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -53,6 +53,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings branchPrefix: 'git-username', branchPrefixCustom: '', theme: 'system', + appIcon: overrides.appIcon ?? 'classic', editorAutoSave: false, editorAutoSaveDelayMs: 1000, editorMinimapEnabled: false, diff --git a/src/main/index.ts b/src/main/index.ts index 80169cb7b..0130c90a8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,11 +6,11 @@ import { grantDirAcl } from './win32-utils' import { existsSync } from 'fs' import { join } from 'path' import os from 'node:os' -import { app, BrowserWindow, nativeImage, nativeTheme } from 'electron' +import { app, BrowserWindow, nativeTheme } from 'electron' import { electronApp, is } from '@electron-toolkit/utils' import * as QRCode from 'qrcode' -import devIcon from '../../resources/icon-dev.png?asset' import { Store, initDataPath } from './persistence' +import { applyAppIcon } from './app-icon' import { StatsCollector, initStatsPath } from './stats/collector' import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' @@ -1052,12 +1052,8 @@ app.whenReady().then(async () => { electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) app.setName(devInstanceIdentity.name) - if (process.platform === 'darwin' && is.dev) { - const dockIcon = nativeImage.createFromPath(devIcon) - app.dock?.setIcon(dockIcon) - } - store = new Store() + applyAppIcon(store.getSettings().appIcon) if (shouldSuppressDevEducation({ isDev: is.dev })) { suppressDevEducationForStore(store) } diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index d1b19f587..e4e5043ce 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' const { + applyAppIconMock, applyElectronProxySettingsMock, browserWindowGetAllWindowsMock, handleMock, previewGhosttyImportMock } = vi.hoisted(() => ({ + applyAppIconMock: vi.fn(), applyElectronProxySettingsMock: vi.fn(), browserWindowGetAllWindowsMock: vi.fn(), handleMock: vi.fn(), @@ -26,6 +28,10 @@ vi.mock('../network/proxy-settings', () => ({ applyElectronProxySettings: applyElectronProxySettingsMock })) +vi.mock('../app-icon', () => ({ + applyAppIcon: applyAppIconMock +})) + import { registerSettingsHandlers } from './settings' const settingsInvokeEvent = { sender: { id: 1 } } @@ -46,6 +52,7 @@ const store = { describe('registerSettingsHandlers', () => { beforeEach(() => { handleMock.mockClear() + applyAppIconMock.mockClear() applyElectronProxySettingsMock.mockClear() applyElectronProxySettingsMock.mockResolvedValue({ source: 'settings' }) previewGhosttyImportMock.mockClear() @@ -216,4 +223,42 @@ describe('registerSettingsHandlers', () => { ) expect(applyElectronProxySettingsMock).toHaveBeenCalledWith({ httpProxyUrl: '' }) }) + + it('normalizes and applies app icon changes from renderer settings IPC', async () => { + store.getSettings.mockReturnValue({ appIcon: 'classic' }) + store.updateSettings.mockReturnValue({ appIcon: 'watercolor' }) + registerSettingsHandlers(store as never) + + const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as ( + _event: unknown, + args: unknown + ) => Promise + + await handler(settingsInvokeEvent, { appIcon: 'watercolor' }) + + expect(store.updateSettings).toHaveBeenCalledWith( + { appIcon: 'watercolor' }, + { notifyListeners: true, originWebContentsId: 1 } + ) + expect(applyAppIconMock).toHaveBeenCalledWith('watercolor') + }) + + it('falls back to the classic app icon for invalid renderer settings IPC values', async () => { + store.getSettings.mockReturnValue({ appIcon: 'watercolor' }) + store.updateSettings.mockReturnValue({ appIcon: 'classic' }) + registerSettingsHandlers(store as never) + + const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as ( + _event: unknown, + args: unknown + ) => Promise + + await handler(settingsInvokeEvent, { appIcon: 'not-real' }) + + expect(store.updateSettings).toHaveBeenCalledWith( + { appIcon: 'classic' }, + { notifyListeners: true, originWebContentsId: 1 } + ) + expect(applyAppIconMock).toHaveBeenCalledWith('classic') + }) }) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 60a457e97..f8ab5fa43 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -11,6 +11,8 @@ import { sanitizeFloatingWorkspaceDirectorySetting } from './floating-workspace- import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { applyElectronProxySettings } from '../network/proxy-settings' import { normalizeProxyBypassRules, normalizeProxyUrl } from '../../shared/network-proxy' +import { normalizeAppIconId } from '../../shared/app-icon' +import { applyAppIcon } from '../app-icon' // Why: the whitelist is the source-of-truth for which keys we emit on. Casting // to a Set once at module load lets the IPC handler's per-key membership @@ -63,6 +65,9 @@ export function registerSettingsHandlers( if ('httpProxyBypassRules' in args) { sanitizedArgs.httpProxyBypassRules = normalizeProxyBypassRules(args.httpProxyBypassRules) } + if ('appIcon' in args) { + sanitizedArgs.appIcon = normalizeAppIconId(args.appIcon) + } if (args.theme) { nativeTheme.themeSource = args.theme } @@ -98,6 +103,9 @@ export function registerSettingsHandlers( console.warn('[settings] failed to apply network proxy settings') } } + if ('appIcon' in sanitizedArgs && before.appIcon !== result.appIcon) { + applyAppIcon(result.appIcon) + } // Why: telemetry-plan.md§Settings — fire `settings_changed` only for // whitelisted keys, with `value_kind` distinguishing booleans from diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index f9a701dbf..4a1829070 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -257,6 +257,7 @@ describe('Store', () => { expect(settings.branchPrefix).toBe('git-username') expect(settings.refreshLocalBaseRefOnWorktreeCreate).toBe(false) expect(settings.theme).toBe('system') + expect(settings.appIcon).toBe('classic') expect(settings.appFontFamily).toBe('Geist') expect(settings.editorAutoSave).toBe(false) expect(settings.editorAutoSaveDelayMs).toBe(1000) @@ -2269,6 +2270,24 @@ describe('Store', () => { expect(updated.disabledTuiAgents).toEqual(['gemini', 'opencode']) }) + it('normalizes app icon on load and update', async () => { + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ + settings: { + appIcon: 'not-real' + } + }) + ) + const store = await createStore() + + expect(store.getSettings().appIcon).toBe('classic') + + expect(store.updateSettings({ appIcon: 'watercolor' }).appIcon).toBe('watercolor') + expect(store.updateSettings({ appIcon: 'blue' }).appIcon).toBe('blue') + expect(store.updateSettings({ appIcon: 'not-real' as never }).appIcon).toBe('classic') + }) + it('updateSettings keeps the legacy commit-message AI projection in sync', async () => { const store = await createStore() const current = store.getSettings().sourceControlAi! diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 4f2cc2d7a..7b4b221b1 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -90,6 +90,7 @@ import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-command import { normalizeTaskProviderSettings } from '../shared/task-providers' import { normalizeOpenInApplications } from '../shared/open-in-applications' import { normalizeTerminalShortcutPolicy } from '../shared/keybindings' +import { normalizeAppIconId } from '../shared/app-icon' import { normalizeFeatureInteractions, type FeatureInteractionId @@ -1852,6 +1853,7 @@ export class Store { terminalQuickCommands: normalizeTerminalQuickCommands( parsed.settings?.terminalQuickCommands ), + appIcon: normalizeAppIconId(parsed.settings?.appIcon), defaultTaskSource: taskProviderSettings.defaultTaskSource, visibleTaskProviders: taskProviderSettings.visibleTaskProviders, visibleTaskProvidersDefaultedForJira: true, @@ -2983,6 +2985,9 @@ export class Store { updates.terminalShortcutPolicy ) } + if ('appIcon' in updates) { + sanitizedUpdates.appIcon = normalizeAppIconId(updates.appIcon) + } const historyWithPreviousLayout = buildWorkspaceDirHistoryForUpdate( this.state.settings, sanitizedUpdates diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 4535c4503..d4ce59e3b 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -36,12 +36,8 @@ vi.mock('@electron-toolkit/utils', () => ({ is: isMock })) -vi.mock('../../../resources/icon.png?asset', () => ({ - default: 'icon' -})) - -vi.mock('../../../resources/icon-dev.png?asset', () => ({ - default: 'icon-dev' +vi.mock('../app-icon', () => ({ + getAppIconPath: vi.fn(() => 'icon') })) vi.mock('../browser/browser-manager', () => ({ diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 93c4e1e6a..f3de6130d 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -2,9 +2,8 @@ import { app, BrowserWindow, ipcMain, Menu, nativeTheme, screen, shell } from 'electron' import { join } from 'path' import { is } from '@electron-toolkit/utils' -import icon from '../../../resources/icon.png?asset' -import devIcon from '../../../resources/icon-dev.png?asset' import type { Store } from '../persistence' +import { getAppIconPath } from '../app-icon' import { browserManager } from '../browser/browser-manager' import { browserSessionRegistry } from '../browser/browser-session-registry' import { @@ -264,7 +263,7 @@ export function createMainWindow( } } : {}), - icon: is.dev ? devIcon : icon, + icon: getAppIconPath(settings?.appIcon), ...platformBlurOptions, webPreferences: { preload: join(__dirname, '../preload/index.js'), diff --git a/src/renderer/src/components/settings/AppIconSelector.tsx b/src/renderer/src/components/settings/AppIconSelector.tsx new file mode 100644 index 000000000..e7b341352 --- /dev/null +++ b/src/renderer/src/components/settings/AppIconSelector.tsx @@ -0,0 +1,71 @@ +import type React from 'react' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import classicIconUrl from '../../../../../resources/icon.png?url' +import watercolorIconUrl from '../../../../../resources/app-icons/orca-watercolor.png?url' +import blueIconUrl from '../../../../../resources/app-icons/orca-blue.png?url' +import { APP_ICON_OPTIONS, normalizeAppIconId, type AppIconId } from '../../../../shared/app-icon' +import { Button } from '../ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' + +const APP_ICON_URLS = { + classic: classicIconUrl, + watercolor: watercolorIconUrl, + blue: blueIconUrl +} satisfies Record + +type AppIconSelectorProps = { + value: AppIconId + onChange: (value: AppIconId) => void +} + +function getAppIconOptionIndex(value: AppIconId): number { + const index = APP_ICON_OPTIONS.findIndex((option) => option.id === value) + return Math.max(index, 0) +} + +function getOffsetIcon(value: AppIconId, offset: -1 | 1): AppIconId { + const index = getAppIconOptionIndex(value) + const next = (index + offset + APP_ICON_OPTIONS.length) % APP_ICON_OPTIONS.length + return APP_ICON_OPTIONS[next].id +} + +type IconCycleButtonProps = { + label: string + onClick: () => void + children: React.ReactNode +} + +function IconCycleButton({ label, onClick, children }: IconCycleButtonProps): React.JSX.Element { + return ( + + + + + + {label} + + + ) +} + +export function AppIconSelector({ value, onChange }: AppIconSelectorProps): React.JSX.Element { + const selected = normalizeAppIconId(value) + + return ( +
+ onChange(getOffsetIcon(selected, -1))}> + + + Selected app icon + onChange(getOffsetIcon(selected, 1))}> + + +
+ ) +} diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index cb9efa941..01bf0e4c2 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -17,8 +17,10 @@ import { SettingsSwitchRow } from './SettingsFormControls' import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants' +import { normalizeAppIconId } from '../../../../shared/app-icon' import { useAvailableStatusBarToggles } from '../status-bar/use-available-status-bar-toggles' import { + APP_ICON_ENTRIES, APPEARANCE_PANE_SEARCH_ENTRIES, LAYOUT_ENTRIES, SIDEBAR_ENTRIES, @@ -32,6 +34,7 @@ import { import { TERMINAL_APPEARANCE_SEARCH_ENTRIES } from './terminal-search' import { TerminalAppearanceSection } from './TerminalAppearanceSection' import type { UseGhosttyImportReturn } from './useGhosttyImport' +import { AppIconSelector } from './AppIconSelector' export { APPEARANCE_PANE_SEARCH_ENTRIES } type AppearancePaneProps = { @@ -295,6 +298,25 @@ export function AppearancePane({ + ) : null, + matchesSettingsSearch(searchQuery, APP_ICON_ENTRIES) ? ( +
+ [ + entry.title, + entry.description ?? '', + ...(entry.keywords ?? []) + ])} + className="max-w-none py-2" + > + updateSettings({ appIcon })} + /> + +
) : null ].filter(Boolean) diff --git a/src/renderer/src/components/settings/appearance-search.ts b/src/renderer/src/components/settings/appearance-search.ts index 989b1e64a..23757a9c3 100644 --- a/src/renderer/src/components/settings/appearance-search.ts +++ b/src/renderer/src/components/settings/appearance-search.ts @@ -120,6 +120,14 @@ export const SIDEBAR_ENTRIES: SettingsSearchEntry[] = [ } ] +export const APP_ICON_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'App Icon', + description: 'Choose the app icon shown in the Dock and window switcher.', + keywords: ['app icon', 'orca', 'dock', 'window', 'switcher', 'blue', 'watercolor'] + } +] + export const APPEARANCE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ ...THEME_ENTRIES, ...TYPOGRAPHY_ENTRIES, @@ -128,5 +136,6 @@ export const APPEARANCE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ ...LAYOUT_ENTRIES, ...TITLEBAR_ENTRIES, ...STATUS_BAR_ENTRIES, - ...SIDEBAR_ENTRIES + ...SIDEBAR_ENTRIES, + ...APP_ICON_ENTRIES ] diff --git a/src/shared/app-icon.ts b/src/shared/app-icon.ts new file mode 100644 index 000000000..fb8245ad3 --- /dev/null +++ b/src/shared/app-icon.ts @@ -0,0 +1,15 @@ +export const APP_ICON_OPTIONS = [ + { id: 'classic', label: 'Classic Orca' }, + { id: 'watercolor', label: 'Watercolor Orca' }, + { id: 'blue', label: 'Blue Orca' } +] as const + +export type AppIconId = (typeof APP_ICON_OPTIONS)[number]['id'] + +export const DEFAULT_APP_ICON_ID: AppIconId = 'classic' + +export function normalizeAppIconId(value: unknown): AppIconId { + return APP_ICON_OPTIONS.some((option) => option.id === value) + ? (value as AppIconId) + : DEFAULT_APP_ICON_ID +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 9bd30629b..b2e96a422 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -18,6 +18,7 @@ import { cloneDefaultWorkspaceStatuses } from './workspace-statuses' import { TASK_PROVIDERS } from './task-providers' import { DEFAULT_WORKTREE_CARD_PROPERTIES } from './worktree-card-properties' import { getDefaultSourceControlAiSettings } from './source-control-ai' +import { DEFAULT_APP_ICON_ID } from './app-icon' export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults' export { @@ -173,6 +174,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { branchPrefixCustom: '', enableGitHubAttribution: false, theme: 'system', + appIcon: DEFAULT_APP_ICON_ID, appFontFamily: DEFAULT_APP_FONT_FAMILY, editorAutoSave: false, editorAutoSaveDelayMs: DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS, diff --git a/src/shared/types.ts b/src/shared/types.ts index 0d95ae786..b38e2fcaf 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -18,6 +18,7 @@ import type { FeatureInteractionState } from './feature-interactions' import type { GitBranchChangeStatus } from './git-status-types' import type { KeybindingOverrides, TerminalShortcutPolicy } from './keybindings' import type { RepoIcon } from './repo-icon' +import type { AppIconId } from './app-icon' import type { RepoSourceControlAiOverrides, SourceControlAiSettings @@ -1952,6 +1953,7 @@ export type GlobalSettings = { branchPrefixCustom: string enableGitHubAttribution: boolean theme: 'system' | 'dark' | 'light' + appIcon: AppIconId appFontFamily: string editorAutoSave: boolean editorAutoSaveDelayMs: number