Add hidden admin restart action (#2970)

This commit is contained in:
Neil
2026-05-27 22:12:10 -07:00
committed by GitHub
parent ddd08be1e6
commit 286a92ff23
12 changed files with 212 additions and 63 deletions
+22 -1
View File
@@ -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<string, (_event: unknown, args?: unknown) => 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()
})
})
+11
View File
@@ -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)
})
+3
View File
@@ -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<void>
/** Restarts Orca through the normal quit pipeline so daemon-backed terminal
* sessions survive and can reattach after the new process starts. */
restart: () => Promise<void>
/** Reloads the current app renderer through main so expected renderer
* teardown can be classified before Electron emits process-gone events. */
reload: () => Promise<void>
+82 -47
View File
@@ -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<void> {
return new Promise<void>((resolve, reject) => {
let claimed = false
window.dispatchEvent(
new CustomEvent<EditorSaveDirtyFilesDetail>(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<void> {
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<string> =>
ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'),
relaunch: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
restart: async (): Promise<void> => {
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<void> => 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<void> => ipcRenderer.invoke('updater:download'),
dismissNudge: (): Promise<void> => ipcRenderer.invoke('updater:dismissNudge'),
quitAndInstall: async (): Promise<void> => {
// 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<void>((resolve, reject) => {
let claimed = false
window.dispatchEvent(
new CustomEvent<EditorSaveDirtyFilesDetail>(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) {
+5 -6
View File
@@ -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
}
@@ -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
}
@@ -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('Couldnt restart Orca.', {
description: error instanceof Error ? error.message : undefined
})
})
}
return (
<div className="mt-auto shrink-0">
<div className="flex items-center justify-between border-t border-sidebar-border px-2 py-1.5">
@@ -324,7 +357,7 @@ const SidebarToolbar = React.memo(function SidebarToolbar() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenu modal={false} open={helpMenuOpen} onOpenChange={handleHelpMenuOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
@@ -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)}
>
<CircleHelp className="size-3.5" />
</Button>
@@ -360,6 +395,15 @@ const SidebarToolbar = React.memo(function SidebarToolbar() {
<ExternalLink className="size-3.5" />
Docs
</DropdownMenuItem>
{showAdminHelpOptions ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleRestartOrca} disabled={isRestartingOrca}>
<RotateCw className="size-3.5" />
Restart Orca
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
@@ -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) {
@@ -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()
})
+16 -6
View File
@@ -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
}
}
+1
View File
@@ -298,6 +298,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
}),
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(),
+2
View File
@@ -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'