diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index f8304642e48..1abd90b560e 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { handlers, appExitMock, appRelaunchMock } = vi.hoisted(() => ({ +const { handlers, appExitMock, appQuitMock, appRelaunchMock } = vi.hoisted(() => ({ handlers: new Map unknown>(), appExitMock: vi.fn(), + appQuitMock: vi.fn(), appRelaunchMock: vi.fn() })) @@ -11,6 +12,7 @@ vi.mock('electron', () => ({ exit: appExitMock, getAppPath: vi.fn(() => '/test/app'), isPackaged: false, + quit: appQuitMock, relaunch: appRelaunchMock }, BrowserWindow: { @@ -37,6 +39,7 @@ describe('registerAppHandlers', () => { vi.useFakeTimers() handlers.clear() appExitMock.mockReset() + appQuitMock.mockReset() appRelaunchMock.mockReset() }) @@ -59,4 +62,22 @@ describe('registerAppHandlers', () => { expect(appRelaunchMock).toHaveBeenCalledTimes(1) expect(appExitMock).toHaveBeenCalledWith(0) }) + + it('marks restart as expected shutdown before quitting through the normal pipeline', () => { + const onBeforeRelaunch = vi.fn() + registerAppHandlers({} as never, { onBeforeRelaunch }) + + handlers.get('app:restart')?.(null) + + expect(onBeforeRelaunch).toHaveBeenCalledTimes(1) + expect(appRelaunchMock).not.toHaveBeenCalled() + expect(appQuitMock).not.toHaveBeenCalled() + expect(appExitMock).not.toHaveBeenCalled() + + vi.advanceTimersByTime(150) + + expect(appRelaunchMock).toHaveBeenCalledTimes(1) + expect(appQuitMock).toHaveBeenCalledTimes(1) + expect(appExitMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 1b3e96c9447..2fb289e3ece 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -178,6 +178,17 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp }, 150) }) + ipcMain.handle('app:restart', () => { + // Why: the hidden admin restart should mirror the update relaunch path: + // schedule a new Orca process, then use the normal quit pipeline so daemon + // checkpoints, runtime metadata, and telemetry flush before exit. + options.onBeforeRelaunch?.() + setTimeout(() => { + app.relaunch() + app.quit() + }, 150) + }) + ipcMain.handle('app:setUnreadDockBadgeCount', (_event, count: number) => { setUnreadDockBadgeCount(Number.isFinite(count) ? count : 0) }) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 163b345773a..835c9e2786b 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -581,6 +581,9 @@ export type AppApi = { * by settings panes that need a full restart to apply changes (e.g. the * terminal-window blur setting in TerminalWindowSection). */ relaunch: () => Promise + /** Restarts Orca through the normal quit pipeline so daemon-backed terminal + * sessions survive and can reattach after the new process starts. */ + restart: () => Promise /** Reloads the current app renderer through main so expected renderer * teardown can be classified before Electron emits process-gone events. */ reload: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 7c27dd5b7e1..68be46b6143 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -140,6 +140,8 @@ import { type EditorSaveDirtyFilesDetail } from '../shared/editor-save-events' import { + ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_APP_RESTART_STARTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' @@ -169,6 +171,62 @@ type NativeFileDropCallback = (data: NativeFileDropPayload) => void const nativeFileDropCallbacks: NativeFileDropCallback[] = [] let nativeFileDropListenerRegistered = false +type AppRestartPrepOptions = { + startedEventName: string + abortedEventName: string + continueOnSaveFailure: boolean + saveFailureLogPrefix: string +} + +function requestDirtyEditorFileSave(): Promise { + return new Promise((resolve, reject) => { + let claimed = false + window.dispatchEvent( + new CustomEvent(ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, { + detail: { + claim: () => { + claimed = true + }, + resolve, + reject: (message) => { + reject(new Error(message)) + } + } + }) + ) + + // Why: restart paths can run when no editor surface is mounted. When + // nothing claims the request there are no in-memory editor buffers to + // flush, so proceed with the normal shutdown path immediately. + if (!claimed) { + resolve() + } + }) +} + +async function prepareRendererForAppRestart({ + startedEventName, + abortedEventName, + continueOnSaveFailure, + saveFailureLogPrefix +}: AppRestartPrepOptions): Promise { + window.dispatchEvent(new Event(startedEventName)) + + try { + await requestDirtyEditorFileSave() + } catch (error) { + if (!continueOnSaveFailure) { + window.dispatchEvent(new Event(abortedEventName)) + throw error + } + console.warn(saveFailureLogPrefix, error) + } + + // Dispatch beforeunload now so terminal buffers are captured while panes are + // still mounted; update installs later bypass the ordinary close sequence. + window.dispatchEvent(new Event('beforeunload')) +} + const onNativeFileDrop = (_event: Electron.IpcRendererEvent, data: NativeFileDropPayload): void => { for (const callback of Array.from(nativeFileDropCallbacks)) { callback(data) @@ -358,6 +416,20 @@ const api = { getFeatureWallAssetBaseUrl: (): Promise => ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'), relaunch: (): Promise => ipcRenderer.invoke('app:relaunch'), + restart: async (): Promise => { + await prepareRendererForAppRestart({ + startedEventName: ORCA_APP_RESTART_STARTED_EVENT, + abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, + continueOnSaveFailure: false, + saveFailureLogPrefix: '[app-restart] Saving dirty files before restart failed:' + }) + try { + return await ipcRenderer.invoke('app:restart') + } catch (error) { + window.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + throw error + } + }, reload: (): Promise => ipcRenderer.invoke('app:reload'), // Why: on macOS this returns AppleCurrentKeyboardLayoutInputSourceID so // the renderer's keyboard-layout probe can distinguish Polish Pro / US @@ -1933,53 +2005,16 @@ const api = { download: (): Promise => ipcRenderer.invoke('updater:download'), dismissNudge: (): Promise => ipcRenderer.invoke('updater:dismissNudge'), quitAndInstall: async (): Promise => { - // Why: quitAndInstall closes the BrowserWindow directly from the main - // process. Renderer beforeunload guards treat that like a normal window - // close unless we mark the updater path explicitly, and #300 introduced - // longer-lived editor dirty/autosave state that can otherwise veto the - // restart even after the update payload has been downloaded. - window.dispatchEvent(new Event(ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT)) - - // Why: we wrap the save attempt in try/catch so that a save failure - // (e.g., unsupported dirty files or a write error) never silently - // prevents the update from installing. The user already clicked - // "install update" — proceeding with the restart is better than - // leaving them stuck with no feedback. - try { - await new Promise((resolve, reject) => { - let claimed = false - window.dispatchEvent( - new CustomEvent(ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, { - detail: { - claim: () => { - claimed = true - }, - resolve, - reject: (message) => { - reject(new Error(message)) - } - } - }) - ) - - // Why: updater installs can run when no editor surface is mounted. - // When nothing claims the request there are no in-memory editor buffers - // to flush, so proceed with the normal shutdown path immediately. - if (!claimed) { - resolve() - } - }) - } catch (error) { - console.warn( - '[updater] Saving dirty files before quit failed; proceeding with install anyway:', - error - ) - } - - // Dispatch beforeunload to trigger terminal buffer capture before the - // update process bypasses the normal window close sequence (quitAndInstall - // removes close listeners, preventing beforeunload from firing naturally). - window.dispatchEvent(new Event('beforeunload')) + // Why: update installs must proceed even when a dirty-file auto-save + // fails; otherwise a downloaded update can get stuck behind hidden editor + // state. Manual app restart uses the same prep but aborts on save failure. + await prepareRendererForAppRestart({ + startedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT, + abortedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, + continueOnSaveFailure: true, + saveFailureLogPrefix: + '[updater] Saving dirty files before quit failed; proceeding with install anyway:' + }) try { return await ipcRenderer.invoke('updater:quitAndInstall') } catch (error) { diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index f10e94a9e5a..c891fdd9549 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -31,7 +31,7 @@ import { type EditorRequestFileCloseDetail, requestEditorSaveQuiesce } from './editor/editor-autosave' -import { isUpdaterQuitAndInstallInProgress } from '@/lib/updater-beforeunload' +import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload' import EditorAutosaveController from './editor/EditorAutosaveController' import type { TabGroupLayoutNode } from '../../../shared/types' import BrowserPane from './browser-pane/BrowserPane' @@ -1330,10 +1330,9 @@ function Terminal(): React.JSX.Element | null { // Warn on window close if there are unsaved editor files useEffect(() => { const handler = (e: BeforeUnloadEvent): void => { - // Why: updater restarts intentionally close the app even if a hidden - // editor tab still reports dirty. Let ShipIt replace the bundle instead - // of vetoing quitAndInstall and leaving the old version running. - if (isUpdaterQuitAndInstallInProgress()) { + // Why: update/manual restarts pre-save dirty tabs and then intentionally + // close the app. Do not let stale dirty flags veto the relaunch path. + if (isIntentionalAppRestartInProgress()) { return } const dirtyFiles = useAppStore.getState().openFiles.filter((f) => f.isDirty) @@ -1350,7 +1349,7 @@ function Terminal(): React.JSX.Element | null { // close here. Explicit destructive terminal actions keep their own confirms. useEffect(() => { return window.api.ui.onWindowCloseRequested(({ isQuitting }) => { - if (isUpdaterQuitAndInstallInProgress()) { + if (isIntentionalAppRestartInProgress()) { window.api.ui.confirmWindowClose() return } diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 08ee402ab07..811b2d11c62 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -45,6 +45,7 @@ import { SettingsSidebar } from './SettingsSidebar' import { ActiveSettingsSectionProvider, SettingsSection } from './SettingsSection' import { matchesSettingsSearch } from './settings-search' import { cn } from '@/lib/utils' +import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { getShortcutPlatform } from '@/lib/shortcut-platform' @@ -295,6 +296,9 @@ function Settings(): React.JSX.Element { useEffect(() => { const handleBeforeUnload = (event: BeforeUnloadEvent): void => { + if (isIntentionalAppRestartInProgress()) { + return + } if (!hasUnsavedCommitPromptChanges) { return } diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.tsx index 476b4d42ef3..192d7ef79e5 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.tsx @@ -8,6 +8,7 @@ import { Github, HardDrive, MessageSquareText, + RotateCw, School, Settings, Smartphone @@ -19,6 +20,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { @@ -258,6 +260,9 @@ const SidebarToolbar = React.memo(function SidebarToolbar() { const openSpacePage = useAppStore((s) => s.openSpacePage) const openMobilePage = useAppStore((s) => s.openMobilePage) const [feedbackOpen, setFeedbackOpen] = useState(false) + const [helpMenuOpen, setHelpMenuOpen] = useState(false) + const [showAdminHelpOptions, setShowAdminHelpOptions] = useState(false) + const [isRestartingOrca, setIsRestartingOrca] = useState(false) const lastShowOnboardingAtRef = React.useRef(0) const handleShowOnboarding = (): void => { @@ -269,6 +274,34 @@ const SidebarToolbar = React.memo(function SidebarToolbar() { void showOnboardingFromRenderer() } + const handleHelpMenuOpenChange = (open: boolean): void => { + setHelpMenuOpen(open) + if (!open) { + setShowAdminHelpOptions(false) + } + } + + const revealAdminHelpOptions = (altKey: boolean): void => { + // Why: keep restart off the ordinary Help menu; Alt/Option-click is an + // intentional admin affordance for recovering the app without teaching it + // as a normal user workflow. + setShowAdminHelpOptions(altKey) + } + + const handleRestartOrca = (): void => { + if (isRestartingOrca) { + return + } + setIsRestartingOrca(true) + toast.info('Restarting Orca…') + void window.api.app.restart().catch((error) => { + setIsRestartingOrca(false) + toast.error('Couldn’t restart Orca.', { + description: error instanceof Error ? error.message : undefined + }) + }) + } + return (
@@ -324,7 +357,7 @@ const SidebarToolbar = React.memo(function SidebarToolbar() { - + @@ -334,6 +367,8 @@ const SidebarToolbar = React.memo(function SidebarToolbar() { type="button" aria-label="Help" className="text-muted-foreground" + onPointerDown={(event) => revealAdminHelpOptions(event.altKey)} + onClick={(event) => revealAdminHelpOptions(event.altKey)} > @@ -360,6 +395,15 @@ const SidebarToolbar = React.memo(function SidebarToolbar() { Docs + {showAdminHelpOptions ? ( + <> + + + + Restart Orca + + + ) : null} diff --git a/src/renderer/src/components/terminal/useTerminalShortcuts.ts b/src/renderer/src/components/terminal/useTerminalShortcuts.ts index 0d875a22819..02b4404d41d 100644 --- a/src/renderer/src/components/terminal/useTerminalShortcuts.ts +++ b/src/renderer/src/components/terminal/useTerminalShortcuts.ts @@ -1,7 +1,7 @@ import { useEffect, useEffectEvent } from 'react' import type { UnifiedTerminalItem } from './useTerminalTabs' import { getNextTabAcrossAllTypes, getNextTabWithinActiveType } from './tab-type-cycle' -import { isUpdaterQuitAndInstallInProgress } from '@/lib/updater-beforeunload' +import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload' type UseTerminalShortcutsParams = { activeWorktreeId: string | null @@ -105,7 +105,7 @@ export function useTerminalShortcuts({ }) const handleBeforeUnload = useEffectEvent((event: BeforeUnloadEvent) => { - if (isUpdaterQuitAndInstallInProgress()) { + if (isIntentionalAppRestartInProgress()) { return } if (!hasDirtyFiles) { diff --git a/src/renderer/src/lib/updater-beforeunload.test.ts b/src/renderer/src/lib/updater-beforeunload.test.ts index 34d7b58f1ff..a32364fcad7 100644 --- a/src/renderer/src/lib/updater-beforeunload.test.ts +++ b/src/renderer/src/lib/updater-beforeunload.test.ts @@ -1,9 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + isIntentionalAppRestartInProgress, isUpdaterQuitAndInstallInProgress, registerUpdaterBeforeUnloadBypass } from './updater-beforeunload' import { + ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_APP_RESTART_STARTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../../../shared/updater-renderer-events' @@ -30,9 +33,25 @@ describe('registerUpdaterBeforeUnloadBypass', () => { window.dispatchEvent(new Event(ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT)) expect(isUpdaterQuitAndInstallInProgress()).toBe(true) + expect(isIntentionalAppRestartInProgress()).toBe(true) window.dispatchEvent(new Event(ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT)) expect(isUpdaterQuitAndInstallInProgress()).toBe(false) + expect(isIntentionalAppRestartInProgress()).toBe(false) + + cleanup() + }) + + it('tracks app restart lifecycle events', () => { + const cleanup = registerUpdaterBeforeUnloadBypass() + expect(isIntentionalAppRestartInProgress()).toBe(false) + + window.dispatchEvent(new Event(ORCA_APP_RESTART_STARTED_EVENT)) + expect(isIntentionalAppRestartInProgress()).toBe(true) + expect(isUpdaterQuitAndInstallInProgress()).toBe(true) + + window.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + expect(isIntentionalAppRestartInProgress()).toBe(false) cleanup() }) diff --git a/src/renderer/src/lib/updater-beforeunload.ts b/src/renderer/src/lib/updater-beforeunload.ts index 073bb0770e5..ba26ae9fdc7 100644 --- a/src/renderer/src/lib/updater-beforeunload.ts +++ b/src/renderer/src/lib/updater-beforeunload.ts @@ -1,31 +1,41 @@ import { + ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_APP_RESTART_STARTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../../../shared/updater-renderer-events' -let updaterQuitAndInstallInProgress = false +let intentionalAppRestartInProgress = false export function isUpdaterQuitAndInstallInProgress(): boolean { - return updaterQuitAndInstallInProgress + return isIntentionalAppRestartInProgress() +} + +export function isIntentionalAppRestartInProgress(): boolean { + return intentionalAppRestartInProgress } export function registerUpdaterBeforeUnloadBypass(): () => void { const markInProgress = (): void => { - updaterQuitAndInstallInProgress = true + intentionalAppRestartInProgress = true } const clearInProgress = (): void => { - updaterQuitAndInstallInProgress = false + intentionalAppRestartInProgress = false } window.addEventListener(ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT, markInProgress) window.addEventListener(ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, clearInProgress) + window.addEventListener(ORCA_APP_RESTART_STARTED_EVENT, markInProgress) + window.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, clearInProgress) return () => { window.removeEventListener(ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT, markInProgress) window.removeEventListener(ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, clearInProgress) + window.removeEventListener(ORCA_APP_RESTART_STARTED_EVENT, markInProgress) + window.removeEventListener(ORCA_APP_RESTART_ABORTED_EVENT, clearInProgress) // Why: hot reloads can re-register this listener inside the same renderer. - // Reset the module flag on cleanup so a failed earlier install attempt + // Reset the module flag on cleanup so a failed earlier restart attempt // cannot silently suppress future unsaved-change prompts. - updaterQuitAndInstallInProgress = false + intentionalAppRestartInProgress = false } } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 5cd68cd33c4..d8e36f37890 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -298,6 +298,7 @@ function createWebPreloadApi(): Partial { }), getFeatureWallAssetBaseUrl: () => Promise.resolve('/'), relaunch: () => Promise.resolve(window.location.reload()), + restart: () => Promise.resolve(window.location.reload()), reload: () => Promise.resolve(window.location.reload()), getKeyboardInputSourceId: () => Promise.resolve(null), setUnreadDockBadgeCount: () => Promise.resolve(), diff --git a/src/shared/updater-renderer-events.ts b/src/shared/updater-renderer-events.ts index a67b08e1a2f..49097bad6ae 100644 --- a/src/shared/updater-renderer-events.ts +++ b/src/shared/updater-renderer-events.ts @@ -1,2 +1,4 @@ export const ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT = 'orca:updater-quit-and-install-started' export const ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT = 'orca:updater-quit-and-install-aborted' +export const ORCA_APP_RESTART_STARTED_EVENT = 'orca:app-restart-started' +export const ORCA_APP_RESTART_ABORTED_EVENT = 'orca:app-restart-aborted'