fix(updater): remove unverifiable session survival promise

This commit is contained in:
Neil
2026-08-23 13:12:19 -07:00
parent 64b42e4ad5
commit c61c4a2d46
14 changed files with 14 additions and 250 deletions
-29
View File
@@ -239,35 +239,6 @@ describe('pty:management IPC handlers', () => {
})
})
describe('sessionPersistence', () => {
async function readAvailability(): Promise<boolean> {
const { registerDaemonManagementHandlers } = await importFresh()
registerDaemonManagementHandlers()
const result = (await buildHandlerMap()['pty:management:sessionPersistence']({})) as {
available: boolean
}
return result.available
}
it('reports preservation while the daemon owns terminals', async () => {
getDaemonProviderMock.mockReturnValue(await makeRouter(makeAdapter(5, [])))
await expect(readAvailability()).resolves.toBe(true)
})
it('reports no preservation when terminals fell back to local PTYs', async () => {
getDaemonProviderMock.mockReturnValue(null)
await expect(readAvailability()).resolves.toBe(false)
})
it('reports no preservation while fresh spawns bypass the daemon', async () => {
getDaemonProviderMock.mockReturnValue(await makeDegradedProvider(makeAdapter(5, [])))
await expect(readAvailability()).resolves.toBe(false)
})
})
describe('killAll', () => {
// Why: the handler sleeps POLL_INTERVAL_MS between listSessions polls.
// Fake timers let the tests drive that loop deterministically — without
-12
View File
@@ -38,11 +38,6 @@ function isDaemonDegraded(): boolean {
)
}
// Why: no daemon provider means terminals run on fallback local PTYs that die with the app, so nothing survives a quit-and-install.
function isSessionPersistenceAvailable(): boolean {
return getDaemonProvider() !== null && !isDaemonDegraded()
}
async function collectSessions(adapters: DaemonPtyAdapter[]): Promise<DaemonSessionInfo[]> {
const results = await Promise.allSettled(
adapters.map(async (adapter) => {
@@ -62,13 +57,6 @@ export function registerDaemonManagementHandlers(): void {
ipcMain.removeHandler('pty:management:killOne')
ipcMain.removeHandler('pty:management:restart')
ipcMain.removeHandler('pty:management:macTccAttribution')
ipcMain.removeHandler('pty:management:sessionPersistence')
// Why: lets the update card stop promising sessions survive an install it has no mechanism to keep them through.
ipcMain.handle(
'pty:management:sessionPersistence',
async (): Promise<{ available: boolean }> => ({ available: isSessionPersistenceAvailable() })
)
// Why: lets Settings warn that macOS privacy grants no longer reach daemon terminals (STA-3491).
ipcMain.handle(
-2
View File
@@ -27,6 +27,4 @@ export type PtyManagementApi = {
killOne: (args: { sessionId: string }) => Promise<{ success: boolean }>
restart: () => Promise<{ success: boolean }>
macTccAttribution: () => Promise<{ health: PtyManagementMacTccAttributionHealth }>
// `false`: terminals run on local PTYs that die with the app, so no session survives quit/update. `null`: unverifiable here.
sessionPersistence: () => Promise<{ available: boolean | null }>
}
+1 -2
View File
@@ -1347,8 +1347,7 @@ const api = {
killAll: () => ipcRenderer.invoke('pty:management:killAll'),
killOne: (args: { sessionId: string }) => ipcRenderer.invoke('pty:management:killOne', args),
restart: () => ipcRenderer.invoke('pty:management:restart'),
macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution'),
sessionPersistence: () => ipcRenderer.invoke('pty:management:sessionPersistence')
macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution')
}
},
@@ -23,7 +23,7 @@ const PACKAGE_RECOVERY: LinuxPackageInstallRecovery = {
version: '1.4.200'
}
function renderAfterAvailableStatus(): RenderResult {
function renderAfterAvailableStatus(updateReassuranceSeen = true): RenderResult {
useAppStore.setState({
updateStatus: {
state: 'available',
@@ -33,7 +33,7 @@ function renderAfterAvailableStatus(): RenderResult {
updateChangelog: null,
dismissedUpdateVersion: null,
updateCardCollapsed: false,
updateReassuranceSeen: true
updateReassuranceSeen
})
return render(<UpdateCard />)
}
@@ -90,6 +90,16 @@ afterEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true)
})
describe('UpdateCard available copy', () => {
it('makes no promise about terminal sessions', () => {
renderAfterAvailableStatus(false)
expect(screen.getByText('Update Available')).toBeTruthy()
expect(screen.queryByText(/terminal sessions/i)).toBeNull()
expect(screen.queryByText(/sessions won't be interrupted/i)).toBeNull()
})
})
describe('UpdateCard Windows signature failures', () => {
it('does not offer the rejected version as a manual publisher-check bypass', () => {
const message =
@@ -1,101 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '../store'
import { UpdateCard } from './UpdateCard'
const sessionPersistence = vi.fn()
function mockApi(): void {
Object.defineProperty(window, 'api', {
configurable: true,
value: {
app: { relaunch: vi.fn() },
settings: { set: vi.fn().mockResolvedValue(undefined) },
shell: { openUrl: vi.fn() },
ui: { set: vi.fn().mockResolvedValue(undefined), writeClipboardText: vi.fn() },
pty: { management: { sessionPersistence } },
updater: {
check: vi.fn(),
dismissNudge: vi.fn(),
dismissAvailableUpdate: vi.fn().mockResolvedValue(undefined),
download: vi.fn(),
getLinuxPackageInstallInstructions: vi.fn().mockResolvedValue({ ok: false }),
showLinuxPackage: vi.fn(),
quitAndInstall: vi.fn().mockResolvedValue(undefined)
}
}
})
}
function renderAvailableUpdate(): void {
useAppStore.setState({
updateStatus: { state: 'available', version: '1.4.200', changelog: null },
updateChangelog: null,
dismissedUpdateVersion: null,
updateCardCollapsed: false,
updateReassuranceSeen: false
})
render(<UpdateCard />)
}
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true)
sessionPersistence.mockReset()
mockApi()
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
})
})
afterEach(() => {
cleanup()
useAppStore.setState(useAppStore.getInitialState(), true)
})
describe('UpdateCard session-preservation promise', () => {
it('drops the no-interruption promise when terminals run on fallback local PTYs', async () => {
sessionPersistence.mockResolvedValue({ available: false })
renderAvailableUpdate()
await waitFor(() => expect(screen.getAllByText(/may not be preserved/).length).toBe(2))
expect(screen.queryByText(/won't be interrupted/)).toBeNull()
expect(
screen.getByText('Terminal sessions may not be preserved during this update.')
).toBeTruthy()
expect(screen.getByText('Sessions may not be preserved.')).toBeTruthy()
})
it('keeps the promise when the daemon owns the sessions', async () => {
sessionPersistence.mockResolvedValue({ available: true })
renderAvailableUpdate()
await waitFor(() => expect(screen.getByText("Sessions won't be interrupted.")).toBeTruthy())
expect(
screen.getByText("Your terminal sessions won't be interrupted during the update.")
).toBeTruthy()
})
it('claims nothing while persistence is still unknown', async () => {
sessionPersistence.mockReturnValue(new Promise(() => {}))
renderAvailableUpdate()
await waitFor(() => expect(screen.getByText('Update Available')).toBeTruthy())
expect(screen.queryByText(/interrupted/)).toBeNull()
expect(screen.queryByText(/preserved/)).toBeNull()
})
it('claims nothing when the persistence probe fails', async () => {
sessionPersistence.mockRejectedValue(new Error('ipc unavailable'))
renderAvailableUpdate()
await waitFor(() => expect(screen.getByText('Update Available')).toBeTruthy())
expect(screen.queryByText(/interrupted/)).toBeNull()
expect(screen.queryByText(/preserved/)).toBeNull()
})
})
@@ -1,7 +1,6 @@
/* eslint-disable max-lines -- Why: keeps the updater state machine and its presentation variants in one file. */
import { useCallback, useEffect, useRef, useState } from 'react'
import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'
import { useTerminalSessionPersistence } from '@/hooks/useTerminalSessionPersistence'
import { useAppStore } from '../store'
import { Card } from './ui/card'
import { Button } from './ui/button'
@@ -88,8 +87,6 @@ export function UpdateCard() {
const dismissUpdate = useAppStore((s) => s.dismissUpdate)
const collapsed = useAppStore((s) => s.updateCardCollapsed)
const setCollapsed = useAppStore((s) => s.setUpdateCardCollapsed)
const reassuranceSeen = useAppStore((s) => s.updateReassuranceSeen)
const markReassuranceSeen = useAppStore((s) => s.markUpdateReassuranceSeen)
const hasStartedDownload = useRef(false)
const dismissAnimationTimerRef = useRef<number | null>(null)
const collapseAnimationTimerRef = useRef<number | null>(null)
@@ -170,11 +167,6 @@ export function UpdateCard() {
// ── Prefers-reduced-motion ──────────────────────────────────────────
const prefersReducedMotion = usePrefersReducedMotion()
// Why: the card must not promise sessions survive the install unless the daemon that preserves them is serving this app.
const sessionsPreserved = useTerminalSessionPersistence(
status.state === 'available' || status.state === 'downloading'
)
const clearAnimationTimers = useCallback(() => {
if (dismissAnimationTimerRef.current !== null) {
window.clearTimeout(dismissAnimationTimerRef.current)
@@ -250,10 +242,6 @@ export function UpdateCard() {
const handleUpdate = () => {
hasStartedDownload.current = true
// Why: clicking Update implies the user isn't worried about interruption, so retire the reassurance tip.
if (!reassuranceSeen) {
markReassuranceSeen()
}
void window.api.updater.download()
}
@@ -603,53 +591,18 @@ export function UpdateCard() {
<SimpleCardContent
version={status.version}
releaseUrl={releaseUrl}
sessionsPreserved={sessionsPreserved}
onUpdate={handleUpdate}
onClose={handleDismissWithAnimation}
/>
)
})()
// One-time tip about what the update does to running terminals; persisted once seen, and silent while persistence is unknown.
const showReassurance =
!reassuranceSeen &&
sessionsPreserved !== null &&
(status.state === 'available' || status.state === 'downloading')
return (
<div
ref={cardRootRef}
className="fixed bottom-10 right-4 z-40 w-[360px] max-w-[calc(100vw-32px)] flex flex-col gap-2
max-[480px]:left-4 max-[480px]:right-4 max-[480px]:w-auto"
>
{showReassurance && (
<Card className={`py-0 gap-0 ${animationClass}`}>
<div className="flex items-center gap-3 p-3">
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground">
{sessionsPreserved
? translate(
'auto.components.UpdateCard.b1d867f4fb',
"Your terminal sessions won't be interrupted during the update."
)
: translate(
'auto.components.UpdateCard.6bcb39fd76',
'Terminal sessions may not be preserved during this update.'
)}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={markReassuranceSeen}
aria-label={translate('auto.components.UpdateCard.7274ef6e59', 'Dismiss tip')}
>
<X className="size-3.5" />
</Button>
</div>
</Card>
)}
<Card
role="complementary"
aria-label={ariaLabel}
@@ -765,14 +718,11 @@ function RichCardContent({
function SimpleCardContent({
version,
releaseUrl,
sessionsPreserved,
onUpdate,
onClose
}: {
version: string
releaseUrl?: string
/** null = daemon persistence unverified; say nothing rather than guess. */
sessionsPreserved: boolean | null
onUpdate: () => void
onClose: () => void
}) {
@@ -799,14 +749,6 @@ function SimpleCardContent({
})}
</p>
{sessionsPreserved !== null && (
<p className="text-xs leading-relaxed text-muted-foreground">
{sessionsPreserved
? translate('auto.components.UpdateCard.fdd4a364fa', "Sessions won't be interrupted.")
: translate('auto.components.UpdateCard.6930f48207', 'Sessions may not be preserved.')}
</p>
)}
{releaseUrl && (
<button
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground self-start"
@@ -1,31 +0,0 @@
import { useEffect, useState } from 'react'
/**
* Whether the daemon that carries terminal sessions across a quit is serving this app.
* `null` = not answered yet or the probe failed; the caller must claim nothing either way.
*/
export function useTerminalSessionPersistence(enabled: boolean): boolean | null {
const [available, setAvailable] = useState<boolean | null>(null)
useEffect(() => {
if (!enabled) {
return
}
let cancelled = false
void (async () => {
try {
const result = await window.api.pty.management.sessionPersistence()
if (!cancelled && typeof result.available === 'boolean') {
setAvailable(result.available)
}
} catch {
// Why: an unanswered probe stays unknown rather than inventing either verdict.
}
})()
return () => {
cancelled = true
}
}, [enabled])
return available
}
-2
View File
@@ -2121,8 +2121,6 @@
"ec8fe71cfc": "Update",
"44324ef542": "Release notes",
"fdd4a364fa": "Sessions won't be interrupted.",
"6930f48207": "Sessions may not be preserved.",
"6bcb39fd76": "Terminal sessions may not be preserved during this update.",
"05ad78a6d1": "Orca v{{value0}} is ready.",
"318d3b4bc7": "Dismiss update",
"9abc59f814": "Update Available",
-2
View File
@@ -1958,8 +1958,6 @@
"ec8fe71cfc": "Actualizar",
"44324ef542": "Notas de la versión",
"fdd4a364fa": "Las sesiones no se interrumpirán.",
"6930f48207": "Es posible que las sesiones no se conserven.",
"6bcb39fd76": "Es posible que las sesiones de terminales no se conserven durante esta actualización.",
"05ad78a6d1": "Orca v{{value0}} está lista.",
"318d3b4bc7": "Descartar actualización",
"9abc59f814": "Actualización disponible",
-2
View File
@@ -1958,8 +1958,6 @@
"ec8fe71cfc": "更新",
"44324ef542": "リリースノート",
"fdd4a364fa": "セッションは中断されません。",
"6930f48207": "セッションが保持されない可能性があります。",
"6bcb39fd76": "このアップデート中にターミナルセッションが保持されない可能性があります。",
"05ad78a6d1": "Orca v{{value0}} の準備ができています。",
"318d3b4bc7": "更新を閉じる",
"9abc59f814": "利用可能なアップデート",
-2
View File
@@ -1960,8 +1960,6 @@
"ec8fe71cfc": "업데이트",
"44324ef542": "릴리스 노트",
"fdd4a364fa": "세션은 중단되지 않습니다.",
"6930f48207": "세션이 유지되지 않을 수 있습니다.",
"6bcb39fd76": "이 업데이트 중에 터미널 세션이 유지되지 않을 수 있습니다.",
"05ad78a6d1": "Orca v{{value0}}이(가) 준비되었습니다.",
"318d3b4bc7": "업데이트 닫기",
"9abc59f814": "업데이트 가능",
-2
View File
@@ -1958,8 +1958,6 @@
"ec8fe71cfc": "更新",
"44324ef542": "发行说明",
"fdd4a364fa": "会话不会被中断。",
"6930f48207": "会话可能不会被保留。",
"6bcb39fd76": "本次更新期间终端会话可能不会被保留。",
"05ad78a6d1": "Orca v{{value0}} 已准备就绪。",
"318d3b4bc7": "关闭更新",
"9abc59f814": "可用更新",
+1 -3
View File
@@ -3415,9 +3415,7 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
killOne: () => Promise.resolve({ success: false }),
restart: () => Promise.resolve({ success: false }),
// Why: web clients can't inspect the host daemon's pid record; 'unknown' keeps the banner hidden.
macTccAttribution: () => Promise.resolve({ health: 'unknown' as const }),
// Why: the daemon serving these terminals runs on the paired host, so this client cannot verify preservation.
sessionPersistence: () => Promise.resolve({ available: null })
macTccAttribution: () => Promise.resolve({ health: 'unknown' as const })
}
}
}