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
4 changes: 2 additions & 2 deletions docs/specs/pocket-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ Pocket is:

> auth screens + `MobileTerminalUi`/`MobileWall` + **`RemotePtyAdapter`**

— the composition [mobile-terminal-ui.md](./mobile-terminal-ui.md) owns, which the website also wires on `FakePtyAdapter` (`website/src/components/PocketTerminalExperience.tsx`). **Everything outside the PTY core no-ops or is absent** — `getCwd` → null, shells/clipboard empty, alerts inert, `alertAwait` settling `cancelled` rather than never resolving.
— the composition [mobile-terminal-ui.md](./mobile-terminal-ui.md) owns, also wired on `FakePtyAdapter` (`website/src/components/PocketTerminalExperience.tsx`). **Everything outside the PTY core no-ops or is absent** — `getCwd` → null, shells/clipboard empty, alerts inert, `alertAwait` settling `cancelled` rather than never resolving.

**Scanning is the only way in** — no setup password, no typed credential; the code on the computer's screen is both account setup and pairing.

**A QR the native camera opened is origin bootstrap only.** The `#pair?` fragment is erased before the first render, parsed or not; nothing is retained, no call is made, the token is not spent, and the auth screen asks for a scan from inside Pocket. **An installed iOS Pocket can never receive a scanned hash** — Camera opens Safari, a different partition. (rationale)

**The scanner reads a code as data**: it never navigates, and camera or pasted text goes to `parsePairingInvitationUrl` ([relay.md](./relay.md) owns the grammar) (rationale). **The invitation lives in memory only**, cleared on every terminal outcome.
**Must parse scanned or pasted text with `parsePairingInvitationUrl`, never navigate** ([relay.md](./relay.md) owns the grammar; rationale). **Must keep invitations in memory only, clearing them on every terminal outcome.** **Must discard parses finishing after Cancel or teardown.**

**After the parse.**

Expand Down
30 changes: 30 additions & 0 deletions lib/src/remote/pocket-app/ScanInvitation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,36 @@ async function paste(text: string): Promise<void> {
}

describe('reading a code', () => {
it.each([
['camera', 'cancel'], ['camera', 'unmount'], ['paste', 'cancel'], ['paste', 'unmount'],
])('drops a pending %s parse after %s', async (source, ending) => {
const camera = fakeCamera();
const { onScanned, onCancel } = render({ startScan: camera.startScan });
await settle();
const { url } = await invitationUrl();
if (source === 'paste') act(() => setNativeFieldValue(pasteField(), url));
act(() => {
if (source === 'camera') camera.read(url);
else container.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
if (ending === 'cancel') buttonNamed(container, 'Cancel')!.click();
else root.render(null);
});
await settle();

expect(onScanned).not.toHaveBeenCalled();
if (ending === 'cancel') expect(onCancel).toHaveBeenCalledOnce();
expect(camera.stops).toBeGreaterThan(0);
});

it('accepts a fresh paste if the caller keeps the scanner after cancellation', async () => {
const { onScanned } = render();
await settle();
act(() => buttonNamed(container, 'Cancel')!.click());
const { url } = await invitationUrl();
await paste(url);
expect(onScanned).toHaveBeenCalledOnce();
});

it('hands a camera read straight to the caller, without navigating', async () => {
const camera = fakeCamera();
const { onScanned } = render({ startScan: camera.startScan });
Expand Down
11 changes: 9 additions & 2 deletions lib/src/remote/pocket-app/ScanInvitation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ export function ScanInvitation({
* an error on screen telling the user to scan again.
*/
const acceptingRef = useRef(false);
// Retire pending parses without disabling fresh input if this screen stays up.
const parseGenerationRef = useRef(0);
useEffect(() => () => { parseGenerationRef.current++; }, []);
/**
* The tail of every start this screen has run, so **at most one is ever in
* flight**.
Expand Down Expand Up @@ -176,12 +179,15 @@ export function ScanInvitation({
const accept = useCallback(
async (text: string): Promise<void> => {
if (acceptingRef.current) return;
const generation = parseGenerationRef.current;
const invitation = await parsePairingInvitationUrl(text, appOrigin);
if (generation !== parseGenerationRef.current || acceptingRef.current) return;
if (!invitation) {
setRejected(await rejectionFor(text, appOrigin));
const reason = await rejectionFor(text, appOrigin);
if (generation === parseGenerationRef.current && !acceptingRef.current) setRejected(reason);
return;
}
if (acceptingRef.current) return;
parseGenerationRef.current++;
acceptingRef.current = true;
// A code that parsed clears the rejection this screen may still be
// showing: the two rows stack otherwise, and a ceremony that fails after
Expand Down Expand Up @@ -261,6 +267,7 @@ export function ScanInvitation({
className={pkButton({ tone: 'ghost', size: 'sm' })}
disabled={busy !== null}
onClick={() => {
parseGenerationRef.current++;
stopCamera();
onCancel();
}}
Expand Down
Loading