diff --git a/apps/desktop/src/state/remoteAccess.ts b/apps/desktop/src/state/remoteAccess.ts new file mode 100644 index 000000000..b570ad730 --- /dev/null +++ b/apps/desktop/src/state/remoteAccess.ts @@ -0,0 +1,216 @@ +// Remote access: this computer's remote server, the devices paired with it, the QR challenge a +// pairing shows and the connect requests waiting on an answer. Split out of state/store.ts; the +// state it writes (`remote`, the challenge, the requests) still lives in the app store. + +import type { JsonObject } from "../acp/jsonrpc"; +import { parseRemoteStatus, RemoteMethod, type RemoteStatus } from "../acp/remote"; +import { challengeIsValid, makeChallenge, newPairedDevice } from "./remotePairing"; +import { errorText, request, toast, useApp } from "./store"; + +/** Ignore machine-status reads that started before a newer answer changed remote state. */ +let remoteStatusRevision = 0; +let remoteStatusReadId = 0; +let latestRemoteStatusReadId = 0; + +/** Test hook: every read starts again on a clean clock. */ +export function resetRemoteStatusReads(): void { + remoteStatusRevision += 1; + remoteStatusReadId = 0; + latestRemoteStatusReadId = 0; +} + +// Every read of the machine's remote state runs on one clock. A change made here (pair, answer, +// revoke, the switch) or a dismissed QR code bumps the revision, so a read that started before it +// cannot put back what it changed; among the reads that started after, only the newest lands. All +// of them, and every mutation's answer, land through `applyRemoteStatus`, which keeps or clears the +// QR challenge and notices a device that just paired. +function invalidateRemoteStatusReads() { + remoteStatusRevision += 1; + latestRemoteStatusReadId = remoteStatusReadId; +} + +function commitRemoteStatus(status: RemoteStatus) { + invalidateRemoteStatusReads(); + applyRemoteStatus(status); +} + +export function dismissRemoteChallenge() { + invalidateRemoteStatusReads(); + useApp.setState({ remoteChallenge: null }); +} + +export function applyRemoteStatus(status: RemoteStatus, now = Date.now()) { + useApp.setState((s) => { + const challenge = s.remoteChallenge; + const fresh = challenge && newPairedDevice(challenge.baselineIds, status.devices); + return { + remote: status, + remoteError: null, + remotePaired: fresh ? { name: fresh.name } : status.enabled ? s.remotePaired : null, + remoteChallenge: challenge && !fresh && challengeIsValid(challenge, status, now) ? challenge : null, + }; + }); +} + +/** + * Re-read the machine's remote access state. `sessionId` reads one thread's + * share instead, for its header indicator. Never throws: Settings shows the + * engine's refusal next to the switch. + */ +export async function refreshRemote(sessionId?: string): Promise { + const params: JsonObject = sessionId ? { sessionId } : {}; + const revision = remoteStatusRevision; + const readId = sessionId ? 0 : ++remoteStatusReadId; + try { + const status = parseRemoteStatus(await request(RemoteMethod.status, params, 10_000)); + if (sessionId) + useApp.setState((s) => ({ + remoteByThread: { ...s.remoteByThread, [sessionId]: status }, + remoteError: null, + })); + else if (revision === remoteStatusRevision && readId > latestRemoteStatusReadId) { + latestRemoteStatusReadId = readId; + applyRemoteStatus(status); + } + return sessionId || (revision === remoteStatusRevision && readId === latestRemoteStatusReadId) + ? status + : useApp.getState().remote; + } catch (error) { + // The engine answered nothing (a restart, a dropped request): say so and + // keep what was last true, rather than showing remote access as off. + if (sessionId || (revision === remoteStatusRevision && readId > latestRemoteStatusReadId)) { + if (!sessionId) latestRemoteStatusReadId = readId; + useApp.setState({ remoteError: errorText(error) }); + } + return null; + } +} + +/** + * Switch remote access on or off for every thread on this Mac. On starts the + * gateway and shares every open thread; off stops the gateway, so no device + * sees anything until it is switched on again. Pairings are kept. + */ +export async function setRemoteAccess(on: boolean): Promise { + invalidateRemoteStatusReads(); + if (!on) useApp.setState({ remoteChallenge: null, remotePaired: null }); + if (on) { + useApp.setState({ + remote: { + ...(useApp.getState().remote ?? {}), + available: true, + enabled: true, + shared: false, + pairedDevices: 0, + devices: [], + } as RemoteStatus, + }); + } + try { + const status = parseRemoteStatus(await request(on ? RemoteMethod.start : RemoteMethod.stop, {}, 20_000)); + commitRemoteStatus(status); + if (status.error) toast(status.error, "error"); + else { + toast( + on + ? "Remote access is on. Every open thread is shared with paired devices." + : "Remote access is off. Paired devices stay paired.", + "success", + ); + } + // Every open thread's share changed with it. + for (const threadId of Object.keys(useApp.getState().remoteByThread)) void refreshRemote(threadId); + } catch (error) { + toast(errorText(error), "error"); + void refreshRemote(); + } +} + +/** + * Ask the gateway for a pairing challenge. Remote access is switched on first + * if it was off, as `/remote` does in a terminal. + */ +export async function pairRemoteDevice(): Promise { + const baselineIds = (useApp.getState().remote?.devices ?? []).map((device) => device.id); + invalidateRemoteStatusReads(); + const revision = remoteStatusRevision; + useApp.setState({ remoteChallenge: null, remotePaired: null }); + try { + const status = parseRemoteStatus(await request(RemoteMethod.pair, {}, 20_000)); + if (revision === remoteStatusRevision) { + const fresh = newPairedDevice(baselineIds, status.devices); + const instanceChanged = + status.offer?.instanceId && + status.gateway?.instanceId && + status.offer.instanceId !== status.gateway.instanceId; + useApp.setState({ + remotePaired: fresh ? { name: fresh.name } : null, + remoteChallenge: + status.offer && !fresh && !instanceChanged + ? makeChallenge(status.offer, Date.now(), baselineIds) + : null, + }); + commitRemoteStatus(status); + } else { + // Dismissed, switched off or answered meanwhile: the code is not shown, but the machine's + // state did change, so read it again. + void refreshRemote(); + } + if (status.pairingError) toast(status.pairingError, "error"); + return status; + } catch (error) { + toast(errorText(error), "error"); + return null; + } +} + +/** + * Accept or deny a phone that asked to connect. Accepting shows the code the phone needs + * (`connectCode`); the request's own prompt goes with the next status either way. + */ +export async function answerConnectRequest(id: string, accept: boolean): Promise { + invalidateRemoteStatusReads(); + try { + const status = parseRemoteStatus(await request(RemoteMethod.answer, { id, accept }, 15_000)); + commitRemoteStatus(status); + if (status.connectError) { + toast(status.connectError, "error"); + return false; + } + const answer = status.connectAnswer; + if (accept && answer?.code) { + useApp.setState({ + connectCode: { + requestId: answer.id, + deviceName: answer.deviceName, + code: answer.code, + instanceId: answer.instanceId ?? status.gateway?.instanceId, + expiresAt: Date.now() + (answer.expiresInSecs ?? 120) * 1000, + lockedForSecs: answer.lockedForSecs, + }, + }); + } + return true; + } catch (error) { + toast(errorText(error), "error"); + return false; + } +} + +/** Take the connection code off the screen. */ +export function closeConnectCode() { + useApp.setState({ connectCode: null }); +} + +/** Forget a paired device: its token stops working and its connections close. */ +export async function revokeRemoteDevice(id: string): Promise { + invalidateRemoteStatusReads(); + try { + const status = parseRemoteStatus(await request(RemoteMethod.revoke, { deviceId: id }, 15_000)); + commitRemoteStatus(status); + if (status.revokeError) toast(status.revokeError, "error"); + else if (status.revoked) toast(`${status.revoked.name} can no longer connect.`, "success"); + } catch (error) { + toast(errorText(error), "error"); + } +} diff --git a/apps/desktop/src/state/remotePairing.test.ts b/apps/desktop/src/state/remotePairing.test.ts index 7045882dd..47ea0d184 100644 --- a/apps/desktop/src/state/remotePairing.test.ts +++ b/apps/desktop/src/state/remotePairing.test.ts @@ -3,8 +3,9 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { parseRemoteStatus } from "../acp/remote"; import { PairingCard } from "../ui/RemoteAccessPage"; +import { applyRemoteStatus } from "./remoteAccess"; import { remainingPairingSeconds } from "./remotePairing"; -import { applyRemoteStatus, resetForTests, useApp } from "./store"; +import { resetForTests, useApp } from "./store"; const offer = parseRemoteStatus({ offer: { qrPayload: "qr", code: "12345678", address: "host", expiresInSecs: 120, challenge: 7 }, diff --git a/apps/desktop/src/state/store.test.ts b/apps/desktop/src/state/store.test.ts index 66ad19a80..cb9a15e57 100644 --- a/apps/desktop/src/state/store.test.ts +++ b/apps/desktop/src/state/store.test.ts @@ -32,12 +32,12 @@ import { import { pendingRequestsOn } from "./pending"; import { projectEntry, updateProjectEntry } from "./projectBook"; import { QUEUE_REASON } from "./queue"; +import { answerConnectRequest, pairRemoteDevice, refreshRemote } from "./remoteAccess"; import { type AppState, addHarnessAccount, advertisedCommands, agentHistory, - answerConnectRequest, answerPermission, answerQuestionOptions, backgroundPlansAsk, @@ -78,7 +78,6 @@ import { PLAN_CACHE_MAX_AGE_MS, PLAN_CACHE_POLL_MS, PLAN_STALE_MARGIN_MS, - pairRemoteDevice, parsePanels, peekThread, permissionModeOf, @@ -99,7 +98,6 @@ import { refreshAgents, refreshHarnesses, refreshReadPlanLogins, - refreshRemote, refreshSessions, refreshSessionsOnFocus, removeQueued, diff --git a/apps/desktop/src/state/store.ts b/apps/desktop/src/state/store.ts index 1c735cba1..587ed7152 100644 --- a/apps/desktop/src/state/store.ts +++ b/apps/desktop/src/state/store.ts @@ -12,7 +12,7 @@ import { RpcError, str, } from "../acp/jsonrpc"; -import { parseRemoteStatus, RemoteMethod, type RemoteStatus } from "../acp/remote"; +import type { RemoteStatus } from "../acp/remote"; import { agentActive, harnessPlanKey, @@ -279,7 +279,8 @@ import { type TurnState, } from "./queue"; import { parseRecapHours, RECAP_HOURS_DEFAULT } from "./recap"; -import { challengeIsValid, makeChallenge, newPairedDevice, type PairingChallenge } from "./remotePairing"; +import { resetRemoteStatusReads } from "./remoteAccess"; +import type { PairingChallenge } from "./remotePairing"; import { markStopped, pruneResumed, @@ -1719,10 +1720,6 @@ function restoreRow(change: OutboxChange, row: SessionSummary | undefined): void store(STORAGE.pinned, JSON.stringify(next)); } -/** Ignore machine-status reads that started before a newer answer changed remote state. */ -let remoteStatusRevision = 0; -let remoteStatusReadId = 0; -let latestRemoteStatusReadId = 0; /** Sessions whose `session/load` replay is arriving. */ const loading = new Set(); /** The shared `session/load` for each thread; callers must not prompt before its replay finishes. */ @@ -8316,204 +8313,6 @@ export async function request( return engine().request(method, params, timeoutMs); } -// ---------------------------------------------------------------- remote access - -// Every read of the machine's remote state runs on one clock. A change made here (pair, answer, -// revoke, the switch) or a dismissed QR code bumps the revision, so a read that started before it -// cannot put back what it changed; among the reads that started after, only the newest lands. All -// of them, and every mutation's answer, land through `applyRemoteStatus`, which keeps or clears the -// QR challenge and notices a device that just paired. -function invalidateRemoteStatusReads() { - remoteStatusRevision += 1; - latestRemoteStatusReadId = remoteStatusReadId; -} - -function commitRemoteStatus(status: RemoteStatus) { - invalidateRemoteStatusReads(); - applyRemoteStatus(status); -} - -export function dismissRemoteChallenge() { - invalidateRemoteStatusReads(); - set({ remoteChallenge: null }); -} - -export function applyRemoteStatus(status: RemoteStatus, now = Date.now()) { - set((s) => { - const challenge = s.remoteChallenge; - const fresh = challenge && newPairedDevice(challenge.baselineIds, status.devices); - return { - remote: status, - remoteError: null, - remotePaired: fresh ? { name: fresh.name } : status.enabled ? s.remotePaired : null, - remoteChallenge: challenge && !fresh && challengeIsValid(challenge, status, now) ? challenge : null, - }; - }); -} - -/** - * Re-read the machine's remote access state. `sessionId` reads one thread's - * share instead, for its header indicator. Never throws: Settings shows the - * engine's refusal next to the switch. - */ -export async function refreshRemote(sessionId?: string): Promise { - const params: JsonObject = sessionId ? { sessionId } : {}; - const revision = remoteStatusRevision; - const readId = sessionId ? 0 : ++remoteStatusReadId; - try { - const status = parseRemoteStatus(await request(RemoteMethod.status, params, 10_000)); - if (sessionId) - set((s) => ({ - remoteByThread: { ...s.remoteByThread, [sessionId]: status }, - remoteError: null, - })); - else if (revision === remoteStatusRevision && readId > latestRemoteStatusReadId) { - latestRemoteStatusReadId = readId; - applyRemoteStatus(status); - } - return sessionId || (revision === remoteStatusRevision && readId === latestRemoteStatusReadId) - ? status - : get().remote; - } catch (error) { - // The engine answered nothing (a restart, a dropped request): say so and - // keep what was last true, rather than showing remote access as off. - if (sessionId || (revision === remoteStatusRevision && readId > latestRemoteStatusReadId)) { - if (!sessionId) latestRemoteStatusReadId = readId; - set({ remoteError: errorText(error) }); - } - return null; - } -} - -/** - * Switch remote access on or off for every thread on this Mac. On starts the - * gateway and shares every open thread; off stops the gateway, so no device - * sees anything until it is switched on again. Pairings are kept. - */ -export async function setRemoteAccess(on: boolean): Promise { - invalidateRemoteStatusReads(); - if (!on) set({ remoteChallenge: null, remotePaired: null }); - if (on) { - set({ - remote: { - ...(get().remote ?? {}), - available: true, - enabled: true, - shared: false, - pairedDevices: 0, - devices: [], - } as RemoteStatus, - }); - } - try { - const status = parseRemoteStatus(await request(on ? RemoteMethod.start : RemoteMethod.stop, {}, 20_000)); - commitRemoteStatus(status); - if (status.error) toast(status.error, "error"); - else { - toast( - on - ? "Remote access is on. Every open thread is shared with paired devices." - : "Remote access is off. Paired devices stay paired.", - "success", - ); - } - // Every open thread's share changed with it. - for (const threadId of Object.keys(get().remoteByThread)) void refreshRemote(threadId); - } catch (error) { - toast(errorText(error), "error"); - void refreshRemote(); - } -} - -/** - * Ask the gateway for a pairing challenge. Remote access is switched on first - * if it was off, as `/remote` does in a terminal. - */ -export async function pairRemoteDevice(): Promise { - const baselineIds = (get().remote?.devices ?? []).map((device) => device.id); - invalidateRemoteStatusReads(); - const revision = remoteStatusRevision; - set({ remoteChallenge: null, remotePaired: null }); - try { - const status = parseRemoteStatus(await request(RemoteMethod.pair, {}, 20_000)); - if (revision === remoteStatusRevision) { - const fresh = newPairedDevice(baselineIds, status.devices); - const instanceChanged = - status.offer?.instanceId && - status.gateway?.instanceId && - status.offer.instanceId !== status.gateway.instanceId; - set({ - remotePaired: fresh ? { name: fresh.name } : null, - remoteChallenge: - status.offer && !fresh && !instanceChanged - ? makeChallenge(status.offer, Date.now(), baselineIds) - : null, - }); - commitRemoteStatus(status); - } else { - // Dismissed, switched off or answered meanwhile: the code is not shown, but the machine's - // state did change, so read it again. - void refreshRemote(); - } - if (status.pairingError) toast(status.pairingError, "error"); - return status; - } catch (error) { - toast(errorText(error), "error"); - return null; - } -} - -/** - * Accept or deny a phone that asked to connect. Accepting shows the code the phone needs - * (`connectCode`); the request's own prompt goes with the next status either way. - */ -export async function answerConnectRequest(id: string, accept: boolean): Promise { - invalidateRemoteStatusReads(); - try { - const status = parseRemoteStatus(await request(RemoteMethod.answer, { id, accept }, 15_000)); - commitRemoteStatus(status); - if (status.connectError) { - toast(status.connectError, "error"); - return false; - } - const answer = status.connectAnswer; - if (accept && answer?.code) { - set({ - connectCode: { - requestId: answer.id, - deviceName: answer.deviceName, - code: answer.code, - instanceId: answer.instanceId ?? status.gateway?.instanceId, - expiresAt: Date.now() + (answer.expiresInSecs ?? 120) * 1000, - lockedForSecs: answer.lockedForSecs, - }, - }); - } - return true; - } catch (error) { - toast(errorText(error), "error"); - return false; - } -} - -/** Take the connection code off the screen. */ -export function closeConnectCode() { - set({ connectCode: null }); -} - -/** Forget a paired device: its token stops working and its connections close. */ -export async function revokeRemoteDevice(id: string): Promise { - invalidateRemoteStatusReads(); - try { - const status = parseRemoteStatus(await request(RemoteMethod.revoke, { deviceId: id }, 15_000)); - commitRemoteStatus(status); - if (status.revokeError) toast(status.revokeError, "error"); - else if (status.revoked) toast(`${status.revoked.name} can no longer connect.`, "success"); - } catch (error) { - toast(errorText(error), "error"); - } -} - /** Test hook: reset to a clean slate. */ export function resetForTests() { // The wake tests follow what happens when a delegate settles, not the 60 s batch window; the @@ -8524,9 +8323,7 @@ export function resetForTests() { // not answer the next one's `openThread` (machinesThreads.test.ts › findings 1). resetMachinesForTests(); lastFocusListAt = 0; - remoteStatusRevision += 1; - remoteStatusReadId = 0; - latestRemoteStatusReadId = 0; + resetRemoteStatusReads(); clearTimeout(slowTimer); if (waitTimer) clearInterval(waitTimer); waitTimer = null; diff --git a/apps/desktop/src/ui/ConnectRequests.tsx b/apps/desktop/src/ui/ConnectRequests.tsx index a5ec2c8c7..99cdba380 100644 --- a/apps/desktop/src/ui/ConnectRequests.tsx +++ b/apps/desktop/src/ui/ConnectRequests.tsx @@ -19,7 +19,8 @@ import { promptedRequests, settleCode, } from "../state/connectRequests"; -import { answerConnectRequest, closeConnectCode, refreshRemote, toast, useApp } from "../state/store"; +import { answerConnectRequest, closeConnectCode, refreshRemote } from "../state/remoteAccess"; +import { toast, useApp } from "../state/store"; import { copyText, Pill, Spinner } from "./primitives"; /** At most this many requests are asked about at once; the gateway holds four. */ diff --git a/apps/desktop/src/ui/MachineSettingsPage.tsx b/apps/desktop/src/ui/MachineSettingsPage.tsx index cc6fb7e44..36d898c60 100644 --- a/apps/desktop/src/ui/MachineSettingsPage.tsx +++ b/apps/desktop/src/ui/MachineSettingsPage.tsx @@ -34,6 +34,7 @@ import { setMachinePaused, useMachines, } from "../state/machines"; +import { revokeRemoteDevice } from "../state/remoteAccess"; import { SERVER_SETTINGS } from "../state/settingsRegistry"; import { checkMachineHarnesses, @@ -44,7 +45,7 @@ import { useServerValues, useSettingsScope, } from "../state/settingsScope"; -import { openSettings, revokeRemoteDevice, useApp } from "../state/store"; +import { openSettings, useApp } from "../state/store"; import { LOCAL_HOST } from "../state/threadKeys"; import { DeviceGlyphIcon } from "./MachineGlyph"; import { MachineIdentityEditor } from "./MachineProfile"; diff --git a/apps/desktop/src/ui/MachinesPage.tsx b/apps/desktop/src/ui/MachinesPage.tsx index b980017f7..adeaf6f05 100644 --- a/apps/desktop/src/ui/MachinesPage.tsx +++ b/apps/desktop/src/ui/MachinesPage.tsx @@ -57,7 +57,8 @@ import { useMachines, versionSkew, } from "../state/machines"; -import { openMachineSettings, refreshRemote, useApp } from "../state/store"; +import { refreshRemote } from "../state/remoteAccess"; +import { openMachineSettings, useApp } from "../state/store"; import { AddEnvironmentDialog } from "./AddEnvironmentDialog"; import { connectionDirection, peerInbound } from "./connectionDirection"; import { diff --git a/apps/desktop/src/ui/RemoteAccessPage.tsx b/apps/desktop/src/ui/RemoteAccessPage.tsx index b0bd049b2..4c4aee7fa 100644 --- a/apps/desktop/src/ui/RemoteAccessPage.tsx +++ b/apps/desktop/src/ui/RemoteAccessPage.tsx @@ -33,17 +33,14 @@ import { useMachines, } from "../state/machines"; import { expiredCodeCopy, refusalAgo, refusalCopy } from "../state/refusals"; -import { activeChallenge, remainingPairingSeconds } from "../state/remotePairing"; import { - askConfirm, dismissRemoteChallenge, - errorText, pairRemoteDevice, refreshRemote, setRemoteAccess, - toast, - useApp, -} from "../state/store"; +} from "../state/remoteAccess"; +import { activeChallenge, remainingPairingSeconds } from "../state/remotePairing"; +import { askConfirm, errorText, toast, useApp } from "../state/store"; import { connectionDirection, peerOutbound } from "./connectionDirection"; import { copyText, cx, Spinner, Switch } from "./primitives"; import { ConnectRequestsGroup, RevokeDeviceAction } from "./RemoteConnect"; diff --git a/apps/desktop/src/ui/RemoteConnect.tsx b/apps/desktop/src/ui/RemoteConnect.tsx index ae5dadf33..286c81196 100644 --- a/apps/desktop/src/ui/RemoteConnect.tsx +++ b/apps/desktop/src/ui/RemoteConnect.tsx @@ -5,7 +5,8 @@ import { useState } from "react"; import type { RemoteDevice } from "../acp/remote"; import { deviceLabel, promptedRequests } from "../state/connectRequests"; -import { answerConnectRequest, askConfirm, revokeRemoteDevice, useApp } from "../state/store"; +import { answerConnectRequest, revokeRemoteDevice } from "../state/remoteAccess"; +import { askConfirm, useApp } from "../state/store"; import { Spinner } from "./primitives"; import { Action, Group, Row } from "./settingsKit";