Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 216 additions & 0 deletions apps/desktop/src/state/remoteAccess.ts
Original file line number Diff line number Diff line change
@@ -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<RemoteStatus | null> {
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<void> {
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<RemoteStatus | null> {
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<boolean> {
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<void> {
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");
}
}
3 changes: 2 additions & 1 deletion apps/desktop/src/state/remotePairing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
4 changes: 1 addition & 3 deletions apps/desktop/src/state/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -78,7 +78,6 @@ import {
PLAN_CACHE_MAX_AGE_MS,
PLAN_CACHE_POLL_MS,
PLAN_STALE_MARGIN_MS,
pairRemoteDevice,
parsePanels,
peekThread,
permissionModeOf,
Expand All @@ -99,7 +98,6 @@ import {
refreshAgents,
refreshHarnesses,
refreshReadPlanLogins,
refreshRemote,
refreshSessions,
refreshSessionsOnFocus,
removeQueued,
Expand Down
Loading
Loading