diff --git a/apps/desktop/src/main/channel/crypto.test.ts b/apps/desktop/src/main/channel/crypto.test.ts index 414108b544..be39453fe2 100644 --- a/apps/desktop/src/main/channel/crypto.test.ts +++ b/apps/desktop/src/main/channel/crypto.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { test } from 'node:test' -import { b64url, deviceHello, FrameCodec, openFrame, privateKey, rawPublic, sasCommit, sealFrame, unb64url } from './crypto.ts' +import { b64url, deviceHello, FrameCodec, openFrame, privateKey, rawPublic, sasCode, sasCommit, sealFrame, unb64url } from './crypto.ts' const vectors = JSON.parse(readFileSync(new URL('../../../../../docs/reference/fixtures/remote-relay-vectors.json', import.meta.url), 'utf8')) const { keys, handshake: hs, frames, sas } = vectors @@ -50,6 +50,7 @@ test('a long message goes in 256 KiB chunks and comes back whole', () => { assert.deepEqual(opened[2], long) }) -test('the access request commit matches the protocol vectors', () => { +test('the access request commit and code match the protocol vectors', () => { assert.equal(b64url(sasCommit(device.pub, unb64url(sas.deviceNonce))), sas.commit) + assert.equal(sasCode(unb64url(keys.hostSignPub), device.pub, unb64url(sas.deviceNonce), unb64url(sas.hostNonce)), sas.code) }) diff --git a/apps/desktop/src/main/channel/crypto.ts b/apps/desktop/src/main/channel/crypto.ts index 0175c6db67..6f3026c019 100644 --- a/apps/desktop/src/main/channel/crypto.ts +++ b/apps/desktop/src/main/channel/crypto.ts @@ -57,6 +57,12 @@ export function deviceHello(device: DeviceKey, hostSignPub: Uint8Array, eph: Key /** §4.2 B: what the device commits to before the computer shows its half of the code. */ export const sasCommit = (dk: Uint8Array, nD: Uint8Array) => createHash('sha256').update(Buffer.concat([text('codync/sascommit/v1'), dk, nD])).digest() +/** §4.2 B: the 6-digit code both sides show once the device revealed its nonce. */ +export function sasCode(hk: Uint8Array, dk: Uint8Array, nD: Uint8Array, nH: Uint8Array) { + const hash = createHash('sha256').update(Buffer.concat([text('codync/sas/v2'), hk, dk, nD, nH])).digest() + return String(hash.readUInt32BE(0) % 1_000_000).padStart(6, '0') +} + // ---- frames (§6.3) ---- export const CHUNK = 256 * 1024 diff --git a/apps/desktop/src/main/channel/index.ts b/apps/desktop/src/main/channel/index.ts index 6e41863279..6980946b8e 100644 --- a/apps/desktop/src/main/channel/index.ts +++ b/apps/desktop/src/main/channel/index.ts @@ -10,8 +10,15 @@ import { RelayChannel, unreachable, type ChannelError, type Socket } from './rel // (`host-proxy.ts`), carried by one `RelayChannel` per computer. const channels = new Map() +/** Open streams by `:`, so a crashed page's can be found. */ const streams = new Map void>() +function closeStream(key: string) { + const stop = streams.get(key) + streams.delete(key) + stop?.() +} + function closeAll() { for (const stop of streams.values()) stop() streams.clear() @@ -57,19 +64,20 @@ function subscription(path: string): { sub: 'events' | 'term'; body: unknown } | } function openStream(account: AccountService, sender: WebContents, id: string, computerId: string, signKey: string, path: string) { + const key = `${sender.id}:${id}` const send = (channel: string, payload: unknown) => { if (!sender.isDestroyed()) sender.send(channel, id, payload) } const end = (error: ChannelError | null) => { - if (!streams.delete(id)) return + if (!streams.delete(key)) return send('host:stream:end', error) } const sub = subscription(path) if (!sub) return send('host:stream:end', { status: 404, message: 'Not found' }) let stop: (() => void) | null = null - streams.set(id, () => stop?.()) + streams.set(key, () => stop?.()) channelFor(account, computerId, signKey).then((channel) => { - if (!streams.has(id)) return + if (!streams.has(key)) return stop = channel.subscribe(sub.sub, sub.body, (event) => send('host:stream:data', JSON.stringify(event)), end) }, (error: ChannelError) => end(error)) } @@ -85,10 +93,10 @@ export function registerChannels(account: AccountService) { } }) ipcMain.on('channel:stream:open', (e, id: string, computerId: string, signKey: string, path: string) => openStream(account, e.sender, id, computerId, signKey, path)) - ipcMain.on('channel:stream:close', (_e, id: string) => { - const stop = streams.get(id) - streams.delete(id) - stop?.() + ipcMain.on('channel:stream:close', (e, id: string) => closeStream(`${e.sender.id}:${id}`)) + // A crashed page never says its streams went (a page that unloads closes its own, preload/index.ts). + app.on('render-process-gone', (_e, contents) => { + for (const key of [...streams.keys()]) if (key.startsWith(`${contents.id}:`)) closeStream(key) }) onAttention((on) => { for (const channel of channels.values()) channel.setAttended(on) diff --git a/apps/desktop/src/main/channel/relay-channel.test.ts b/apps/desktop/src/main/channel/relay-channel.test.ts index 7150ff1175..7570ca8803 100644 --- a/apps/desktop/src/main/channel/relay-channel.test.ts +++ b/apps/desktop/src/main/channel/relay-channel.test.ts @@ -158,3 +158,49 @@ test('a socket that goes quiet or fails is closed and dialed again', async () => mock.timers.reset() } }) + +test("a computer that rejoins while the relay's window is full gets a new handshake, then the calls that never left", async () => { + mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'] }) + try { + const { ch, relays } = channel() + const calls = Array.from({ length: 250 }, () => ch.call('hello', {}, 600_000).catch(() => 'dropped')) + const relay = relays[0]! + relay.push({ t: 'presence', online: true }) + assert.equal(relay.sent.length, 199) + mock.timers.tick(500) + relay.push({ t: 'presence', online: true }) + // `hello` waits out the window, longer than the handshake's own deadline, which runs from when it leaves. + mock.timers.tick(10_500) + assert.equal(relay.closedWith, null) + assert.equal(ch.state, 'ready') + const resent = relay.sent.slice(199) + assert.equal(resent.length, 51) + for (const call of resent) relay.answer({ id: call.id, ok: 'answered' }) + const ends = await Promise.all(calls) + assert.equal(ends.filter((end) => end === 'dropped').length, 199) + assert.equal(ends.filter((end) => end === 'answered').length, 51) + ch.close() + } finally { + mock.timers.reset() + } +}) + +test("a call that times out while it waits on the relay's window never reaches the computer", async () => { + mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'] }) + try { + const { ch, relays } = channel() + const earlier = Array.from({ length: 199 }, () => ch.call('hello', {}, 600_000).catch(() => {})) + const relay = relays[0]! + relay.push({ t: 'presence', online: true }) + const late = ch.call('setBot', {}, 5_000) + mock.timers.tick(5_000) + await assert.rejects(late, { status: 0 }) + mock.timers.tick(6_000) + assert.equal(relay.sent.length, 199) + assert.ok(relay.sent.every((message) => message.m === 'hello')) + ch.close() + await Promise.all(earlier) + } finally { + mock.timers.reset() + } +}) diff --git a/apps/desktop/src/main/channel/relay-channel.ts b/apps/desktop/src/main/channel/relay-channel.ts index a32b71404f..7a063884fa 100644 --- a/apps/desktop/src/main/channel/relay-channel.ts +++ b/apps/desktop/src/main/channel/relay-channel.ts @@ -1,4 +1,5 @@ import { deviceHello, FrameCodec, unb64url, type DeviceKey } from './crypto.ts' +import { RelayPacer } from './relay-pacer.ts' // One end-to-end encrypted channel to an account computer over the Codync relay (docs/reference/remote-relay.md // §6–§7), as the iPhone's `ChannelTransport` does it, relay only: the computers the desktop app reaches this way @@ -66,6 +67,8 @@ const asleep = (): ChannelError => ({ status: 0, message: 'Your computer is offl export class RelayChannel { state: 'idle' | 'connecting' | 'asleep' | 'ready' | 'refused' = 'idle' private socket: Socket | null = null + /** The socket's outgoing messages, held to the relay's per-link budget. */ + private out: RelayPacer | null = null private codec: FrameCodec | null = null private hello: ReturnType | null = null private nextId = 0 @@ -101,7 +104,7 @@ export class RelayChannel { reject, timer: setTimeout(() => { if (!this.calls.delete(call)) return - if (call.id !== null) this.sendInner({ id: call.id, cancel: true }) + if (call.id !== null) this.sendInner(() => ({ id: call.id, cancel: true })) reject(this.state === 'asleep' ? asleep() : unreachable()) this.settle() }, timeoutMs), @@ -124,7 +127,7 @@ export class RelayChannel { if (this.codec) this.sendSub(s) return () => { if (!this.subs.delete(s)) return - if (s.id !== null) this.sendInner({ id: s.id, cancel: true }) + if (s.id !== null) this.sendInner(() => ({ id: s.id, cancel: true })) this.settle() } } @@ -178,6 +181,7 @@ export class RelayChannel { return this.again() } this.socket = socket + this.out = new RelayPacer((text) => socket.send(text)) this.state = 'connecting' this.heard = Date.now() socket.addEventListener('message', (e) => { @@ -194,7 +198,7 @@ export class RelayChannel { socket.addEventListener('error', () => this.socket === socket && socket.close(BROKEN)) this.heartbeat = setInterval(() => { if (Date.now() - this.heard > SILENCE) socket.close(BROKEN) - else socket.send(PING) + else this.out?.send(() => [PING]) }, HEARTBEAT) } @@ -223,12 +227,16 @@ export class RelayChannel { } private startHandshake() { - this.hello = deviceHello(this.setup.device, unb64url(this.setup.signKey)) + const hello = deviceHello(this.setup.device, unb64url(this.setup.signKey)) + this.hello = hello this.state = 'connecting' - this.socket?.send(JSON.stringify(this.hello.hello)) const socket = this.socket if (this.handshakeTimer) clearTimeout(this.handshakeTimer) - this.handshakeTimer = setTimeout(() => this.hello && this.socket === socket && socket?.close(1000), HANDSHAKE) + // The deadline runs from when `hello` leaves: it may wait out the relay's window first. + this.out?.send(() => { + this.handshakeTimer = setTimeout(() => this.hello === hello && this.socket === socket && socket?.close(1000), HANDSHAKE) + return [JSON.stringify(hello.hello)] + }) } private welcome(wire: { ek: string; sig: string }) { @@ -294,6 +302,8 @@ export class RelayChannel { } private stopTimers() { + this.out?.clear() + this.out = null if (this.heartbeat) clearInterval(this.heartbeat) if (this.handshakeTimer) clearTimeout(this.handshakeTimer) if (this.idle) clearTimeout(this.idle) @@ -310,9 +320,13 @@ export class RelayChannel { }, delay) } - /** The channel is gone: what was sent on it can't be answered any more. What still waits to be sent waits on. */ + /** + * The channel is gone: what was sent on it can't be answered any more, and frames sealed for it but still held + * back by the pacer can't be opened by the next one. Calls and subscriptions not sent yet wait on. + */ private drop(error: ChannelError) { this.codec = null + this.out?.clear() for (const call of this.calls) { if (call.id === null) continue this.calls.delete(call) @@ -348,14 +362,24 @@ export class RelayChannel { // MARK: inner RPC + /** + * A call or subscription gets its id when it leaves, so one given up on while it waited for the relay's window + * (`call`'s timeout, an unsubscribe) never goes out, and `drop` keeps it waiting for the next channel. + */ private sendCall(call: Call) { - call.id = this.id() - this.sendInner({ id: call.id, m: call.method, b: call.body ?? null }) + this.sendInner(() => { + if (!this.calls.has(call)) return null + call.id = this.id() + return { id: call.id, m: call.method, b: call.body ?? null } + }) } private sendSub(sub: Sub) { - sub.id = this.id() - this.sendInner({ id: sub.id, sub: sub.sub, b: sub.body }) + this.sendInner(() => { + if (!this.subs.has(sub)) return null + sub.id = this.id() + return { id: sub.id, sub: sub.sub, b: sub.body } + }) } private id() { @@ -363,8 +387,13 @@ export class RelayChannel { return this.nextId } - private sendInner(message: unknown) { - if (!this.codec || !this.socket) return - for (const frame of this.codec.seal(message)) this.socket.send(JSON.stringify(frame)) + /** Seals what `make` returns once the pacer lets it out, on the channel it was queued for. */ + private sendInner(make: () => unknown) { + const codec = this.codec + if (!codec || !this.out) return + this.out.send(() => { + const message = make() + return message === null ? [] : codec.seal(message).map((frame) => JSON.stringify(frame)) + }) } } diff --git a/apps/desktop/src/main/channel/relay-pacer.test.ts b/apps/desktop/src/main/channel/relay-pacer.test.ts new file mode 100644 index 0000000000..fe2cdc4c7e --- /dev/null +++ b/apps/desktop/src/main/channel/relay-pacer.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict' +import { mock, test } from 'node:test' +import { RelayPacer } from './relay-pacer.ts' + +test("a large upload waits out the relay's 12 MiB window instead of overrunning it, in order", () => { + mock.timers.enable({ apis: ['setTimeout', 'Date'] }) + const written: string[] = [] + const pacer = new RelayPacer((text) => written.push(text)) + const frames = Array.from({ length: 20 }, (_, i) => String(i).padEnd(700 * 1024)) + pacer.send(() => [...frames]) + assert.equal(written.length, 17) + mock.timers.tick(10_999) + assert.equal(written.length, 17) + mock.timers.tick(1) + assert.deepEqual(written, frames) + mock.timers.reset() +}) + +test("many small messages wait out the relay's 200-message window", () => { + mock.timers.enable({ apis: ['setTimeout', 'Date'] }) + const written: string[] = [] + const pacer = new RelayPacer((text) => written.push(text)) + for (let i = 0; i < 250; i++) pacer.send(() => ['{"t":"ping"}']) + assert.equal(written.length, 200) + mock.timers.tick(11_000) + assert.equal(written.length, 250) + mock.timers.reset() +}) + +test('a message is made only once the window has room for it, so it can still be given up on while it waits', () => { + mock.timers.enable({ apis: ['setTimeout', 'Date'] }) + const written: string[] = [] + const pacer = new RelayPacer((text) => written.push(text)) + for (let i = 0; i < 200; i++) pacer.send(() => ['old']) + let madeAt: number | null = null + let wanted = true + pacer.send(() => { + madeAt = Date.now() + return wanted ? ['given up'] : [] + }) + pacer.send(() => ['next']) + assert.equal(madeAt, null) + wanted = false + mock.timers.tick(11_000) + assert.equal(madeAt, 11_000) + assert.deepEqual(written.slice(200), ['next']) + mock.timers.reset() +}) + +test('dropping what waits keeps what already left counting against the window', () => { + mock.timers.enable({ apis: ['setTimeout', 'Date'] }) + const written: string[] = [] + const pacer = new RelayPacer((text) => written.push(text)) + for (let i = 0; i < 201; i++) pacer.send(() => ['old']) + pacer.clear() + pacer.send(() => ['new']) + assert.equal(written.length, 200) + mock.timers.tick(11_000) + assert.deepEqual(written.slice(200), ['new']) + mock.timers.reset() +}) diff --git a/apps/desktop/src/main/channel/relay-pacer.ts b/apps/desktop/src/main/channel/relay-pacer.ts new file mode 100644 index 0000000000..b5aeb826a9 --- /dev/null +++ b/apps/desktop/src/main/channel/relay-pacer.ts @@ -0,0 +1,64 @@ +/** + * Holds a device link's outgoing messages to the relay's per-link budget (cloud `relay-wire.ts` `RATE_*`: ≤ 200 messages and + * ≤ 12 MiB per 10 s), which closes the link when exceeded. The window here is a second longer, so messages still in + * flight never land in the relay's next window early. Messages leave in the order they were queued. + * + * Each send is a function that makes its messages only once the window has room for the first of them, so what it + * makes (a sealed frame's counter, a request's id, a deadline) starts the moment it leaves, and it can still return + * nothing for a request given up on while it waited. + */ +const WINDOW_MS = 11_000 +const MAX_MESSAGES = 200 +const MAX_BYTES = 12 * 1024 * 1024 +/** The largest WebSocket message Cloudflare passes; a sealed 256 KiB frame is about 350 KiB. */ +const MAX_MESSAGE_BYTES = 1024 * 1024 + +export class RelayPacer { + private queue: (() => string[])[] = [] + /** Made, not sent yet: the rest of a message sealed into several frames. */ + private made: string[] = [] + private sent: { at: number; bytes: number }[] = [] + private timer: NodeJS.Timeout | undefined + + private readonly write: (text: string) => void + + constructor(write: (text: string) => void) { + this.write = write + } + + send(make: () => string[]) { + this.queue.push(make) + if (!this.timer) this.drain() + } + + /** Drops what hasn't left yet (the channel it was sealed for is gone); what left still counts against the window. */ + clear() { + this.queue = [] + this.made = [] + clearTimeout(this.timer) + this.timer = undefined + } + + private drain() { + this.timer = undefined + while (this.made.length || this.queue.length) { + const now = Date.now() + this.sent = this.sent.filter((s) => now - s.at < WINDOW_MS) + const room = MAX_BYTES - this.sent.reduce((sum, s) => sum + s.bytes, 0) + const size = this.made.length ? Buffer.byteLength(this.made[0]!) : MAX_MESSAGE_BYTES + if (this.sent.length >= MAX_MESSAGES || size > room) { + // A drain run from inside `write` (a reply that sends again) may have set one already. + clearTimeout(this.timer) + this.timer = setTimeout(() => this.drain(), this.sent[0]!.at + WINDOW_MS - now) + return + } + if (!this.made.length) { + this.made = this.queue.shift()!() + continue + } + const text = this.made.shift()! + this.sent.push({ at: now, bytes: size }) + this.write(text) + } + } +} diff --git a/apps/desktop/src/main/cloud-access.ts b/apps/desktop/src/main/cloud-access.ts index 90a585a3dc..10071efbb8 100644 --- a/apps/desktop/src/main/cloud-access.ts +++ b/apps/desktop/src/main/cloud-access.ts @@ -1,34 +1,37 @@ import { randomBytes } from 'node:crypto' +import { setTimeout as sleep } from 'node:timers/promises' import { ipcMain } from 'electron' +import type { AccessComputer, AccessResult } from '../shared/ipc' import type { AccountService } from './account' -import { b64url, sasCommit, unb64url } from './channel/crypto.ts' -import { cloudRequest, identity, type CloudFailure } from './cloud' +import { b64url, sasCode, sasCommit, unb64url } from './channel/crypto.ts' +import { cloudRequest, identity } from './cloud' // Asking an account computer for access (docs/reference/remote-relay.md §4.2 B): commit to a nonce, reveal it -// once the computer answered with its own. Only for Codync Cloud computers, which approve this account's -// devices by themselves, so nobody compares the code. The nonce never leaves this function before the reveal. +// once the computer answered with its own, then wait for its decision. `onCode` gets the 6-digit code both sides +// then show, for the person to compare; a Codync Cloud computer approves this account's devices by itself. +// The nonce never leaves this function before the reveal. const POLL_MS = 1500 -type Status = { status: string; hostNonce?: string; expiresAt?: number } +type Status = { status: string; hostNonce?: string } -async function requestAccess(account: AccountService, computerId: string): Promise<{ ok: true; value: { status: string } } | { ok: false; error: CloudFailure }> { +async function requestAccess(account: AccountService, computer: AccessComputer, onCode: (code: string) => void, signal: AbortSignal): Promise { const user = account.state.user if (!user) return { ok: false, error: { status: 0, code: 'signedOut', message: 'Sign in again to reach your account.' } } + const deviceKey = unb64url((await identity(user.userId)).publicKey) const nD = randomBytes(32) - const commit = b64url(sasCommit(unb64url((await identity(user.userId)).publicKey), nD)) - const created = await cloudRequest(account, 'POST', `v1/computers/${computerId}/access-requests`, { commit }, true) + const created = await cloudRequest(account, 'POST', `v1/computers/${computer.computerId}/access-requests`, { commit: b64url(sasCommit(deviceKey, nD)) }, true) if (!created.ok) return created const requestId = String(created.value.requestId) const expiresAt = Number(created.value.expiresAt) - /** The request's status once `done` accepts it, or a settled one; null once it expired here. */ - const wait = async (done: (s: Status) => boolean): Promise<{ ok: true; value: Status } | { ok: false; error: CloudFailure } | null> => { - while (Date.now() < expiresAt) { + /** The request's status once `done` accepts it, or a settled one; null once it expired here or was withdrawn. */ + const wait = async (done: (s: Status) => boolean): Promise<{ ok: true; value: Status } | Extract | null> => { + while (Date.now() < expiresAt && !signal.aborted) { const res = await cloudRequest(account, 'GET', `v1/access-requests/${requestId}`, undefined, false) if (!res.ok) return res const s = res.value as unknown as Status if (s.status !== 'pending' || done(s)) return { ok: true, value: s } - await new Promise((r) => setTimeout(r, POLL_MS)) + await sleep(POLL_MS, undefined, { signal }).catch(() => {}) } return null } @@ -36,16 +39,40 @@ async function requestAccess(account: AccountService, computerId: string): Promi if (answered?.ok && answered.value.status === 'pending') { const revealed = await cloudRequest(account, 'POST', `v1/access-requests/${requestId}/reveal`, { nonce: b64url(nD) }, true) if (!revealed.ok) return revealed + onCode(sasCode(unb64url(computer.signKey), deviceKey, nD, unb64url(answered.value.hostNonce!))) const decided = await wait(() => false) if (decided) return decided.ok ? { ok: true, value: { status: decided.value.status } } : decided } else if (answered) { return answered.ok ? { ok: true, value: { status: answered.value.status } } : answered } - // Nobody answered in time: take the request back, so the computer's list doesn't keep it. + // Withdrawn here, or nobody answered in time: take the request back, so the computer's list doesn't keep it. await cloudRequest(account, 'DELETE', `v1/access-requests/${requestId}`, undefined, false) - return { ok: true, value: { status: 'expired' } } + return { ok: true, value: { status: signal.aborted ? 'cancelled' : 'expired' } } } export function registerCloudAccess(account: AccountService) { - ipcMain.handle('cloud:requestAccess', (_e, computerId: string) => requestAccess(account, computerId)) + /** The request still waiting here for each computer, and the promise of the last one to settle. */ + const waiting = new Map }>() + ipcMain.handle('cloud:requestAccess', async (e, computer: AccessComputer, requestId: string) => { + const id = computer.computerId + const previous = waiting.get(id) + previous?.abort.abort() + const abort = new AbortController() + const onCode = (code: string) => { + if (!e.sender.isDestroyed()) e.sender.send('cloud:accessCode', requestId, code) + } + const run = (async () => { + // The computer cancels its pending request when a new one arrives, so the previous one must be gone first. + await previous?.settled + try { + if (abort.signal.aborted) return { ok: true, value: { status: 'cancelled' } } + return await requestAccess(account, computer, onCode, abort.signal) + } finally { + if (waiting.get(id)?.abort === abort) waiting.delete(id) + } + })() + waiting.set(id, { abort, settled: run.then(() => {}, () => {}) }) + return run + }) + ipcMain.on('cloud:cancelAccess', (_e, computerId: string) => waiting.get(computerId)?.abort.abort()) } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index d8ad865316..b0c9aa79e7 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -2,8 +2,8 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron' import { sshBridge } from './ssh' import type { CallError, CodyncBridge, HostSnapshot, TraySummary, WindowCommand } from '../shared/ipc' -let streamId = 0 -const streams = new Map void; onEnd: (error: CallError | null) => void }>() +/** Open streams by id, each with the close that ends it in the main process. */ +const streams = new Map void; onEnd: (error: CallError | null) => void; close: () => void }>() ipcRenderer.on('host:stream:data', (_e, id: string, line: string) => streams.get(id)?.onData(line)) ipcRenderer.on('host:stream:end', (_e, id: string, error: CallError | null) => { @@ -11,10 +11,9 @@ ipcRenderer.on('host:stream:end', (_e, id: string, error: CallError | null) => { streams.delete(id) s?.onEnd(error) }) -// A page that goes (a reload, its window closing) closes its streams: the next page numbers its own from 1 again. +// A page that goes (a reload, its window closing) closes its streams, local and remote alike. window.addEventListener('pagehide', () => { - for (const id of streams.keys()) ipcRenderer.send('host:stream:close', id) - streams.clear() + for (const s of [...streams.values()]) s.close() }) function listen(channel: string, cb: (value: T) => void) { @@ -23,6 +22,26 @@ function listen(channel: string, cb: (value: T) => void) { return () => void ipcRenderer.removeListener(channel, handler) } +/** A main-process call answering `{ ok, value }` or `{ ok, error }`: the value, or a rejection with the `CallError`. */ +async function invoke(channel: string, ...args: unknown[]) { + const result = (await ipcRenderer.invoke(channel, ...args)) as { ok: true; value: unknown } | { ok: false; error: CallError } + if (!result.ok) throw result.error + return result.value +} + +/** Lines from a stream the main process runs (`:stream:open`), until `onEnd` or the returned close. */ +function openStream(prefix: string, args: unknown[], onData: (line: string) => void, onEnd: (error: CallError | null) => void) { + // Unique across reloads: a late line for a stream of the page before never reaches one of this page. + const id = crypto.randomUUID() + const close = () => { + if (!streams.delete(id)) return + ipcRenderer.send(`${prefix}:stream:close`, id) + } + streams.set(id, { onData, onEnd, close }) + ipcRenderer.send(`${prefix}:stream:open`, id, ...args) + return close +} + // A dev sandbox starts past onboarding (`skip`: only a window that never chose is seeded) or back at // its welcome flow (`reset`); see tools/dev.mjs. Skipping also answers the GitHub star ask and marks // What's new seen on this version, which a fresh sandbox would otherwise show at once as an installation @@ -61,40 +80,12 @@ const bridge: CodyncBridge = { onChange: (cb) => listen('host:change', cb), install: () => ipcRenderer.send('host:install'), restart: () => ipcRenderer.send('host:restart'), - async call(baseURL, token, method, body, timeoutMs) { - const result = (await ipcRenderer.invoke('host:call', baseURL, token, method, body, timeoutMs)) as - | { ok: true; value: unknown } - | { ok: false; error: CallError } - if (!result.ok) throw result.error - return result.value - }, - stream(url, token, onData, onEnd) { - const id = `s${++streamId}` - streams.set(id, { onData, onEnd }) - ipcRenderer.send('host:stream:open', id, url, token) - return () => { - if (!streams.delete(id)) return - ipcRenderer.send('host:stream:close', id) - } - }, + call: (baseURL, token, method, body, timeoutMs) => invoke('host:call', baseURL, token, method, body, timeoutMs), + stream: (url, token, onData, onEnd) => openStream('host', [url, token], onData, onEnd), }, channel: { - async call(computerId, signKey, method, body, timeoutMs) { - const result = (await ipcRenderer.invoke('channel:call', computerId, signKey, method, body, timeoutMs)) as - | { ok: true; value: unknown } - | { ok: false; error: CallError } - if (!result.ok) throw result.error - return result.value - }, - stream(computerId, signKey, path, onData, onEnd) { - const id = `s${++streamId}` - streams.set(id, { onData, onEnd }) - ipcRenderer.send('channel:stream:open', id, computerId, signKey, path) - return () => { - if (!streams.delete(id)) return - ipcRenderer.send('channel:stream:close', id) - } - }, + call: (computerId, signKey, method, body, timeoutMs) => invoke('channel:call', computerId, signKey, method, body, timeoutMs), + stream: (computerId, signKey, path, onData, onEnd) => openStream('channel', [computerId, signKey, path], onData, onEnd), }, account: { state: () => ipcRenderer.invoke('account:state'), @@ -137,7 +128,18 @@ const bridge: CodyncBridge = { }, cloud: { request: (method, path, body, signed) => ipcRenderer.invoke('cloud:request', method, path, body, signed), - requestAccess: (computerId) => ipcRenderer.invoke('cloud:requestAccess', computerId), + async requestAccess(computer, onCode) { + // Its own code only: a late one of a request withdrawn before this one never shows here. + const requestId = crypto.randomUUID() + const handler = (_e: IpcRendererEvent, id: string, code: string) => id === requestId && onCode?.(code) + ipcRenderer.on('cloud:accessCode', handler) + try { + return await ipcRenderer.invoke('cloud:requestAccess', computer, requestId) + } finally { + ipcRenderer.removeListener('cloud:accessCode', handler) + } + }, + cancelAccess: (computerId) => ipcRenderer.send('cloud:cancelAccess', computerId), }, updates: { state: () => ipcRenderer.invoke('updates:state'), diff --git a/apps/desktop/src/renderer/demo/bridge.ts b/apps/desktop/src/renderer/demo/bridge.ts index cee038a779..ea7c47af49 100644 --- a/apps/desktop/src/renderer/demo/bridge.ts +++ b/apps/desktop/src/renderer/demo/bridge.ts @@ -113,6 +113,7 @@ const bridge: CodyncBridge = { cloud: { request: async () => ({ ok: false, error: { status: 503, code: 'demo', message: DEMO_UNAVAILABLE } }), requestAccess: async () => ({ ok: false, error: { status: 503, code: 'demo', message: DEMO_UNAVAILABLE } }), + cancelAccess() {}, }, updates: { state: async () => updates, onChange: none, check() {}, install() {}, setAutoCheck() {}, setAutoDownload() {} }, artifacts: { show: async () => {}, openInBrowser: unavailable }, diff --git a/apps/desktop/src/renderer/lib/account-computers.test.ts b/apps/desktop/src/renderer/lib/account-computers.test.ts new file mode 100644 index 0000000000..4bb1686cf6 --- /dev/null +++ b/apps/desktop/src/renderer/lib/account-computers.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import { registerHooks } from 'node:module' +import { test } from 'node:test' +import ts from 'typescript' +import type { AccessResult } from '@shared/ipc' +import type { AppModel } from '../store/app-model' + +registerHooks({ resolve(specifier, context, nextResolve) { + if (specifier.startsWith('@shared/')) return nextResolve(new URL(`../../shared/${specifier.slice(8)}.ts`, import.meta.url).href, context) + if (specifier.startsWith('.') && context.parentURL && !/\.[a-z]+$/.test(specifier)) { + const url = new URL(`${specifier}.ts`, context.parentURL) + if (existsSync(url)) return nextResolve(url.href, context) + } + return nextResolve(specifier, context) +}, load(url, context, nextLoad) { + if (url.endsWith('.ts')) return { format: 'module', shortCircuit: true, source: ts.transpileModule(readFileSync(new URL(url), 'utf8'), { compilerOptions: { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext } }).outputText } + return nextLoad(url, context) +} }) + +const cancelled: string[] = [] +/** The main process's request: `showCode` as the computer shows it, `end` with how it ended. */ +let showCode!: (code: string) => void +let end!: (result: AccessResult) => void +Object.assign(globalThis, { + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + window: { addEventListener() {}, codync: { + appVersion: '2.38.0', + cloud: { + requestAccess: (_computer: unknown, onCode: (code: string) => void) => new Promise((resolve) => { + showCode = onCode + end = resolve + }), + cancelAccess: (computerId: string) => cancelled.push(computerId), + }, + } }, +}) +// The store modules read `window` as they load, so they come in after it is set. +const { AccountComputers } = await import('../store/account-computers.ts') +const { analytics } = await import('../store/analytics.ts') +const events: unknown[] = [] +analytics.connect(async (e) => void events.push(e)) + +const studio = { computerId: 'studio', name: 'Studio', signKey: 'key', access: 'none' } + +/** A request whose code the computer showed, then ended `status`; what the person saw and what was counted. */ +async function ended(status: string) { + events.length = 0 + const errors: (string | null)[] = [] + const access = new AccountComputers({ setError: (e: string | null) => errors.push(e), cloud: { refresh: async () => {} } } as unknown as AppModel) + const asking = access.ask(studio) + showCode('123456') + assert.equal(access.asking.get('studio')?.code, '123456') + end({ ok: true, value: { status } }) + await asking + assert.equal(access.asking.has('studio'), false) + return { events: [...events], errors } +} + +test('a request counts once the code shows, and its end counts with why: denied or expired', async () => { + assert.deepEqual(await ended('approved'), { events: [{ event: 'access_requested' }], errors: [] }) + const denied = await ended('denied') + assert.deepEqual(denied.events, [{ event: 'access_requested' }, { event: 'access_request_failed', reason: 'denied' }]) + assert.deepEqual(denied.errors, ['The computer declined the request.']) + assert.deepEqual((await ended('expired')).events, [{ event: 'access_requested' }, { event: 'access_request_failed', reason: 'expired' }]) +}) + +test('Cancel withdraws the request and counts as cancelled only, whatever ends it next', async () => { + events.length = 0 + const access = new AccountComputers({ setError() { assert.fail('a cancelled request shows no error') } } as unknown as AppModel) + const asking = access.ask(studio) + showCode('123456') + access.cancel('studio') + assert.deepEqual(cancelled, ['studio']) + end({ ok: true, value: { status: 'denied' } }) + await asking + assert.deepEqual(events, [{ event: 'access_requested' }, { event: 'access_request_failed', reason: 'cancelled' }]) +}) + +test("a code still on its way when the request was withdrawn doesn't count", async () => { + events.length = 0 + const access = new AccountComputers({ setError() {} } as unknown as AppModel) + void access.ask(studio) + const lateCode = showCode + access.cancel('studio') + lateCode('111111') + assert.deepEqual(events, []) + end({ ok: true, value: { status: 'cancelled' } }) +}) diff --git a/apps/desktop/src/renderer/lib/refresh.test.ts b/apps/desktop/src/renderer/lib/refresh.test.ts index a167073e7b..dbd18e6972 100644 --- a/apps/desktop/src/renderer/lib/refresh.test.ts +++ b/apps/desktop/src/renderer/lib/refresh.test.ts @@ -25,6 +25,7 @@ Object.assign(globalThis, { }) const { AppModel } = await import('../store/app-model.ts') const { BotSync } = await import('../store/bot-sync.ts') +const { HostError } = await import('../client/host-client.ts') function deferred() { let resolve!: () => void @@ -106,6 +107,21 @@ test('an unresponsive host cannot leave manual refresh spinning forever', async store.retire() }) +test('a computer that refuses this device shows No access and stops retrying', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }) + let streams = 0 + const store = sync({ + call: async () => ({ version: '2.29.0', name: 'Test', backends: [] }), + stream: (_sub, _onData, onEnd) => { streams++; onEnd(new HostError(401, 'Refused')); return () => {} }, + }) + await store.refreshStream().catch(() => {}) + t.mock.timers.tick(60_000) + await new Promise((r) => setImmediate(r)) + assert.equal(store.connection.kind, 'unauthorized') + assert.equal(streams, 1) + store.retire() +}) + test("another device's auto order switch shows here", async () => { let send!: (event: object) => void const ready = deferred() diff --git a/apps/desktop/src/renderer/lib/relay-plan.test.ts b/apps/desktop/src/renderer/lib/relay-plan.test.ts new file mode 100644 index 0000000000..7179ad2edf --- /dev/null +++ b/apps/desktop/src/renderer/lib/relay-plan.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import type { CloudComputer } from '../store/cloud' +import { relayComputers } from './relay-plan.ts' + +const computer = (id: string, access: string | null): CloudComputer => ({ computerId: id, name: id, signKey: '', access }) + +test('approved computers open through the relay, except this one and one already reached over SSH', () => { + const computers = [computer('here', 'granted'), computer('studio', 'granted'), computer('ssh', 'granted'), computer('asked', 'pending'), computer('new', 'none')] + const ids = relayComputers(computers, 'here', (id) => id !== 'ssh').map((c) => c.computerId) + assert.deepEqual(ids, ['studio']) +}) + +test('when the SSH tunnel ends, the approved computer opens through the relay again', () => { + assert.deepEqual(relayComputers([computer('ssh', 'granted')], null, () => true).map((c) => c.computerId), ['ssh']) +}) diff --git a/apps/desktop/src/renderer/lib/relay-plan.ts b/apps/desktop/src/renderer/lib/relay-plan.ts new file mode 100644 index 0000000000..b47f3ff2b1 --- /dev/null +++ b/apps/desktop/src/renderer/lib/relay-plan.ts @@ -0,0 +1,9 @@ +import type { CloudComputer } from '../store/cloud' + +/** + * The account computers this app opens through the relay: approved ones (`access` `granted`) other than this + * computer and any the app reaches otherwise (SSH), whose store the relay doesn't own. + */ +export function relayComputers(computers: CloudComputer[], localId: string | null, owned: (id: string) => boolean) { + return computers.filter((c) => c.access === 'granted' && c.computerId !== localId && owned(c.computerId)) +} diff --git a/apps/desktop/src/renderer/store/account-computers.ts b/apps/desktop/src/renderer/store/account-computers.ts new file mode 100644 index 0000000000..b45c2f4dec --- /dev/null +++ b/apps/desktop/src/renderer/store/account-computers.ts @@ -0,0 +1,58 @@ +import type { AccessFailure } from '@shared/analytics' +import { Observable } from '../lib/observable' +import { analytics } from './analytics' +import type { AppModel } from './app-model' +import { CloudError, type CloudComputer } from './cloud' + +/** An access request this app is waiting on: `code` is null until the computer shows it too. */ +type Asking = { code: string | null } + +/** + * Requests for access to the account's other computers, as the phones ask: the computer and this app show the + * same 6-digit code, and its bots open (`useCloudAttachments`) once it's approved there. + */ +export class AccountComputers extends Observable { + asking = new Map() + private app: AppModel + + constructor(app: AppModel) { + super() + this.app = app + } + + /** Asks `computer` to let this app in. */ + async ask(computer: CloudComputer) { + const id = computer.computerId + if (this.asking.has(id)) return + const asking: Asking = { code: null } + this.set(id, asking) + const result = await window.codync.cloud.requestAccess(computer, (code) => { + // Withdrawn while the computer's code was on its way. + if (this.asking.get(id) !== asking) return + asking.code = code + analytics.track({ event: 'access_requested' }) + this.changed() + }) + // Withdrawn here: `cancel` already counted it. + if (this.asking.get(id) !== asking) return + this.set(id, null) + if (!result.ok) return this.app.setError(new CloudError(result.error.status, result.error.code, result.error.message).message) + if (result.value.status === 'approved') return void this.app.cloud.refresh() + const reason: AccessFailure = result.value.status === 'denied' ? 'denied' : result.value.status === 'cancelled' ? 'cancelled' : 'expired' + analytics.track({ event: 'access_request_failed', reason }) + this.app.setError(reason === 'denied' ? 'The computer declined the request.' : new CloudError(410, 'requestExpired', null).message) + } + + /** Withdraws the request: the computer stops showing it. */ + cancel(computerId: string) { + if (this.asking.get(computerId)?.code) analytics.track({ event: 'access_request_failed', reason: 'cancelled' }) + this.set(computerId, null) + window.codync.cloud.cancelAccess(computerId) + } + + private set(computerId: string, asking: Asking | null) { + if (asking) this.asking.set(computerId, asking) + else this.asking.delete(computerId) + this.changed() + } +} diff --git a/apps/desktop/src/renderer/store/app-model.ts b/apps/desktop/src/renderer/store/app-model.ts index b0e20da522..0e57952edf 100644 --- a/apps/desktop/src/renderer/store/app-model.ts +++ b/apps/desktop/src/renderer/store/app-model.ts @@ -7,6 +7,7 @@ import { undoOnFailure } from '../lib/undo-on-failure' import { account } from './account' import { analytics } from './analytics' import { BotStore } from './bot-store' +import { AccountComputers } from './account-computers' import { CloudModel } from './cloud' import { CloudComputerModel } from './cloud-computer' import { DeviceComputerOrder } from './computer-order' @@ -76,6 +77,8 @@ export class AppModel extends Observable { readonly computerAccess: ComputerAccess readonly computerOrder: DeviceComputerOrder readonly cloud: CloudModel + /** Requests for access to the account's other computers (their bots open through `useCloudAttachments`). */ + readonly accountComputers: AccountComputers /** The account's Codync Cloud computer (Settings → Computers & devices). */ readonly cloudComputer: CloudComputerModel @@ -84,6 +87,7 @@ export class AppModel extends Observable { this.computerAccess = new ComputerAccess(this) this.computerOrder = new DeviceComputerOrder() this.cloud = new CloudModel(this) + this.accountComputers = new AccountComputers(this) this.cloudComputer = new CloudComputerModel(this) account.subscribe(() => this.noteAccount()) } diff --git a/apps/desktop/src/renderer/store/bot-sync.ts b/apps/desktop/src/renderer/store/bot-sync.ts index e84ae66c51..3d79d17895 100644 --- a/apps/desktop/src/renderer/store/bot-sync.ts +++ b/apps/desktop/src/renderer/store/bot-sync.ts @@ -211,8 +211,9 @@ export class BotSync extends BotMirror { if (!alive()) return const message = error instanceof Error ? error.message : String(error) this.finishRefresh?.(new Error(message)) + // Refused (a revoked token or device): retrying can't help until access changes. + if (error instanceof HostError && error.status === 401) return this.setConnection({ kind: 'unauthorized', message }) this.setConnection(error instanceof HostError && error.offline ? { kind: 'computerOffline', lastSeen: null } : { kind: 'offline', message }) - if (error instanceof HostError && error.status === 401) return } if (!alive()) return await sleep(backoff) diff --git a/apps/desktop/src/renderer/store/cloud-attachments.ts b/apps/desktop/src/renderer/store/cloud-attachments.ts index 416962e290..da3c237156 100644 --- a/apps/desktop/src/renderer/store/cloud-attachments.ts +++ b/apps/desktop/src/renderer/store/cloud-attachments.ts @@ -1,20 +1,24 @@ import { useEffect } from 'react' import type { Computer } from '@shared/models' import { ChannelTransport } from '../client/host-client' +import { relayComputers } from '../lib/relay-plan' import { account } from './account' import type { AppModel } from './app-model' import { BotStore } from './bot-store' import type { CloudComputer } from './cloud' /** - * The account's Codync Cloud computer, used from this app like the iPhone does: asked for access once per launch - * (it approves this account's devices by itself), then attached as a store over the relay's encrypted channel, - * and detached when it leaves the account. Its channel stays open only while someone looks at the window, so the - * computer can sleep. Call once, from ChatWindow. + * The account's other computers, used from this app like the iPhone does: each one approved for this device is + * attached as a store over the relay's encrypted channel (its bots join the sidebar) and detached when it leaves + * the account or turns this device away. A Codync Cloud computer is asked for access once per launch (it approves + * this account's devices by itself); any other is asked from Settings (`AccountComputers`). Channels stay open only + * while someone looks at the window, so a cloud computer can sleep. Call once, from ChatWindow. */ export function useCloudAttachments(app: AppModel) { useEffect(() => { const attached = new Map() + // Each attached store's own changes: its connection reaches neither `app` nor `app.cloud`. + const watches = new Map void>() const asked = new Set() let attended = false let offline = new Set() @@ -22,7 +26,7 @@ export function useCloudAttachments(app: AppModel) { const ask = (c: CloudComputer) => { if (asked.has(c.computerId)) return asked.add(c.computerId) - void window.codync.cloud.requestAccess(c.computerId).then((res) => { + void window.codync.cloud.requestAccess(c).then((res) => { if (!res.ok) console.warn('Codync Cloud access:', res.error.message ?? res.error.code) void app.cloud.refresh(true) }) @@ -45,15 +49,22 @@ export function useCloudAttachments(app: AppModel) { if (!attended) store.pause() app.attach(computer, store) attached.set(c.computerId, store) + watches.get(c.computerId)?.() + watches.set(c.computerId, store.subscribe(noteConnections)) + } + + // Gone offline: the account's list says whether it's asleep (it wakes when this app connects) or off. Turned + // away (its grant went): the list no longer has it as granted, so its store goes and Settings offers to ask again. + const noteConnections = () => { + const now = new Set([...attached].filter(([, s]) => s.connection.kind === 'computerOffline' || s.connection.kind === 'unauthorized').map(([id]) => id)) + if ([...now].some((id) => !offline.has(id))) void app.cloud.refresh(true) + offline = now } const sync = () => { - const live = new Map() - for (const c of app.cloud.computers) { - if (!c.cloud) continue - if (c.access === 'granted') live.set(c.computerId, c) - else if (c.access === 'none') ask(c) - } + for (const c of app.cloud.computers) if (c.cloud && c.access === 'none') ask(c) + const owned = (id: string) => !app.store(id) || app.store(id) === attached.get(id) + const live = new Map(relayComputers(app.cloud.computers, app.local?.computer.id ?? null, owned).map((c) => [c.computerId, c])) for (const [id, c] of live) { const store = attached.get(id) if (store && store.computer.signKey === c.signKey && app.store(id) === store) store.setCloudHosting(c.cloud ?? null) @@ -62,12 +73,11 @@ export function useCloudAttachments(app: AppModel) { for (const [id, store] of attached) { if (live.has(id)) continue attached.delete(id) + watches.get(id)?.() + watches.delete(id) if (app.store(id) === store) app.detach(id) } - // Gone offline: the account's list says whether it's asleep (it wakes when this app connects) or off. - const now = new Set([...attached].filter(([, s]) => s.connection.kind === 'computerOffline').map(([id]) => id)) - if ([...now].some((id) => !offline.has(id))) void app.cloud.refresh(true) - offline = now + noteConnections() } const setAttended = (on: boolean) => { @@ -89,6 +99,7 @@ export function useCloudAttachments(app: AppModel) { offCloud() offApp() offAttended() + for (const off of watches.values()) off() for (const [id, store] of attached) if (app.store(id) === store) app.detach(id) } }, [app]) diff --git a/apps/desktop/src/renderer/views/settings/ComputersView.tsx b/apps/desktop/src/renderer/views/settings/ComputersView.tsx index ce1231ba8b..f4e07d822b 100644 --- a/apps/desktop/src/renderer/views/settings/ComputersView.tsx +++ b/apps/desktop/src/renderer/views/settings/ComputersView.tsx @@ -1,7 +1,7 @@ import { useCallback, useState } from 'react' import type { AccountApproval, PairingInfo } from '@shared/models' import { newSSHProfile, type SSHProfile } from '@shared/ssh' -import { Button, CardSection, IconButton, Reveal, Switch } from '../../components/Controls' +import { Button, CardSection, IconButton, Reveal, Spinner, Switch } from '../../components/Controls' import { Icon } from '../../components/Icon' import { Dialog, ModalHeader, Sheet, useDismiss } from '../../components/Overlay' import { HStack, Spacer, VStack } from '../../components/Stack' @@ -17,7 +17,7 @@ import { ComputerBadge } from '../ComputerBadge' import { PairingCode } from '../PairingCode' import { AuthorizedDevicesSection } from './AuthorizedDevicesSection' import { ComputersUsedSection } from './ComputersUsedSection' -import { leavingRemovesText, withJoinRemoves } from '../../lib/account-copy' +import { leavingRemovesText, spacedCode, withJoinRemoves } from '../../lib/account-copy' import { useJoinRemoves, useLeaveRemoves } from '../use-join-removes' import { cloudStatusLine } from '../../lib/relay-copy' import { CodyncCloudSection } from './CodyncCloudSection' @@ -47,8 +47,9 @@ export function ComputersView() { // A computer this app reaches (an SSH one) can name what goes; any other gets the general warning. const removingNames = useLeaveRemoves(managed.find((store) => store.computer.id === removing?.computerId) ?? null, removing !== null) // Every computer in the account but this one, the SSH computers too: their cards above are about managing them. - // Account computers this app isn't using (iOS `accountOnly`): the ones it uses have their own card or section. - const others = cloud.computers.filter((c) => !app.store(c.computerId)) + // Account computers this app doesn't manage (iOS `accountOnly`): this one and the SSH ones have their own card or + // section; one reached through the relay stays here, with Open. + const others = cloud.computers.filter((c) => !app.store(c.computerId)?.isLoopback) const addSSH = (host: string) => setEditing({ profile: { ...newSSHProfile(window.codync.hostPort), host }, isNew: true }) return ( @@ -323,13 +324,24 @@ function ManagedComputerCard({ store, ssh: overSSH, nested = false }: { store: B } /** - * An account computer this app isn't authorized on, or one it uses under an earlier identity (`olderCopy`). An - * offline one can be removed: a computer that was reset or given away never comes back under that identity. A - * cloud computer says where it runs, sleeping isn't offline, and it is deleted in its own section instead. + * An account computer this app reaches through the relay once it's approved there (Open), asks for access to + * (with the code the computer shows), or uses under an earlier identity (`olderCopy`). An offline one can be + * removed: a computer that was reset or given away never comes back under that identity. A cloud computer says + * where it runs, asks for access by itself, sleeping isn't offline, and it is deleted in its own section instead. */ function AccountComputerRow({ computer, olderCopy, onRemove }: { computer: CloudComputer; olderCopy: boolean; onRemove: () => void }) { const tr = useTr() + const app = useApp() + const dismiss = useDismiss() + const access = useModel(app.accountComputers) + const asking = access.asking.get(computer.computerId) + const store = app.store(computer.computerId) const status = accountComputerStatus(computer, olderCopy) + const open = () => { + const bot = store?.roster[0] + if (bot) app.select({ computerId: computer.computerId, botId: bot.id }) + dismiss() + } return (
@@ -342,9 +354,23 @@ function AccountComputerRow({ computer, olderCopy, onRemove }: { computer: Cloud {tr(status.text)}
+ {store && !store.isLoopback ? + : asking ? + : computer.online && !computer.cloud && !olderCopy && computer.access !== 'granted' ? + : null} {/* A cloud computer goes with Delete cloud computer, in its own section. */} {computer.online || computer.cloud ? null : }
+ +
+ {asking?.code ? ( + + {spacedCode(asking.code)} + {tr("Approve only if the computer shows exactly this code. If it doesn't, deny it there: someone may be trying to get in.")} + + ) : } +
+
) } diff --git a/apps/desktop/src/shared/analytics.ts b/apps/desktop/src/shared/analytics.ts index 69af74eb27..e027a70d12 100644 --- a/apps/desktop/src/shared/analytics.ts +++ b/apps/desktop/src/shared/analytics.ts @@ -43,6 +43,8 @@ export type VoiceCallEngine = 'on_device' | 'openai' | 'gemini' | 'cloud' export type VoiceStuck = 'none' | 'connecting' | 'reconnecting' | 'speaking' /** Why a call's engine gave up. */ export type VoiceFailure = 'none' | 'microphone' | 'connection' | 'provider' +/** Why an access request to an account computer ended without access (the phones' reasons). */ +export type AccessFailure = 'denied' | 'expired' | 'cancelled' /** What came of a window whose page crashed: it loaded again, or crashed too often and stays down. */ export type WindowRecoveryOutcome = 'reloaded' | 'gave_up' @@ -73,6 +75,8 @@ export type AnalyticsEvent = | { event: 'conversation_find_searched'; found: boolean } | { event: 'voice_call_started'; engine: VoiceCallEngine } | { event: 'voice_call_quality'; engine: VoiceCallEngine; ready_ms?: number; reconnects: number; stuck: VoiceStuck; failure: VoiceFailure } + | { event: 'access_requested' } + | { event: 'access_request_failed'; reason: AccessFailure } | { event: 'window_recovery'; outcome: WindowRecoveryOutcome } | { event: 'reach_account_answered'; outcome: ReachOutcome; error?: ClaimFailure } | { event: 'continued_without_account' } diff --git a/apps/desktop/src/shared/cloud-access.test.ts b/apps/desktop/src/shared/cloud-access.test.ts new file mode 100644 index 0000000000..2b98708284 --- /dev/null +++ b/apps/desktop/src/shared/cloud-access.test.ts @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict' +import * as crypto from 'node:crypto' +import { test } from 'node:test' +import * as channelCrypto from '../main/channel/crypto.ts' +import { deferred, loadMainModule, settle } from './test-support/main-module.ts' + +type Handler = (event: unknown, ...args: unknown[]) => unknown +const handlers = new Map() +const ipcMain = { handle: (channel: string, fn: Handler) => handlers.set(channel, fn), on: (channel: string, fn: Handler) => handlers.set(channel, fn) } + +/** `true`: each poll wait ends at once; `false`: it lasts until the request is withdrawn. */ +let quick = true +const sleep = (_ms: number, _value: unknown, { signal }: { signal: AbortSignal }) => new Promise((resolve, reject) => { + if (quick) return resolve(undefined) + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) +}) + +const deviceKey = crypto.generateKeyPairSync('ed25519').publicKey +const devicePub = (deviceKey.export({ format: 'jwk' }) as { x: string }).x +const hostNonce = crypto.randomBytes(32).toString('base64url') +/** The computer's answers, in order, to each status poll. */ +let polls: Record[] = [] +const calls: string[] = [] +let posts = 0 +/** When set, the next POST waits for it before answering, as a slow network would. */ +let holdPost: Promise | null = null +const cloudRequest = async (_account: unknown, method: string, path: string, body: unknown) => { + calls.push(`${method} ${path}`) + if (path.endsWith('/access-requests')) { + if (holdPost) await holdPost + return { ok: true, value: { requestId: `r${++posts}`, expiresAt: Date.now() + 600_000, commit: body } } + } + if (method === 'GET') return { ok: true, value: polls.length > 1 ? polls.shift()! : polls[0]! } + return { ok: true, value: {} } +} + +const { registerCloudAccess } = loadMainModule('cloud-access', { + 'node:crypto': crypto, 'node:timers/promises': { setTimeout: sleep }, electron: { ipcMain }, + './channel/crypto.ts': channelCrypto, './cloud': { cloudRequest, identity: async () => ({ publicKey: devicePub }) }, +}, { AbortController, Buffer }) +registerCloudAccess({ state: { user: { userId: 'user' } } } as never) + +const signKey = (crypto.generateKeyPairSync('ed25519').publicKey.export({ format: 'jwk' }) as { x: string }).x +const computer = { computerId: 'studio', signKey } +const sent: unknown[][] = [] +const sender = { isDestroyed: () => false, send: (...args: unknown[]) => void sent.push(args) } +type Result = { ok: boolean; value?: { status: string } } +/** How the request ended: its status, or the failure; read field by field, the module's objects are another realm's. */ +const ending = async (result: Promise) => { + const res = await result + return res.ok ? res.value!.status : 'failed' +} +const requestAccess = () => handlers.get('cloud:requestAccess')!({ sender }, computer, 'ask-1') as Promise +const steps = () => calls.map((call) => call.replace('v1/', '')) + +test('the code the computer shows goes to the page once ours is revealed, then its decision ends the request', async () => { + calls.length = sent.length = 0 + posts = 0 + quick = true + polls = [{ status: 'pending' }, { status: 'pending', hostNonce }, { status: 'approved' }] + assert.equal(await ending(requestAccess()), 'approved') + assert.deepEqual(steps(), ['POST computers/studio/access-requests', 'GET access-requests/r1', 'GET access-requests/r1', 'POST access-requests/r1/reveal', 'GET access-requests/r1']) + assert.equal(sent.length, 1) + const [channel, requestId, code] = sent[0]! + assert.deepEqual([channel, requestId], ['cloud:accessCode', 'ask-1']) + assert.match(String(code), /^\d{6}$/) +}) + +test('cancelling withdraws the request from the computer, before or after its code', async () => { + for (const answered of [false, true]) { + calls.length = sent.length = 0 + posts = 0 + quick = false + polls = [answered ? { status: 'pending', hostNonce } : { status: 'pending' }] + const asking = requestAccess() + for (let i = 0; i < 100 && !calls.at(-1)?.startsWith('GET'); i++) await settle() + handlers.get('cloud:cancelAccess')!({}, 'studio') + assert.equal(await ending(asking), 'cancelled') + assert.equal(calls.at(-1), 'DELETE v1/access-requests/r1') + assert.equal(sent.length, answered ? 1 : 0) + } +}) + +test('asking again waits for the withdrawn request to be gone, so the computer keeps the new one', async () => { + calls.length = sent.length = 0 + posts = 0 + quick = false + polls = [{ status: 'pending' }] + const held = deferred() + holdPost = held.promise + const first = requestAccess() + for (let i = 0; i < 100 && !calls.length; i++) await settle() + handlers.get('cloud:cancelAccess')!({}, 'studio') + const second = requestAccess() + await settle() + assert.deepEqual(steps(), ['POST computers/studio/access-requests'], 'the new request waits for the old one') + held.resolve() + holdPost = null + polls = [{ status: 'approved' }] + for (let i = 0; i < 100 && calls.length < 4; i++) await settle() + assert.deepEqual(steps(), ['POST computers/studio/access-requests', 'DELETE access-requests/r1', 'POST computers/studio/access-requests', 'GET access-requests/r2']) + assert.equal(await ending(first), 'cancelled') + assert.equal(await ending(second), 'approved') +}) diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 11ccc3f164..5b7de78c6f 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -39,6 +39,15 @@ export interface CallError { offline?: boolean } +/** An account computer to ask for access: its ID and pinned signing key, which the code is derived from. */ +export interface AccessComputer { + computerId: string + signKey: string +} + +/** How an access request ended (`approved`, `denied`, `cancelled`, `expired`), or why it couldn't be made. */ +export type AccessResult = { ok: true; value: { status: string } } | { ok: false; error: { status: number; code: string; message: string | null } } + /** A menu image as PNG data URLs drawn for 1x and 2x screens; each screen shows its own. */ export interface TrayImage { x1: string @@ -236,8 +245,13 @@ export interface CodyncBridge { /** The Codync cloud's `/v1` API with the signed-in session (and the device key for `signed`). */ cloud: { request(method: string, path: string, body: unknown, signed: boolean): Promise<{ ok: true; value: unknown } | { ok: false; error: { status: number; code: string; message: string | null } }> - /** Asks a Codync Cloud computer for this device's access and waits for its answer (it approves by itself). */ - requestAccess(computerId: string): Promise<{ ok: true; value: { status: string } } | { ok: false; error: { status: number; code: string; message: string | null } }> + /** + * Asks the computer for this device's access and waits for its decision. `onCode` gets the 6-digit code once + * the computer shows it too (a Codync Cloud computer approves by itself, so nobody compares it). + */ + requestAccess(computer: AccessComputer, onCode?: (code: string) => void): Promise + /** Withdraws the computer's request still waiting here; `requestAccess` then ends `cancelled`. */ + cancelAccess(computerId: string): void } updates: { state(): Promise diff --git a/apps/desktop/src/shared/preload-streams.test.ts b/apps/desktop/src/shared/preload-streams.test.ts new file mode 100644 index 0000000000..0db48caa5b --- /dev/null +++ b/apps/desktop/src/shared/preload-streams.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { loadMainModule } from './test-support/main-module.ts' +import type { CodyncBridge } from './ipc' + +/** One page load of the preload: what it sent the main process, its `window.codync`, and its unload. */ +function loadPage() { + const sent: unknown[][] = [] + const invoked: unknown[][] = [] + const ipcListeners = new Map void)[]>() + const listeners = new Map void>() + let bridge!: CodyncBridge + const ipcRenderer = { + on: (channel: string, fn: (e: unknown, ...args: unknown[]) => void) => void ipcListeners.set(channel, [...(ipcListeners.get(channel) ?? []), fn]), + removeListener() {}, + send: (...args: unknown[]) => void sent.push(args), + sendSync: () => null, + invoke: (...args: unknown[]) => new Promise(() => void invoked.push(args)), + } + /** A message from the main process. */ + const receive = (channel: string, ...args: unknown[]) => ipcListeners.get(channel)?.forEach((fn) => fn({}, ...args)) + loadMainModule('../preload/index', { + electron: { ipcRenderer, contextBridge: { exposeInMainWorld: (_name: string, value: CodyncBridge) => void (bridge = value) } }, + './ssh': { sshBridge: {} }, + }, { window: { addEventListener: (name: string, fn: () => void) => listeners.set(name, fn) }, crypto, process }) + return { sent, invoked, receive, bridge, unload: () => listeners.get('pagehide')?.() } +} + +test("a reloaded page closes its relay streams, and the next page's streams never take their ids", () => { + const before = loadPage() + before.bridge.channel.stream('c1', 'k', '/events', () => {}, () => {}) + before.bridge.host.stream('http://127.0.0.1:1/api/events', 't', () => {}, () => {}) + before.unload() + const id = before.sent.find((m) => m[0] === 'channel:stream:open')?.[1] + assert.ok(before.sent.some((m) => m[0] === 'channel:stream:close' && m[1] === id), 'the relay subscription closes') + assert.ok(before.sent.some((m) => m[0] === 'host:stream:close'), 'the local stream closes') + + const after = loadPage() + after.bridge.channel.stream('c1', 'k', '/events', () => {}, () => {}) + assert.notEqual(after.sent.find((m) => m[0] === 'channel:stream:open')?.[1], id) +}) + +test("a code meant for a request withdrawn earlier doesn't show on the next one for the same computer", () => { + const page = loadPage() + const computer = { computerId: 'c1', signKey: 'k' } + const codes: string[] = [] + void page.bridge.cloud.requestAccess(computer, (code) => codes.push(`first ${code}`)) + page.bridge.cloud.cancelAccess('c1') + void page.bridge.cloud.requestAccess(computer, (code) => codes.push(`second ${code}`)) + const [first, second] = page.invoked.filter((args) => args[0] === 'cloud:requestAccess').map((args) => args[2]) + page.receive('cloud:accessCode', first, '111111') + page.receive('cloud:accessCode', second, '222222') + assert.deepEqual(codes, ['first 111111', 'second 222222']) +}) diff --git a/cloud/src/relay-wire.ts b/cloud/src/relay-wire.ts index 95f750595b..40a6e398f8 100644 --- a/cloud/src/relay-wire.ts +++ b/cloud/src/relay-wire.ts @@ -8,7 +8,8 @@ export const PAIRING_TTL_MS = 120_000; export const PAIRING_MAX_MESSAGES = 16; export const RATE_WINDOW_MS = 10_000; export const RATE_MAX_MESSAGES = 200; -export const RATE_MAX_BYTES = 8 * 1024 * 1024; +// One message at the host's 8 MiB cap for an approved device (a 5 MiB icon) is ~10.7 MiB of sealed base64url frames. +export const RATE_MAX_BYTES = 12 * 1024 * 1024; export const MBOX_MAX_BLOB = 64 * 1024; export const MBOX_PER_DEVICE = 50; export const MBOX_PER_COMPUTER = 500; diff --git a/cloud/src/relay.ts b/cloud/src/relay.ts index 470d178ee9..011029c371 100644 --- a/cloud/src/relay.ts +++ b/cloud/src/relay.ts @@ -403,7 +403,7 @@ export class ComputerRelay extends DurableObject { } } - /** Fixed 10 s window per device link: ≤ 200 messages and ≤ 8 MiB (§7.6). */ + /** Fixed 10 s window per device link: ≤ 200 messages and ≤ 12 MiB (§7.6). */ private charge(link: string, bytes: number, now: number): boolean { let r = this.rates.get(link); if (!r || now - r.start >= RATE_WINDOW_MS) { diff --git a/cloud/test/relay.test.ts b/cloud/test/relay.test.ts index f14e53b392..aab236ffe6 100644 --- a/cloud/test/relay.test.ts +++ b/cloud/test/relay.test.ts @@ -384,6 +384,16 @@ describe("rate limits", () => { expect(await dev.closed()).toBe(4008); }); + it("passes one message at an approved device's 8 MiB cap (a 5 MiB icon), sealed into frames", async () => { + const { hostWs, dev } = await pair(); + await dev.nextT("presence"); + const frames = new ref.FrameCodec(ref.random(32), ref.random(32)).seal({ b: { data: "A".repeat(8 * 1024 * 1024 - 17) } }); + for (const f of frames) dev.send(f); + for (let i = 0; i < frames.length; i++) await hostWs.nextT("data"); + dev.send(PING); + expect(await dev.nextT("pong")).toEqual({ t: "pong" }); + }); + it("closes on malformed or unknown messages", async () => { const { dev } = await pair(); dev.send("not json"); diff --git a/docs/architecture/connections.md b/docs/architecture/connections.md index 1976b5c3b3..37330cbd74 100644 --- a/docs/architecture/connections.md +++ b/docs/architecture/connections.md @@ -165,7 +165,7 @@ Phases, file-level steps, tests and risks: [require sign-in](../plans/require-si | 7 | One-click host install over SSH, then the app asks about **Add to account** | Built (2.41.0) | | 8 | Honor the 15-minute lease when the relay drops | Not built: account devices are refused until the cloud state syncs again | | 9 | The renames above, in every client | Not built | -| 10 | Desktop apps as devices (encrypted channel), so one computer can use another's bots | Not built | +| 10 | Desktop apps as devices (encrypted channel), so one computer can use another's bots | Built (2.43.0) | | 11 | [Optional sign-in](../plans/optional-sign-in.md): signed-out phones, computers in no account, the relay only within an account | Built (2.41.0); the relay check is off on the main cloud until the release steps run | ## Prior art (checked 2026-11-02) diff --git a/docs/architecture/desktop-app.md b/docs/architecture/desktop-app.md index c13b42f38a..c5efc9d9f1 100644 --- a/docs/architecture/desktop-app.md +++ b/docs/architecture/desktop-app.md @@ -103,15 +103,20 @@ The TUI connects to one host at a time, so it keeps its single roster. work). The tray icon is the app icon; left-click opens the chat, right-click the menu. - **Remote screen** on macOS registers `CodyncScreen.app` (built from the Xcode `Screen` target) as an SMAppService agent; Linux starts its helper from the host, as before. -- **Other computers in the account** are reached through this computer's own host and SSH - tunnels, except the account's Codync Cloud computers: the main process opens the relay's encrypted channel to each - (`main/channel/`, the iPhone's `ChannelTransport` over the relay only, since a cloud computer has no address to - dial), asks each for access once per launch (they approve the account's devices by themselves), and the window uses them - like any computer: chats, bots and their settings, setup terminals. The channel is open only while someone looks - at the window (shown, not minimized, the computer used in the last 10 minutes: `main/attention.ts`), as a phone - connects only in the foreground, since a connected device keeps a cloud computer awake and billed. Other - computers' "Ask for access" rows show status only: the product targets one computer. Every offline row - but a cloud computer's (deleted in its own section) has *Remove from account* (confirmed first); an older copy of a computer the app uses also reads +- **Other computers in the account** that approved this install open through the account's relay: the main + process opens the relay's end-to-end encrypted channel to each (`main/channel/`, the iPhone's `ChannelTransport` + over the relay only), unless an SSH tunnel already reaches it, and the window uses them like any computer: chats, + bots and their settings, setup terminals. A computer that refuses this install reads *No access* and stops + retrying; outgoing frames are paced to the relay's per-link budget (`main/channel/relay-pacer.ts`), so a large + upload never trips its `4008 rateLimited`. A request is sealed and numbered only when the pacer lets it out, so one + that times out while it waits never reaches the computer, and the handshake's deadline runs from when `hello` + leaves. Codync Cloud computers are asked for access once per launch (they + approve the account's devices by themselves); any other computer's online row has *Ask for access*, which runs + the phones' 6-digit access request. The channel is open only while someone looks at the window (shown, not + minimized, the computer used in the last 10 minutes: `main/attention.ts`), as a phone connects only in the + foreground, since a connected device keeps a cloud computer awake and billed. Every offline row but a cloud + computer's (deleted in its own section) has *Remove from account* (confirmed first); an older copy of a computer + the app uses also reads *Older copy · last seen …*. - **Updates**: electron-updater reads the release GitHub marks latest, and only the release gate (`tools/release-gate.py`) sets that mark diff --git a/docs/guides/accounts-and-ssh.md b/docs/guides/accounts-and-ssh.md index 19d4f1b3df..c4eb7a13de 100644 --- a/docs/guides/accounts-and-ssh.md +++ b/docs/guides/accounts-and-ssh.md @@ -232,9 +232,11 @@ A simulator build verifies compilation, not the Dashboard settings or OAuth flow ## Desktop: computers, approvals and SSH The desktop app attaches this computer's host and its SSH computers over loopback; they -follow into whichever account is active. The account's Codync Cloud computer is attached over the relay's -encrypted channel, while the window is in use ([desktop app](../architecture/desktop-app.md)). The account's other -computers are not reached directly, so their "Ask for access" rows show status only. An older copy of a computer the app uses shows +follow into whichever account is active. The account's other computers that approved this +install open through the relay over the encrypted channel while the window is in use, as on the phones, with +their bots in the sidebar (an SSH tunnel to the same computer wins; [desktop app](../architecture/desktop-app.md)). +The Codync Cloud computer is asked for access by itself; *Ask for access* on any other online row runs the +phones' access request and shows its 6-digit code. An older copy of a computer the app uses shows *Older copy · last seen …*. Every offline row has *Remove from account* (`DELETE /v1/computers/{id}`, confirmed first), except the Codync Cloud computer, which is deleted in its own section (the cloud refuses to take it out of the account), so a computer that was reset or given away can be cleaned up even when its diff --git a/docs/plans/require-sign-in.md b/docs/plans/require-sign-in.md index 6cc868b0db..6658df25bb 100644 --- a/docs/plans/require-sign-in.md +++ b/docs/plans/require-sign-in.md @@ -204,4 +204,3 @@ Phase 2 and 3 need these once per cloud, before the hosts that ask for them ship - Honor the 15-minute lease when the relay drops; today account devices are refused until the cloud state syncs again. - Renames: `local`, `owner`, the desktop's "{name} can use". -- Desktop apps as devices (the encrypted channel on the desktop), so one computer can use another's bots. diff --git a/docs/reference/analytics-events.md b/docs/reference/analytics-events.md index fb2cfac07d..5e14e541f9 100644 --- a/docs/reference/analytics-events.md +++ b/docs/reference/analytics-events.md @@ -75,8 +75,8 @@ host answers and sends it once the host is up. | `pairing_failed` | iOS, Android | A pairing attempt fails | `reason` (`not_pairing_link`/`outdated_host`/`invalid`/`expired`/`unauthorized`/`unreachable`/`timeout`/`upgrade_required`/`other`) | | `computer_paired` | iOS, Android | The phone reached a computer | `via` (`qr`: scanned or pasted on the scan screen; `link`: a pairing link opened from outside; `account`), `computer` | | `device_paired` | host | A phone was added: a pairing code redeemed or an account request approved | `via` (`qr`/`account`), `platform`, `automatic` (approved without a tap), `account` (`joined`: in the computer's account, or the computer joined the phone's; `phone_only`: signed in, the computer stayed out; `signed_out`) | -| `access_requested` | iOS, Android | The phone asks an account computer for access | — | -| `access_request_failed` | iOS, Android | The request ended without access | `reason` (`denied`/`expired`/`cancelled`) | +| `access_requested` | host `track` (desktop), iOS, Android | The app asks an account computer for access (the computer shows the code) | — | +| `access_request_failed` | host `track` (desktop), iOS, Android | The request ended without access | `reason` (`denied`/`expired`/`cancelled`) | | `access_request_denied` | host (`decideAccessRequest`) | The computer's owner denied a phone | `client` | | `device_revoked` | host (`revokeDevice`) | A phone's access was removed | `client` | | `phone_connected` | host | A paired phone connects, at most once per phone per UTC day | `platform`, `route` (`direct`/`relay`: how that first connection of the day came in) | diff --git a/docs/reference/remote-relay.md b/docs/reference/remote-relay.md index a1a394ec80..a193dff667 100644 --- a/docs/reference/remote-relay.md +++ b/docs/reference/remote-relay.md @@ -63,7 +63,7 @@ Shared deterministic [vectors](fixtures/remote-relay-vectors.json) and their [ge | Swift transports / identity | `apps/ios/Kit/Sources/CodyncKit/Client/` | | Account and per-computer state | `apps/ios/Kit/Sources/CodyncUI/` | | iOS account integration | `apps/ios/App/AccountSession.swift` | -| Desktop account, device key, cloud API | `apps/desktop/src/main/account.ts`, `cloud.ts` | +| Desktop account, device key, cloud API, device channel | `apps/desktop/src/main/account.ts`, `cloud.ts`, `cloud-access.ts`, `channel/` | See [architecture](../architecture/overview.md) and [file structure](../architecture/file-structure.md). Application/host compatibility follows mutual minimum versions ([compatibility](compatibility.md)); channel `v`, QR version and Worker package version are separate values. @@ -85,7 +85,7 @@ See [architecture](../architecture/overview.md) and [file structure](../architec - 每個 `SharedStore.Context.id`(帳號 namespace,`local` 或 hash(userID))一把,避免跨帳號關聯。 - 另有一把 X25519 **push key**(`Curve25519.KeyAgreement.PrivateKey`),同樣每 context 一把,只用於解開推播(§6.7)。 - Keychain:`kSecClassGenericPassword`,`kSecAttrService = "com.pokai.Codync.device-key"`(push key 用 `"com.pokai.Codync.push-key"`),`kSecAttrAccount = context.id`,值 = 32-byte `rawRepresentation`,`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`,`kSecAttrSynchronizable = false`。 -- 桌面 App(`apps/desktop/src/main/cloud.ts`):每個帳號一把 Ed25519 device key,seed 以 Electron `safeStorage`(OS keychain)加密後存在 app 的 userData 目錄;只用於雲端 `/v1` API 的 `Codync-Sig`。 +- 桌面 App(`apps/desktop/src/main/cloud.ts`):每個帳號一把 Ed25519 device key,seed 以 Electron `safeStorage`(OS keychain)加密後存在 app 的 userData 目錄;用於雲端 `/v1` API 與 relay `/v1/relay/device/{computerId}` 連線的 `Codync-Sig`、channel `hello` 的 `sig`(`main/channel/`),access request 的 `commit` 也綁定它的公鑰(`main/cloud-access.ts`)。 - iOS App、Widgets、NotificationService 共用 access group `$(AppIdentifierPrefix)com.pokai.Codync`(`project.yml` 對這三個 target 加 `keychain-access-groups` entitlement;`CodyncKit` 從 Info.plist `CodyncKeychainGroup` 讀 group,缺值則不設 group)。 - 私鑰永不離開 Keychain/process;雲端只存公鑰。 - 帳號登出:先在 Clerk 登出(失敗就什麼都不變)。成功後只停通知、保留金鑰:連得到的電腦呼叫 `unregisterDevice` 刪掉 push tickets;雲端 `POST /v1/devices/sign-out`(用登出前先取的 session token)標記這台裝置已登出,帳號的其他電腦下次拉 `/v1/host/state` 時依 `signedOutDevices` 刪掉它在登出前登記的 push tickets。各自最多數秒、失敗不擋登出。該 context 的 device key、computers、草稿都留著,同一帳號登回來直接連上(`POST /v1/devices` 清掉標記)。**Start over**/刪除帳號才交還金鑰:連得到的電腦呼叫 `forgetDevice`(舊版 host 改呼叫 `unregisterDevice`),雲端 `DELETE /v1/devices/{id}`(刪除帳號由 `user.deleted` webhook 處理),再刪除 device key、computers 快取、mailbox 草稿、widget snapshot;Start over 連這支手機上登出過的帳號一起刪。刪除帳號先在 Clerk 刪除使用者,成功後才交還給電腦。 @@ -450,7 +450,7 @@ Channel 訊息與 DO 訊息的 `t` 值不重疊:channel 只有 `hello`/`welcom - 重連退避:host 1 s 起倍增至 60 s、device 1 s 起倍增至 30 s,皆 full jitter;穩定 60 秒後重置。 - `4100 paired` → 立即以一般模式重連(§4.1)。 - 不重試的 close code:`4001 unauthorized`、`4003 revoked`、`4410 pairingExpired`(device 顯示需重新授權);`4009 replaced` → host 等 5 分鐘再試並記錄 `error`(可能有人複製了 `identity.json`);`4400 unsupportedVersion` → 顯示需要更新。 -- 限流(DO 記憶體 token bucket,休眠後重置可接受):**只計 device → DO 方向**,每條 device socket 每 10 秒 ≤ 200 則、≤ 8 MiB;超過 `4008 rateLimited`。host → device(含 `sync` 補送)不限。 +- 限流(DO 記憶體 token bucket,休眠後重置可接受):**只計 device → DO 方向**,每條 device socket 每 10 秒 ≤ 200 則、≤ 12 MiB(容得下一則 8 MiB 上限訊息封成 base64url frames 後約 10.7 MiB);超過 `4008 rateLimited`。host → device(含 `sync` 補送)不限。 - 單則 WebSocket 訊息 ≤ 1 MiB(Cloudflare 限制);§6.3 的 256 KiB chunk 保證不超過。 Close code 總表:`1000` 正常、`1011` 內部錯誤/逾時、`4001 unauthorized`、`4002 protocolError`、`4003 revoked`、`4004 unknownComputer`、`4008 rateLimited`、`4009 replaced`、`4011 rekey`、`4013 tooLarge`、`4100 paired`、`4400 unsupportedVersion`、`4410 pairingExpired`。 @@ -567,7 +567,7 @@ The route table below summarizes the contract. Executable schema and validation ## 10. iOS Swift integration -`CodyncKit/Client` (`apps/ios/Kit/`) owns transport, crypto, identity and cloud API models. `CodyncUI` owns account/per-computer stores and presentation. The desktop app reaches its own host and SSH computers over loopback, and only the account's Codync Cloud computer over the device channel (relay only, `apps/desktop/src/main/channel/`). Consumers must retain account and computer scope when handling asynchronous responses, links and cached widget data. See [file structure](../architecture/file-structure.md). +`CodyncKit/Client` (`apps/ios/Kit/`) owns transport, crypto, identity and cloud API models. `CodyncUI` owns account/per-computer stores and presentation. The desktop app reaches its own host and SSH computers over loopback, and the account computers that approved it over the device channel (relay only, `apps/desktop/src/main/channel/`). Consumers must retain account and computer scope when handling asynchronous responses, links and cached widget data. See [file structure](../architecture/file-structure.md). ## 11. Apps and SSH diff --git a/host/src/analytics/event.rs b/host/src/analytics/event.rs index acfb9c3470..ebed95132e 100644 --- a/host/src/analytics/event.rs +++ b/host/src/analytics/event.rs @@ -18,6 +18,8 @@ pub enum Event { SignedOut, PairingCodeCreated, DevicePaired, + AccessRequested, + AccessRequestFailed, AccessRequestDenied, DeviceRevoked, PhoneConnected, @@ -158,6 +160,8 @@ impl Event { Self::SignedOut => "signed_out", Self::PairingCodeCreated => "pairing_code_created", Self::DevicePaired => "device_paired", + Self::AccessRequested => "access_requested", + Self::AccessRequestFailed => "access_request_failed", Self::AccessRequestDenied => "access_request_denied", Self::DeviceRevoked => "device_revoked", Self::PhoneConnected => "phone_connected", diff --git a/host/src/analytics/reported.rs b/host/src/analytics/reported.rs index d745f528ca..ebebbf2bf1 100644 --- a/host/src/analytics/reported.rs +++ b/host/src/analytics/reported.rs @@ -107,6 +107,11 @@ pub enum Reported { stuck: VoiceStuck, failure: VoiceFailure, }, + /// The desktop app asks an account computer for access (the phones send this themselves). + AccessRequested, + AccessRequestFailed { + reason: AccessFailure, + }, WindowRecovery { outcome: WindowRecoveryOutcome, }, @@ -204,6 +209,17 @@ pub enum Ask { Other, } +/// Why an access request ended without access. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccessFailure { + Denied, + Expired, + Cancelled, + #[serde(other)] + Other, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Link { @@ -589,6 +605,11 @@ mod tests { "reconnects": 1, "stuck": "none", "failure": "none"}), Event::VoiceCallQuality, ), + (json!({"event": "access_requested", "app": "desktop"}), Event::AccessRequested), + ( + json!({"event": "access_request_failed", "app": "desktop", "reason": "denied"}), + Event::AccessRequestFailed, + ), (json!({"event": "window_recovery", "app": "desktop", "outcome": "reloaded"}), Event::WindowRecovery), (json!({"event": "window_recovery", "app": "desktop", "outcome": "gave_up"}), Event::WindowRecovery), (json!({"event": "continued_without_account", "app": "desktop"}), Event::ContinuedWithoutAccount), diff --git a/web/content/docs/remote-access.mdx b/web/content/docs/remote-access.mdx index f5dc19b9ed..65d5a81b8d 100644 --- a/web/content/docs/remote-access.mdx +++ b/web/content/docs/remote-access.mdx @@ -60,6 +60,8 @@ Signing in with Apple, Google or GitHub doesn't give any device access by itself On a computer without the desktop app, use `codync-host access` to list requests and `codync-host access approve ` to approve one. +The desktop app reaches your account's other computers the same way, with no SSH setup or QR code: in **Settings → Computers**, under **Other computers in your account**, choose **Ask for access** on an online computer and compare the 6-digit code it shows. Once it's approved, its bots join the sidebar, and **Open** on its row opens them. + Signing out of the desktop app keeps the computer in your account. If you then sign in with a different account, the app asks whether to move the computer there. Choose **Move here** and your phone, signed in to the new account, finds it under **Manage computers**. Every device of the old account loses access, phones paired with a QR code included. Moving it turns **Skip the 6-digit check** off, so new devices ask with the 6-digit code; turn it back on in **Settings → Computers** if you want. Choose **Keep it there** to leave it where it is; you can still move it later with **Remove from account** and then **Add to account**. When the computer is in another account than the desktop app's, an amber notice at the bottom of the sidebar says so (in the narrow sidebar, an amber icon; point at it to read it):