diff --git a/README.md b/README.md index 9ba08ed..be01f59 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The mobile app now uses **Expo, React Native, and TypeScript**. This is a perman ## What the app does -- Pair by Desktop QR or pasted pairing payload, with a matching code and explicit Desktop approval. +- Pair by Desktop QR, an opened pairing link, or pasted pairing payload, with a matching code and explicit Desktop approval. - Save, switch, add, and remove computers; revoke the phone credential when Desktop is reachable. - Search and page through chats, filter projects, create chats, and continue existing conversations. - Read streamed replies, Markdown/code and tool activity; copy text, preserve reading position, and jump to the latest reply. @@ -24,10 +24,16 @@ The selected [conversation design](docs/design/assets/selected-conversation.png) The only real connection path is: ```text -Native mobile app → hosted OpenTunnel → existing Desktop connector → existing Desktop sidecar +Native mobile app → tunnel.bharatcode.ai → existing Desktop connector → existing Desktop sidecar ``` -There is no separate mobile agent, account, BC-managed relay, LAN mode, or manual hostname field. Use the Desktop integration described in [desktop-integration/README.md](desktop-integration/README.md). The older published Desktop beta alone does not establish mobile support. The integration targets Desktop source `1e97842919d87a350b38ea5fcf55f952af072e2e`. +The existing OpenTunnel client uses BharatCode’s hosted tunnel service. There is no separate mobile agent, account requirement, LAN mode, or manual hostname field. Use the Desktop integration described in [desktop-integration/README.md](desktop-integration/README.md). The older published Desktop beta alone does not establish mobile support. The integration targets Desktop source `695c2d956e6bc6f27b881a1cdbe0a7acd0764bc3`. + +Pairing links contain only the Desktop identity and HTTPS tunnel origin. Open **Settings → Mobile devices → Connect phone** on Desktop, scan/open the link on your phone, then approve only if the six-digit confirmation matches on both screens. Links work only during the five-minute pairing window; possessing a link never grants API access. Existing saved upstream `*.opentunnel.xyz` connections require fresh pairing with this integration. + +First-time HTTPS certificate issuance can take a few minutes. Desktop shows progress and allows cancellation; later connections reuse the persisted identity and certificate. Turning off remote access disconnects the tunnel and prevents automatic startup. Revoke individual phones separately, or revoke all phones and reset the tunnel when retiring its identity. + +See [the application trust boundary](docs/security/tunnel-trust-boundary.md) for authentication, credential storage, and the loopback proxy boundary. The transport’s TLS terminates inside Desktop; the relay carries encrypted application traffic. ## Desktop connector compatibility @@ -72,7 +78,7 @@ Web always uses an in-memory sample client and never initializes native credenti ## Source and native verification -See the [verification record](docs/verification/expo-mobile.md) for source gates, the node-forge security backport, and platform limits; the [Android release QA report](docs/verification/android-release-qa-2026-10-24.md) for the emulator qualification; and the [performance audit](docs/verification/performance-2026-10-24.md) for source-level measurements. Final source gates passed 49 mobile tests, 15 security regressions, TypeScript and lint, 35 connector tests, and five helper tests. Full and production-only npm audits report no known advisories for the resolved tree; the local backport and audit scope are documented separately. Source tests remain distinct from native acceptance through hosted OpenTunnel. +See the [verification record](docs/verification/expo-mobile.md) for source gates, the node-forge security backport, and platform limits; the [Android release QA report](docs/verification/android-release-qa-2026-10-24.md) for the emulator qualification; and the [performance audit](docs/verification/performance-2026-10-24.md) for source-level measurements. For the self-hosted integration, [the current verification report](docs/verification/self-hosted-tunnel-2026-10-25.md) records 53 mobile tests, 15 security regressions, TypeScript and lint, 44 connector tests, and five helper tests. Full and production-only npm audits report no known advisories for the resolved tree; the local backport and audit scope are documented separately. Source tests remain distinct from native acceptance through hosted OpenTunnel. Run the Desktop connector and helper gates with: diff --git a/app/+native-intent.tsx b/app/+native-intent.tsx new file mode 100644 index 0000000..f79a3e9 --- /dev/null +++ b/app/+native-intent.tsx @@ -0,0 +1,26 @@ +import { parsePairingPayload } from "../src/connection/pairing-payload"; + +let delivery = 0; + +export function redirectSystemPath({ + path, + initial, +}: { + path: string; + initial: boolean; +}) { + // Expo Router synthesizes this root URL for a normal standalone cold launch. + if (initial && (path === "bharatcode://" || path === "bharatcode:///")) + return "/"; + if (!path.startsWith("bharatcode:")) return path; + let link = "invalid"; + try { + parsePairingPayload(path); + link = path; + } catch { + // Never copy a malformed URI's credentials or extra parameters into a route. + } + // Public links stay identical when Desktop refreshes its pairing window. + // Give each delivered intent its own identity so cancellation can be retried. + return `/pair?${new URLSearchParams({ link, delivery: String(++delivery) })}`; +} diff --git a/desktop-connector/package-lock.json b/desktop-connector/package-lock.json index e8958f1..4061797 100644 --- a/desktop-connector/package-lock.json +++ b/desktop-connector/package-lock.json @@ -12,6 +12,9 @@ "effect": "^4.0.0", "qrcode": "1.5.4" }, + "devDependencies": { + "pngjs": "^5.0.0" + }, "engines": { "node": ">=22" } diff --git a/desktop-connector/package.json b/desktop-connector/package.json index 04fc4df..11b3d1e 100644 --- a/desktop-connector/package.json +++ b/desktop-connector/package.json @@ -6,7 +6,12 @@ "engines": { "node": ">=22" }, - "exports": { ".": { "types": "./src/index.d.ts", "import": "./src/index.mjs" } }, + "exports": { + ".": { + "types": "./src/index.d.ts", + "import": "./src/index.mjs" + } + }, "scripts": { "test": "node --test", "check": "node --check src/gateway.mjs" @@ -15,5 +20,8 @@ "@opentunnel/client": "0.5.2", "effect": "^4.0.0", "qrcode": "1.5.4" + }, + "devDependencies": { + "pngjs": "^5.0.0" } } diff --git a/desktop-connector/src/desktop-access.mjs b/desktop-connector/src/desktop-access.mjs index ead64a8..d1fd02f 100644 --- a/desktop-connector/src/desktop-access.mjs +++ b/desktop-connector/src/desktop-access.mjs @@ -1,77 +1,262 @@ -import { join } from 'node:path' -import { mkdir, readFile, writeFile, rename } from 'node:fs/promises' -import QRCode from 'qrcode' -import { createGateway } from './gateway.mjs' -import { connectTunnel } from './tunnel.mjs' -import { openDevices } from './devices.mjs' +import { join } from "node:path" +import { mkdir, readFile, writeFile, rename } from "node:fs/promises" +import QRCode from "qrcode" +import { createGateway } from "./gateway.mjs" +import { connectTunnel, removeTunnel } from "./tunnel.mjs" +import { openDevices, resetDevices } from "./devices.mjs" export async function createMobileAccess(options) { - const root = join(options.userDataPath, 'mobile-access') - await mkdir(root, { recursive: true, mode: 0o700 }) - const storePath = join(root, 'devices.json') - const initial = await openDevices(storePath) - let gateway, tunnel, pairing, qrImage, errorKey, starting, epoch = 0 - let devices = initial.list(), requests = [] - let enabled = false - const flagPath = join(root, 'enabled.json') - const persist = async value => { await writeFile(flagPath + '.tmp', JSON.stringify({ enabled: value }), { mode: 0o600 }); await rename(flagPath + '.tmp', flagPath) } - const snapshot = () => ({ enabled, connecting: !!starting, devices: devices.map(d => ({ ...d })), requests: requests.map(r => ({ ...r })), pairing, qrImage, errorKey }) + const root = join(options.userDataPath, "mobile-access") + const storePath = join(root, "devices.json") + let gateway, + tunnel, + pairing, + qrImage, + pairingExpiresAt, + errorKey, + starting, + controller, + epoch = 0 + let devices = [], + requests = [], + cancellations = 0 + let enabled = false, + status = "off", + progress + const flagPath = join(root, "enabled.json") + const persist = async (value) => { + await mkdir(root, { recursive: true, mode: 0o700 }) + await writeFile(flagPath + ".tmp", JSON.stringify({ enabled: value }), { mode: 0o600 }) + await rename(flagPath + ".tmp", flagPath) + } + const snapshot = () => ({ + enabled, + connecting: !!starting, + status, + progress, + devices: (gateway ? gateway.devices() : devices).map((d) => ({ ...d })), + requests: requests.filter((r) => r.expiresAt > Date.now()).map((r) => ({ ...r })), + pairing: pairingExpiresAt > Date.now() ? pairing : undefined, + qrImage: pairingExpiresAt > Date.now() ? qrImage : undefined, + errorKey, + }) const emit = () => options.onChange?.(snapshot()) + // Optional mobile storage must never make Desktop's startup initializer fail. + async function loadDevices() { + try { + devices = (await openDevices(storePath)).list() + } catch (error) { + errorKey = "mobile.error.storage" + throw error + } + } async function enable() { if (enabled) return if (starting) return starting const token = ++epoch + controller = new AbortController() + status = "provisioning" + errorKey = undefined + progress = "creating-tunnel" + emit() starting = (async () => { - errorKey = undefined; emit() + await loadDevices() + controller.signal.throwIfAborted() const connection = options.getConnection() - if (!connection?.password) throw new Error('Protected local Desktop sidecar required') - gateway = await createGateway({ runtime: 'desktop', upstream: connection.url, username: connection.username ?? 'opencode', password: connection.password, storePath, onPairingRequest: claim => { requests.push(claim); emit() } }) + if (!connection?.password) throw new Error("Protected local Desktop sidecar required") + gateway = await createGateway({ + runtime: "desktop", + upstream: connection.url, + username: connection.username ?? "opencode", + password: connection.password, + storePath, + onPairingRequest: (claim) => { + requests.push(claim) + emit() + }, + }) try { + controller.signal.throwIfAborted() const target = await gateway.listen() - tunnel = await (options.tunnelFactory ?? connectTunnel)({ target, storePath: join(root, 'tunnel') }) - enabled = true; await persist(true) + tunnel = await (options.tunnelFactory ?? connectTunnel)({ + target, + storePath: join(root, "tunnel-bharatcode-ai"), + signal: controller.signal, + onProgress: (stage) => { + if (token === epoch) { + progress = stage + emit() + } + }, + }) + if (token !== epoch) { + await tunnel.close() + tunnel = undefined + return + } + enabled = true + status = "connected" + progress = undefined + await persist(true) void (async () => { - try { for await (const event of tunnel.events) { if (token !== epoch) break; errorKey = event.type === 'disconnected' || event.type === 'reconnecting' ? 'mobile.error.reconnecting' : undefined; emit() } } - catch { if (token === epoch) { errorKey = 'mobile.error.connection'; emit() } } + try { + for await (const event of tunnel.events) { + if (token !== epoch) break + if (["disconnected", "reconnecting", "connecting"].includes(event.type)) { + status = "reconnecting" + errorKey = "mobile.error.reconnecting" + } else if (event.type === "connected") { + status = "connected" + errorKey = undefined + } else if (event.type === "stopped") { + status = "error" + errorKey = "mobile.error.connection" + } + emit() + } + } catch { + if (token === epoch) { + status = "error" + errorKey = "mobile.error.connection" + emit() + } + } })() - } catch (error) { await gateway.close(); gateway = undefined; throw error } + } catch (error) { + await gateway?.close() + await tunnel?.close() + gateway = undefined + tunnel = undefined + enabled = false + throw error + } })() - try { await starting } catch (error) { errorKey = 'mobile.error.connection'; throw error } finally { starting = undefined; emit() } + try { + await starting + } catch (error) { + if (token === epoch) { + status = "error" + errorKey ??= "mobile.error.connection" + } + throw error + } finally { + starting = undefined + emit() + } } - async function disable() { + async function shutdown(disable) { ++epoch - if (starting) { try { await starting } catch {} } - await gateway?.close(); await tunnel?.close() - gateway = undefined; tunnel = undefined; enabled = false; pairing = undefined; qrImage = undefined; requests = [] - devices = (await openDevices(storePath)).list() - await persist(false); emit() + controller?.abort() + if (starting) { + try { + await starting + } catch {} + } + if (gateway) devices = gateway.devices() + await gateway?.close() + await tunnel?.close() + gateway = undefined + tunnel = undefined + enabled = false + status = "off" + progress = undefined + errorKey = undefined + pairing = undefined + qrImage = undefined + pairingExpiresAt = undefined + requests = [] + if (disable) await persist(false) + emit() } async function run(input) { - if (!input || typeof input.type !== 'string' || (input.id !== undefined && (typeof input.id !== 'string' || input.id.length > 100))) throw new Error('Invalid mobile action') - if (input.type === 'state') { - requests = requests.filter(r => r.expiresAt > Date.now()) - if (gateway) devices = gateway.devices() - return snapshot() - } - if (input.type === 'pair') { + if ( + !input || + typeof input.type !== "string" || + (input.id !== undefined && (typeof input.id !== "string" || input.id.length > 100)) + ) + throw new Error("Invalid mobile action") + if (input.type === "state") return snapshot() + if (input.type === "pair") { + if (status === "error" && enabled) await shutdown(false) await enable() - pairing = JSON.stringify(gateway.issuePairing(tunnel.origin)) - qrImage = await QRCode.toDataURL(pairing, { width: 300, margin: 2, errorCorrectionLevel: 'M' }) - } else if (input.type === 'disable') { await disable() } - else if (input.type === 'approve') { if (!gateway) throw new Error('Remote access disabled'); await gateway.approve(input.id); requests = requests.filter(r => r.claimID !== input.id) } - else if (input.type === 'decline') { gateway?.decline(input.id); requests = requests.filter(r => r.claimID !== input.id) } - else if (input.type === 'revoke') { + if (!enabled || !gateway || !tunnel) return snapshot() + const invitation = gateway.issuePairing(tunnel.origin) + requests = [] + pairingExpiresAt = invitation.expiresAt + pairing = + "bharatcode://pair?" + new URLSearchParams({ origin: invitation.origin, desktopID: invitation.desktopID }) + qrImage = await QRCode.toDataURL(pairing, { width: 300, margin: 4, errorCorrectionLevel: "M" }) + } else if (input.type === "disable") { + await shutdown(true) + } else if (input.type === "reset") { + await shutdown(true) + devices = (await resetDevices(storePath)).list() + await (options.removeTunnel ?? removeTunnel)({ storePath: join(root, "tunnel-bharatcode-ai") }) + } else if (input.type === "approve") { + if (!gateway) throw new Error("Remote access disabled") + await gateway.approve(input.id) + requests = [] + pairing = undefined + qrImage = undefined + } else if (input.type === "decline") { + gateway?.decline(input.id) + requests = requests.filter((r) => r.claimID !== input.id) + } else if (input.type === "revoke") { if (gateway) await gateway.revoke(input.id) - else { const registry = await openDevices(storePath); await registry.remove(input.id); devices = registry.list() } - } else { throw new Error('Unknown mobile action') } + else { + const registry = await openDevices(storePath) + await registry.remove(input.id) + devices = registry.list() + } + } else { + throw new Error("Unknown mobile action") + } if (gateway) devices = gateway.devices() - emit(); return snapshot() + emit() + return snapshot() } let actions = Promise.resolve() return { snapshot, - action(input) { const operation = actions.then(() => run(input)); actions = operation.catch(() => {}); return operation }, - async restore() { try { if (JSON.parse(await readFile(flagPath, 'utf8')).enabled === true) await enable() } catch (error) { if (error.code !== 'ENOENT') { errorKey = 'mobile.error.connection'; emit() } } }, - async close() { ++epoch; if (starting) { try { await starting } catch {} } await gateway?.close(); await tunnel?.close(); gateway = undefined; tunnel = undefined; enabled = false }, + action(input) { + // Status and cancellation must remain available during multi-minute issuance. + if (input?.type === "state") return Promise.resolve(snapshot()) + if (input?.type === "disable" || input?.type === "reset") { + cancellations++ + controller?.abort() + } + const generation = cancellations + const operation = actions.then(() => + input?.type === "pair" && generation !== cancellations ? snapshot() : run(input), + ) + actions = operation.catch(() => {}) + return operation + }, + restore() { + const generation = cancellations + const operation = actions.then(async () => { + if (generation !== cancellations) return + try { + await loadDevices() + const preference = JSON.parse(await readFile(flagPath, "utf8")) + if (generation !== cancellations) return + if (preference.enabled === true) await enable() + } catch (error) { + if (error.code !== "ENOENT") { + status = "error" + errorKey ??= "mobile.error.storage" + emit() + } + } + }) + actions = operation.catch(() => {}) + return operation + }, + async close() { + cancellations++ + controller?.abort() + await actions + await shutdown(false) + }, } } diff --git a/desktop-connector/src/devices.mjs b/desktop-connector/src/devices.mjs index 7166080..09625fe 100644 --- a/desktop-connector/src/devices.mjs +++ b/desktop-connector/src/devices.mjs @@ -1,30 +1,40 @@ -import { randomBytes, createHash, timingSafeEqual } from 'node:crypto' -import { mkdir, readFile, writeFile, rename } from 'node:fs/promises' -import { dirname } from 'node:path' +import { randomBytes, createHash, timingSafeEqual } from "node:crypto" +import { mkdir, readFile, writeFile, rename } from "node:fs/promises" +import { dirname } from "node:path" -export const secret = () => randomBytes(32).toString('base64url') -export const verifier = value => createHash('sha256').update(value).digest('hex') +export const secret = () => randomBytes(32).toString("base64url") +export const verifier = (value) => createHash("sha256").update(value).digest("hex") export function matches(value, expected) { - if (typeof value !== 'string' || typeof expected !== 'string') return false - const a = Buffer.from(verifier(value)), b = Buffer.from(expected) + if (typeof value !== "string" || typeof expected !== "string") return false + const a = Buffer.from(verifier(value)), + b = Buffer.from(expected) return a.length === b.length && timingSafeEqual(a, b) } export async function openDevices(path) { - if (!path) throw new Error('Private device store path required') + if (!path) throw new Error("Private device store path required") await mkdir(dirname(path), { recursive: true, mode: 0o700 }) let state try { - state = JSON.parse(await readFile(path, 'utf8')) - if (state.version !== 1 || typeof state.desktopID !== 'string' || !Array.isArray(state.devices) || state.devices.some(d => typeof d.id !== 'string' || !/^[a-f0-9]{64}$/.test(d.verifier))) throw new Error('Invalid device registry') + state = JSON.parse(await readFile(path, "utf8")) + if ( + !state || + typeof state !== "object" || + state.version !== 1 || + typeof state.desktopID !== "string" || + !Array.isArray(state.devices) || + state.devices.some((d) => !d || typeof d.id !== "string" || !/^[a-f0-9]{64}$/.test(d.verifier)) + ) + throw Object.assign(new Error("Invalid device registry"), { code: "INVALID_DEVICE_REGISTRY" }) } catch (error) { - if (error.code !== 'ENOENT') throw error - state = { version: 1, desktopID: 'pc_' + secret().slice(0, 20), devices: [] } + if (error instanceof SyntaxError) error.code = "INVALID_DEVICE_REGISTRY" + if (error.code !== "ENOENT") throw error + state = { version: 1, desktopID: "pc_" + secret().slice(0, 20), devices: [] } } let writes = Promise.resolve() function save(next) { const operation = writes.then(async () => { - const temporary = path + '.' + secret().slice(0, 8) + const temporary = path + "." + secret().slice(0, 8) await writeFile(temporary, JSON.stringify(next), { mode: 0o600 }) await rename(temporary, path) state = next @@ -34,17 +44,34 @@ export async function openDevices(path) { } // Mutations build their next state only after preceding writes have settled. let mutations = Promise.resolve() - const mutate = fn => { + const mutate = (fn) => { const operation = mutations.then(() => save(fn(state))) mutations = operation.catch(() => {}) return operation } await save(state) return { - get desktopID() { return state.desktopID }, + get desktopID() { + return state.desktopID + }, list: () => state.devices.map(({ verifier: _, ...device }) => ({ ...device })), - authenticate: token => state.devices.find(device => matches(token, device.verifier)), - add: device => mutate(current => ({ ...current, devices: [...current.devices, device] })), - remove: id => mutate(current => ({ ...current, devices: current.devices.filter(d => d.id !== id) })), + authenticate: (token) => state.devices.find((device) => matches(token, device.verifier)), + add: (device) => mutate((current) => ({ ...current, devices: [...current.devices, device] })), + remove: (id) => mutate((current) => ({ ...current, devices: current.devices.filter((d) => d.id !== id) })), } } + +// Only an explicit revoke-all/reset may replace corrupt state. Keep its bytes +// privately for recovery; storage permission and I/O errors must still fail closed. +export async function resetDevices(path) { + let registry + try { + registry = await openDevices(path) + } catch (error) { + if (error.code !== "INVALID_DEVICE_REGISTRY") throw error + await rename(path, path + ".corrupt-" + secret()) + registry = await openDevices(path) + } + for (const device of registry.list()) await registry.remove(device.id) + return registry +} diff --git a/desktop-connector/src/files.mjs b/desktop-connector/src/files.mjs index 4a48174..dc05740 100644 --- a/desktop-connector/src/files.mjs +++ b/desktop-connector/src/files.mjs @@ -1,27 +1,50 @@ -import { realpath, readdir, open } from 'node:fs/promises' -import { resolve, relative, isAbsolute, sep } from 'node:path' +import { realpath, readdir, open, stat } from "node:fs/promises" +import { resolve, relative, isAbsolute, sep } from "node:path" export async function projectFile(directory, path) { - if (typeof path !== 'string' || path.includes('\0') || path.includes('\\') || isAbsolute(path) || path.split('/').includes('..')) throw Object.assign(new Error('Invalid project path'), { status: 400 }) + if ( + typeof path !== "string" || + path.includes("\0") || + path.includes("\\") || + isAbsolute(path) || + path.split("/").includes("..") + ) + throw Object.assign(new Error("Invalid project path"), { status: 400 }) const root = await realpath(directory) const target = await realpath(resolve(root, path)) const rel = relative(root, target) - if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) throw Object.assign(new Error('Path is outside the project'), { status: 403 }) - const file = await open(target, 'r') + if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) + throw Object.assign(new Error("Path is outside the project"), { status: 403 }) + // Windows cannot open directory handles with fs.open; enumerate directories + // before opening a regular file, whose size/type are still checked on its handle. + if ((await stat(target)).isDirectory()) { + const entries = await readdir(target, { withFileTypes: true }) + return { + type: "directory", + path, + truncated: entries.length > 2000, + entries: entries + .slice(0, 2000) + .map((entry) => ({ name: entry.name, directory: entry.isDirectory(), symlink: entry.isSymbolicLink() })) + .sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name)), + } + } + const file = await open(target, "r") try { const stat = await file.stat() - if (stat.isDirectory()) { - const entries = await readdir(target, { withFileTypes: true }) - return { type: 'directory', path, truncated: entries.length > 2000, entries: entries.slice(0, 2000).map(entry => ({ name: entry.name, directory: entry.isDirectory(), symlink: entry.isSymbolicLink() })).sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name)) } - } - if (!stat.isFile()) throw Object.assign(new Error('Unsupported file type'), { status: 400 }) - if (stat.size > 262144) return { type: 'file', path, size: stat.size, tooLarge: true } + if (!stat.isFile()) throw Object.assign(new Error("Unsupported file type"), { status: 400 }) + if (stat.size > 262144) return { type: "file", path, size: stat.size, tooLarge: true } const bytes = Buffer.alloc(262145) const { bytesRead } = await file.read(bytes, 0, bytes.length, 0) - if (bytesRead > 262144) return { type: 'file', path, tooLarge: true } + if (bytesRead > 262144) return { type: "file", path, tooLarge: true } const content = bytes.subarray(0, bytesRead) - if (content.includes(0)) return { type: 'file', path, binary: true } - try { return { type: 'file', path, text: new TextDecoder('utf-8', { fatal: true }).decode(content) } } - catch { return { type: 'file', path, binary: true } } - } finally { await file.close() } + if (content.includes(0)) return { type: "file", path, binary: true } + try { + return { type: "file", path, text: new TextDecoder("utf-8", { fatal: true }).decode(content) } + } catch { + return { type: "file", path, binary: true } + } + } finally { + await file.close() + } } diff --git a/desktop-connector/src/gateway.mjs b/desktop-connector/src/gateway.mjs index 03952d3..4a60224 100644 --- a/desktop-connector/src/gateway.mjs +++ b/desktop-connector/src/gateway.mjs @@ -19,10 +19,11 @@ export async function createGateway(options) { const now = options.now ?? Date.now const claims = new Map(), active = new Map() const desktopAPI = options.runtime === 'desktop' ? await createDesktopAPI({ request: upstreamCall, storePath: join(dirname(options.storePath), 'admissions.json') }) : undefined - let qr, closed = false + let qr, closed = false, attempts = [] const metadata = () => ({ id: registry.desktopID, name: options.name ?? hostname(), platform: platform() }) function issuePairing(origin) { if (closed) throw new Error('Gateway closed') + claims.clear() qr = { v: 1, desktopID: registry.desktopID, origin: tunnelOrigin(origin), code: secret(), expiresAt: now() + lifetime } return { ...qr } } @@ -41,6 +42,8 @@ export async function createGateway(options) { const token = secret() const device = { id: 'dev_' + secret().slice(0, 20), name: claim.name, pairedAt: now(), verifier: verifier(token) } try { await registry.add(device) } catch (error) { claim.state = 'waiting'; throw error } + qr = undefined + for (const other of claims.values()) if (other !== claim && other.state === 'waiting') other.state = 'declined' claim.state = 'approved' claim.token = token claim.device = { id: device.id, name: device.name, pairedAt: device.pairedAt } @@ -59,11 +62,14 @@ export async function createGateway(options) { if (req.headers.origin) throw status(403, 'Browser-origin requests are not allowed') if (req.method === 'POST' && req.url === '/mobile/v1/claim') { const body = await jsonBody(req) - if (!qr || qr.expiresAt <= now() || !matches(body.code, verifier(qr.code))) throw status(401, 'Pairing code expired or used') + if (!qr || qr.expiresAt <= now() || (body.code !== undefined ? !matches(body.code, verifier(qr.code)) : body.desktopID !== registry.desktopID)) throw status(401, 'Pairing code expired or used') if (typeof body.deviceName !== 'string' || !body.deviceName.trim() || body.deviceName.length > 80 || /[\x00-\x1f\x7f]/.test(body.deviceName)) throw status(400, 'Invalid device name') for (const [id, claim] of claims) if (claim.expiresAt <= now()) claims.delete(id) - if (claims.size >= 100) throw status(429, 'Too many pairing attempts') - qr = undefined + attempts = attempts.filter(time => now() - time < 60_000) + if (claims.size >= 5 || attempts.length >= 10) throw status(429, 'Too many pairing attempts; refresh on Desktop') + attempts.push(now()) + // A public link is discovery only. Access is issued solely by Desktop approval. + if (body.code !== undefined) qr = undefined const claimID = secret(), claimSecret = secret(), confirmation = String(randomInt(1_000_000)).padStart(6, '0') const claim = { claimID, verifier: verifier(claimSecret), name: body.deviceName.trim(), confirmation, expiresAt: now() + lifetime, state: 'waiting' } claims.set(claimID, claim) diff --git a/desktop-connector/src/index.d.ts b/desktop-connector/src/index.d.ts index 9393457..1bd1b4c 100644 --- a/desktop-connector/src/index.d.ts +++ b/desktop-connector/src/index.d.ts @@ -1,6 +1,6 @@ export type MobileDevice = { id: string; name: string; pairedAt: number } export type MobileClaim = { claimID: string; name: string; confirmation: string; expiresAt: number } -export type MobileState = { enabled: boolean; connecting: boolean; devices: MobileDevice[]; requests: MobileClaim[]; pairing?: string; qrImage?: string; errorKey?: string } -export type MobileAction = { type: 'state' | 'pair' | 'disable' | 'approve' | 'decline' | 'revoke'; id?: string } +export type MobileState = { enabled: boolean; connecting: boolean; status: "off" | "provisioning" | "connected" | "reconnecting" | "error"; progress?: string; devices: MobileDevice[]; requests: MobileClaim[]; pairing?: string; qrImage?: string; errorKey?: string } +export type MobileAction = { type: 'state' | 'pair' | 'disable' | 'reset' | 'approve' | 'decline' | 'revoke'; id?: string } export type MobileAccess = { snapshot(): MobileState; action(input: MobileAction): Promise; restore(): Promise; close(): Promise } export function createMobileAccess(options: { userDataPath: string; getConnection(): { url: string; username?: string | null; password: string } | undefined; onChange?(state: MobileState): void }): Promise diff --git a/desktop-connector/src/policy.mjs b/desktop-connector/src/policy.mjs index ef116b6..19c036b 100644 --- a/desktop-connector/src/policy.mjs +++ b/desktop-connector/src/policy.mjs @@ -1,6 +1,6 @@ export function tunnelOrigin(value) { + if (typeof value !== 'string' || !/^https:\/\/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+tunnel\.bharatcode\.ai(?::443)?\/?$/i.test(value)) throw new Error('An HTTPS BharatCode tunnel origin is required') const url = new URL(value) - if (url.protocol !== 'https:' || !url.hostname.endsWith('.opentunnel.xyz') || url.hostname === 'opentunnel.xyz' || url.port || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('An HTTPS OpenTunnel origin is required') return url.origin } diff --git a/desktop-connector/src/tunnel.mjs b/desktop-connector/src/tunnel.mjs index 8081d1c..474637a 100644 --- a/desktop-connector/src/tunnel.mjs +++ b/desktop-connector/src/tunnel.mjs @@ -1,15 +1,67 @@ -import { create, OpenTunnelStorage } from '@opentunnel/client' -import { mkdir } from 'node:fs/promises' +import { OpenTunnelClient, OpenTunnelApiClient, OpenTunnelStorage } from '@opentunnel/client/effect' +import { Effect, Exit, ManagedRuntime, Scope, Stream } from 'effect' +import { mkdir, chmod } from 'node:fs/promises' import { tunnelOrigin } from './policy.mjs' -// Only called after the user enables remote access in Desktop. -export async function connectTunnel({ target, storePath, signal }) { +export const tunnelAPI = 'https://tunnel.bharatcode.ai' +const profile = 'bharatcode-mobile' + +async function runtimeFor(storePath) { + await mkdir(storePath, { recursive: true, mode: 0o700 }) + await chmod(storePath, 0o700) + return ManagedRuntime.make(OpenTunnelClient.layer({ + api: tunnelAPI, + storage: OpenTunnelStorage.xdg({ env: { ...process.env, XDG_DATA_HOME: storePath } }), + })) +} + +// Use the SDK's cancellable Effect interface: certificate issuance may take minutes. +// Only the authenticated gateway is routed; never expose the Desktop sidecar itself. +export async function connectTunnel({ target, storePath, signal, onProgress }) { const url = new URL(target) - if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port) throw new Error('Gateway must listen on loopback') + if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('Gateway must listen on loopback') + signal?.throwIfAborted() + const runtime = await runtimeFor(storePath) + const scope = await runtime.runPromise(Scope.make()) + let connection, closing + const run = operation => runtime.runPromise(Effect.flatMap(OpenTunnelClient, operation), { signal }) + const close = () => closing ??= (async () => { + try { if (connection) await runtime.runPromise(connection.close) } + finally { + try { await runtime.runPromise(Scope.close(scope, Exit.void)) } + finally { await runtime.dispose() } + } + })() + try { + const existing = await run(client => client.tunnel.get({ profile })) + if (!existing) { + const pending = await run(client => client.tunnel.pending({ profile })) + await run(client => pending ? client.tunnel.resume({ profile, onProgress }) : client.tunnel.create({ profile, onProgress })) + } + onProgress?.('connecting') + connection = await run(client => Effect.provideService(client.tunnel.connect({ profile, routes: { '@': `${url.hostname}:${url.port}` } }), Scope.Scope, scope)) + signal?.throwIfAborted() + const origin = tunnelOrigin(`https://${connection.tunnel.hostname}`) + const abort = () => { void close().catch(() => {}) } + signal?.addEventListener('abort', abort, { once: true }) + return { origin, events: Stream.toAsyncIterable(connection.events), close: async () => { signal?.removeEventListener('abort', abort); await close() } } + } catch (error) { await close(); throw error } +} + +export async function removeTunnel({ storePath }) { await mkdir(storePath, { recursive: true, mode: 0o700 }) - const client = create({ store: OpenTunnelStorage.xdg({ env: { ...process.env, XDG_DATA_HOME: storePath } }) }) + await chmod(storePath, 0o700) + const storage = OpenTunnelStorage.xdg({ env: { ...process.env, XDG_DATA_HOME: storePath } }) + const runtime = ManagedRuntime.make(OpenTunnelApiClient.layer({ api: tunnelAPI })) try { - const connection = await client.tunnel.connect({ profile: 'bharatcode-mobile', routes: { '@': `${url.hostname}:${url.port}` }, signal }) - return { origin: tunnelOrigin(`https://${connection.tunnel.hostname}`), events: connection.events, close: async () => { await connection.close(); await client.dispose() } } - } catch (error) { await client.dispose(); throw error } + await runtime.runPromise(Effect.gen(function* () { + const identity = (yield* storage.load(profile)) ?? (yield* storage.loadPending(profile)) + if (!identity) return + const api = yield* OpenTunnelApiClient + const authorized = yield* api.authorized(identity.token) + // Deletion requires the tunnel token, never a ready certificate. + yield* authorized.tunnel['tunnel.remove']({ params: { id: identity.id } }) + yield* storage.remove(profile) + }), { signal: AbortSignal.timeout(30_000) }) + } finally { await runtime.dispose() } } diff --git a/desktop-connector/test/desktop-access.test.mjs b/desktop-connector/test/desktop-access.test.mjs index c6be077..be9a848 100644 --- a/desktop-connector/test/desktop-access.test.mjs +++ b/desktop-connector/test/desktop-access.test.mjs @@ -1,39 +1,440 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { mkdtemp, rm } from 'node:fs/promises' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { createMobileAccess } from '../src/desktop-access.mjs' +import { test } from "node:test" +import assert from "node:assert/strict" +import { mkdtemp, rm, mkdir, writeFile, readFile, readdir } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { createMobileAccess } from "../src/desktop-access.mjs" +import QRCode from "qrcode" +import { PNG } from "pngjs" -test('Desktop starts no tunnel until enabled, and disabling closes both resources', async t => { - const directory = await mkdtemp(join(tmpdir(), 'bc-access-')) +test("pairing QR preserves a four-module clear border for physical scanners", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-qr-margin-")) t.after(() => rm(directory, { recursive: true, force: true })) - let connects = 0, closes = 0 const access = await createMobileAccess({ userDataPath: directory, - getConnection: () => ({ url: 'http://127.0.0.1:4096', username: 'opencode', password: 'private' }), - tunnelFactory: async () => { connects++; return { origin: 'https://unit.opentunnel.xyz', close: async () => { closes++ }, events: (async function* () {})() } }, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + password: "private", + }), + tunnelFactory: async () => ({ + origin: "https://pc.tunnel.bharatcode.ai", + events: (async function* () {})(), + close: async () => {}, + }), + }) + t.after(() => access.close()) + await access.action({ type: "pair" }) + const state = access.snapshot() + const image = PNG.sync.read(Buffer.from(state.qrImage.split(",")[1], "base64")) + let left = image.width, + top = image.height, + right = -1, + bottom = -1 + for (let y = 0; y < image.height; y++) { + for (let x = 0; x < image.width; x++) { + const offset = (y * image.width + x) * 4 + if (image.data[offset] > 127 || image.data[offset + 3] === 0) continue + left = Math.min(left, x) + right = Math.max(right, x) + top = Math.min(top, y) + bottom = Math.max(bottom, y) + } + } + assert.ok(right > left, "QR must contain a visible symbol") + const modules = QRCode.create(state.pairing, { errorCorrectionLevel: "M" }).modules.size + const modulePixels = Math.floor(Math.min(right - left + 1, bottom - top + 1) / modules) + const clearBorder = Math.min(left, top, image.width - 1 - right, image.height - 1 - bottom) + assert.ok(modulePixels > 0) + assert.ok( + clearBorder >= modulePixels * 4, + `clear border ${clearBorder}px is smaller than four ${modulePixels}px modules`, + ) +}) + +test("reset during restoration cannot start a new connection after cancellation", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-restore-cancel-")) + t.after(() => rm(directory, { recursive: true, force: true })) + await mkdir(join(directory, "mobile-access")) + await writeFile(join(directory, "mobile-access", "enabled.json"), '{"enabled":true}') + let connections = 0 + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => { + connections++ + throw new Error("cancelled restore reached the sidecar") + }, + removeTunnel: async () => {}, + }) + t.after(() => access.close()) + const restoring = access.restore() + await Promise.resolve() + const resetting = access.action({ type: "reset" }) + await restoring + await resetting + assert.equal(connections, 0) + assert.equal(access.snapshot().enabled, false) +}) + +test("malformed optional mobile state cannot reject Desktop initialization or overwrite the registry", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-corrupt-")) + t.after(() => rm(directory, { recursive: true, force: true })) + await mkdir(join(directory, "mobile-access")) + const path = join(directory, "mobile-access", "devices.json") + const damaged = '{"version":1,"desktopID":' + await writeFile(path, damaged) + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => undefined, + }) + await access.restore() + assert.equal(access.snapshot().enabled, false) + assert.equal(access.snapshot().status, "error") + assert.equal(access.snapshot().errorKey, "mobile.error.storage") + await assert.rejects(access.action({ type: "pair" })) + await access.close() + assert.equal(await readFile(path, "utf8"), damaged) +}) + +test("unavailable optional mobile directory does not reject initialization or close", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-unavailable-")) + t.after(() => rm(directory, { recursive: true, force: true })) + await writeFile(join(directory, "mobile-access"), "keep this file") + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => undefined, + }) + await access.restore() + assert.equal(access.snapshot().status, "error") + assert.equal(access.snapshot().errorKey, "mobile.error.storage") + await access.close() + assert.equal(await readFile(join(directory, "mobile-access"), "utf8"), "keep this file") +}) + +test("reset cancels pending issuance before joining the action queue", { timeout: 3000 }, async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-reset-")) + t.after(() => rm(directory, { recursive: true, force: true })) + let begin + const ready = new Promise((resolve) => { + begin = resolve + }) + let aborted = false, + removed = false + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + password: "private", + }), + tunnelFactory: async ({ signal }) => { + begin() + await new Promise((_, reject) => + signal.addEventListener( + "abort", + () => { + aborted = true + reject(new Error("cancelled")) + }, + { once: true }, + ), + ) + }, + removeTunnel: async () => { + removed = true + }, + }) + t.after(() => access.close()) + const pairing = access.action({ type: "pair" }).catch(() => {}) + await ready + const reset = access.action({ type: "reset" }) + try { + await Promise.race([ + reset, + new Promise((_, reject) => setTimeout(() => reject(new Error("reset did not cancel issuance")), 500)), + ]) + assert.equal(aborted, true) + assert.equal(removed, true) + assert.equal(access.snapshot().enabled, false) + assert.equal(access.snapshot().status, "off") + } finally { + await access.action({ type: "disable" }) + await pairing + await reset + } +}) + +test("reset cancels a queued pair before it starts provisioning", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-queued-")) + t.after(() => rm(directory, { recursive: true, force: true })) + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => { + throw new Error("cancelled pairing must not reach the sidecar") + }, + removeTunnel: async () => {}, + }) + t.after(() => access.close()) + const pair = access.action({ type: "pair" }) + const reset = access.action({ type: "reset" }) + await pair + await reset + assert.equal(access.snapshot().status, "off") +}) + +test("failure to persist enabled state closes the new tunnel and leaves access disabled", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-write-")) + t.after(() => rm(directory, { recursive: true, force: true })) + await mkdir(join(directory, "mobile-access", "enabled.json"), { + recursive: true, + }) + let closed = false + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + password: "private", + }), + tunnelFactory: async () => ({ + origin: "https://pc.tunnel.bharatcode.ai", + events: (async function* () {})(), + close: async () => { + closed = true + }, + }), + }) + t.after(() => access.close()) + await assert.rejects(access.action({ type: "pair" })) + assert.equal(access.snapshot().enabled, false) + assert.equal(closed, true) +}) + +test("explicit reset preserves a damaged registry backup and allows fresh pairing", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-repair-")) + t.after(() => rm(directory, { recursive: true, force: true })) + const root = join(directory, "mobile-access") + await mkdir(root) + const damaged = '{"version":1,"desktopID":' + await writeFile(join(root, "devices.json"), damaged) + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + password: "private", + }), + removeTunnel: async () => {}, + tunnelFactory: async () => ({ + origin: "https://pc.tunnel.bharatcode.ai", + events: (async function* () {})(), + close: async () => {}, + }), + }) + t.after(() => access.close()) + await access.restore() + await access.action({ type: "reset" }) + const backups = (await readdir(root)).filter((name) => name.startsWith("devices.json.corrupt-")) + assert.equal(backups.length, 1) + assert.equal(await readFile(join(root, backups[0]), "utf8"), damaged) + await access.action({ type: "pair" }) + assert.equal(access.snapshot().status, "connected") + assert.deepEqual(access.snapshot().devices, []) +}) + +test("Desktop starts no tunnel until enabled, and disabling closes both resources", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-")) + t.after(() => rm(directory, { recursive: true, force: true })) + let connects = 0, + closes = 0 + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + username: "opencode", + password: "private", + }), + tunnelFactory: async () => { + connects++ + return { + origin: "https://unit.tunnel.bharatcode.ai", + close: async () => { + closes++ + }, + events: (async function* () {})(), + } + }, }) t.after(() => access.close()) await access.restore() assert.equal(access.snapshot().errorKey, undefined) assert.equal(connects, 0) - await access.action({ type: 'pair' }) + await access.action({ type: "pair" }) assert.equal(connects, 1) - assert.ok(access.snapshot().qrImage.startsWith('data:image/png;base64,')) - assert.equal(JSON.parse(access.snapshot().pairing).v, 1) - await access.action({ type: 'disable' }) + assert.ok(access.snapshot().qrImage.startsWith("data:image/png;base64,")) + assert.equal(new URL(access.snapshot().pairing).hostname, "pair") + await access.action({ type: "disable" }) assert.equal(closes, 1) assert.equal(access.snapshot().enabled, false) assert.equal(access.snapshot().pairing, undefined) - await assert.rejects(access.action({ type: 'approve', id: 'unknown' })) + await assert.rejects(access.action({ type: "approve", id: "unknown" })) }) -test('Desktop refuses a remote or unprotected sidecar without creating a tunnel', async t => { - const directory = await mkdtemp(join(tmpdir(), 'bc-access-')) +test("Desktop refuses a remote or unprotected sidecar without creating a tunnel", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-")) t.after(() => rm(directory, { recursive: true, force: true })) - const access = await createMobileAccess({ userDataPath: directory, getConnection: () => ({ url: 'https://other.example', password: '' }), tunnelFactory: () => { throw new Error('must not connect') } }) + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => ({ url: "https://other.example", password: "" }), + tunnelFactory: () => { + throw new Error("must not connect") + }, + }) t.after(() => access.close()) - await assert.rejects(access.action({ type: 'pair' })) + await assert.rejects(access.action({ type: "pair" })) assert.equal(access.snapshot().enabled, false) }) + +test( + "provisioning reports progress while status stays readable and disconnect cancels startup", + { timeout: 2000 }, + async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-")) + t.after(() => rm(directory, { recursive: true, force: true })) + let began + const ready = new Promise((resolve) => (began = resolve)) + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + password: "private", + }), + tunnelFactory: async ({ signal, onProgress }) => { + onProgress("waiting-certificate") + began() + await new Promise((_, reject) => + signal.addEventListener("abort", () => reject(new Error("cancelled")), { once: true }), + ) + }, + }) + t.after(() => access.close()) + const pairing = access.action({ type: "pair" }).catch(() => {}) + await ready + const snapshot = await access.action({ type: "state" }) + assert.equal(snapshot.status, "provisioning") + assert.equal(snapshot.progress, "waiting-certificate") + await access.action({ type: "disable" }) + await pairing + assert.equal(access.snapshot().status, "off") + }, +) + +test("Desktop emits credential-free links, restores enabled identity and clears revoked phones on reset", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-")) + t.after(() => rm(directory, { recursive: true, force: true })) + let connects = 0, + removed = 0 + const options = { + userDataPath: directory, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + password: "private", + }), + tunnelFactory: async () => { + connects++ + return { + origin: "https://pc.tunnel.bharatcode.ai", + events: (async function* () {})(), + close: async () => {}, + } + }, + removeTunnel: async () => { + removed++ + }, + } + const first = await createMobileAccess(options) + await first.action({ type: "pair" }) + const link = new URL(first.snapshot().pairing) + assert.equal(link.protocol, "bharatcode:") + assert.deepEqual([...link.searchParams.keys()].sort(), ["desktopID", "origin"]) + assert.equal(first.snapshot().status, "connected") + await first.close() + const second = await createMobileAccess(options) + t.after(() => second.close()) + await second.restore() + assert.equal(connects, 2) + assert.equal(second.snapshot().enabled, true) + await second.action({ type: "reset" }) + assert.equal(removed, 1) + assert.deepEqual(second.snapshot().devices, []) + assert.equal(second.snapshot().enabled, false) +}) + +test("Desktop status reflects a phone unpairing itself immediately", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-")) + t.after(() => rm(directory, { recursive: true, force: true })) + let target + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + password: "private", + }), + tunnelFactory: async (options) => { + target = options.target + return { + origin: "https://pc.tunnel.bharatcode.ai", + events: (async function* () {})(), + close: async () => {}, + } + }, + }) + t.after(() => access.close()) + await access.action({ type: "pair" }) + const link = new URL(access.snapshot().pairing) + const post = (path, body) => + fetch(target + path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) + const claim = await ( + await post("/mobile/v1/claim", { + desktopID: link.searchParams.get("desktopID"), + deviceName: "Phone", + }) + ).json() + await access.action({ type: "approve", id: claim.claimID }) + const paired = await (await post("/mobile/v1/claim/status", claim)).json() + assert.equal((await access.action({ type: "state" })).devices.length, 1) + assert.equal( + ( + await fetch(target + "/mobile/v1/device", { + method: "DELETE", + headers: { authorization: "Bearer " + paired.token }, + }) + ).status, + 200, + ) + assert.equal((await access.action({ type: "state" })).devices.length, 0) +}) + +test("self-hosted identities use a service-specific store, never the old upstream token", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "bc-access-")) + t.after(() => rm(directory, { recursive: true, force: true })) + let actual + const access = await createMobileAccess({ + userDataPath: directory, + getConnection: () => ({ + url: "http://127.0.0.1:4096", + password: "private", + }), + tunnelFactory: async ({ storePath }) => { + actual = storePath + return { + origin: "https://pc.tunnel.bharatcode.ai", + events: (async function* () {})(), + close: async () => {}, + } + }, + }) + t.after(() => access.close()) + await access.action({ type: "pair" }) + assert.equal(actual, join(directory, "mobile-access", "tunnel-bharatcode-ai")) +}) diff --git a/desktop-connector/test/gateway.test.mjs b/desktop-connector/test/gateway.test.mjs index 113b10b..196eab7 100644 --- a/desktop-connector/test/gateway.test.mjs +++ b/desktop-connector/test/gateway.test.mjs @@ -11,6 +11,7 @@ async function setup(t, options = {}) { let admittedAt = Infinity const upstream = createServer((req, res) => { seen.push({ url: req.url, method: req.method, headers: req.headers }) + res.setHeader('set-cookie', 'bc_test=sidecar-private; Domain=.bharatcode.ai; Secure; HttpOnly') if (options.asyncAdmission && req.url.startsWith('/session/ses_one/prompt_async?')) { req.resume() admittedAt = Date.now() + 250 @@ -75,7 +76,7 @@ async function setup(t, options = {}) { body: JSON.stringify(body), }) async function pair() { - const qr = gateway.issuePairing('https://api.unit.opentunnel.xyz') + const qr = gateway.issuePairing('https://api.unit.tunnel.bharatcode.ai') const response = await post('/mobile/v1/claim', { code: qr.code, deviceName: 'Test phone' }) assert.equal(response.status, 201) const claim = await response.json() @@ -88,7 +89,7 @@ async function setup(t, options = {}) { test('scan needs explicit desktop approval, and one-use code cannot be claimed twice', async t => { const c = await setup(t) - const qr = c.gateway.issuePairing('https://api.unit.opentunnel.xyz') + const qr = c.gateway.issuePairing('https://api.unit.tunnel.bharatcode.ai') const responses = await Promise.all([1, 2].map(() => c.post('/mobile/v1/claim', { code: qr.code, deviceName: 'Phone' }))) assert.deepEqual(responses.map(r => r.status).sort(), [201, 401]) const claim = await responses.find(r => r.status === 201).json() @@ -104,10 +105,10 @@ test('scan needs explicit desktop approval, and one-use code cannot be claimed t test('expired pairing codes and pending approvals cannot issue a credential', async t => { const c = await setup(t) - const qr = c.gateway.issuePairing('https://unit.opentunnel.xyz') + const qr = c.gateway.issuePairing('https://unit.tunnel.bharatcode.ai') c.expire() assert.equal((await c.post('/mobile/v1/claim', { code: qr.code, deviceName: 'Phone' })).status, 401) - const next = c.gateway.issuePairing('https://unit.opentunnel.xyz') + const next = c.gateway.issuePairing('https://unit.tunnel.bharatcode.ai') const claim = await (await c.post('/mobile/v1/claim', { code: next.code, deviceName: 'Phone' })).json() c.expire() await assert.rejects(c.gateway.approve(claim.claimID), /expired/) @@ -116,17 +117,17 @@ test('expired pairing codes and pending approvals cannot issue a credential', as test('only HTTPS OpenTunnel origins can be issued and issuing a new QR invalidates the old one', async t => { const c = await setup(t) - for (const url of ['http://unit.opentunnel.xyz', 'https://evil.example', 'https://unit.opentunnel.xyz/path', 'https://user:pass@unit.opentunnel.xyz']) { + for (const url of ['http://unit.tunnel.bharatcode.ai', 'https://evil.example', 'https://unit.tunnel.bharatcode.ai/path', 'https://user:pass@unit.tunnel.bharatcode.ai']) { assert.throws(() => c.gateway.issuePairing(url)) } - const old = c.gateway.issuePairing('https://unit.opentunnel.xyz') - c.gateway.issuePairing('https://unit.opentunnel.xyz') + const old = c.gateway.issuePairing('https://unit.tunnel.bharatcode.ai') + c.gateway.issuePairing('https://unit.tunnel.bharatcode.ai') assert.equal((await c.post('/mobile/v1/claim', { code: old.code, deviceName: 'Phone' })).status, 401) }) test('denied claim cannot be approved and a wrong claim secret cannot poll', async t => { const c = await setup(t) - const qr = c.gateway.issuePairing('https://unit.opentunnel.xyz') + const qr = c.gateway.issuePairing('https://unit.tunnel.bharatcode.ai') const claim = await (await c.post('/mobile/v1/claim', { code: qr.code, deviceName: 'Phone' })).json() assert.equal((await c.post('/mobile/v1/claim/status', { ...claim, claimSecret: 'wrong' })).status, 401) c.gateway.decline(claim.claimID) @@ -138,11 +139,13 @@ test('device credential proxies only allowed routes and never leaks master crede const c = await setup(t) assert.equal((await fetch(c.origin + '/api/session')).status, 401) const { token } = await c.pair() - const response = await fetch(c.origin + '/api/session?limit=30', { headers: { authorization: `Bearer ${token}`, 'x-forwarded-host': 'evil.example' } }) + const response = await fetch(c.origin + '/api/session?limit=30', { headers: { authorization: `Bearer ${token}`, 'x-forwarded-host': 'evil.example', cookie: 'bc_test=browser-private' } }) assert.equal(response.status, 200) assert.deepEqual(await response.json(), { data: [], cursor: {} }) assert.equal(c.seen[0].headers.authorization, 'Basic ' + Buffer.from('opencode:private-sidecar-secret').toString('base64')) assert.equal(c.seen[0].headers['x-forwarded-host'], undefined) + assert.equal(c.seen[0].headers.cookie, undefined) + assert.equal(response.headers.get('set-cookie'), null) const metadata = await (await fetch(c.origin + '/mobile/v1/health', { headers: { authorization: `Bearer ${token}` } })).text() assert.ok(!metadata.includes('private-sidecar-secret')) for (const path of ['/api/credential', '/api/config', '/api/integration', '/api/session/abc/permission', '/api/fs/file?path=/etc/passwd', '/api/session/abc%2F..%2Fcredential']) { @@ -293,3 +296,37 @@ test('Desktop accepts a real bodyless 204 async acknowledgement before delayed s assert.equal(response.status, 200) assert.equal(c.seen.filter(item => item.url.includes('/prompt_async')).length, 1) }) + +test('public link requires an active pairing window and explicit approval; localhost headers never authorize', async t => { + const c = await setup(t) + const qr = c.gateway.issuePairing('https://pc.tunnel.bharatcode.ai') + const response = await c.post('/mobile/v1/claim', { desktopID: qr.desktopID, deviceName: 'Link phone' }) + assert.equal(response.status, 201) + const claim = await response.json() + assert.equal((await (await c.post('/mobile/v1/claim/status', claim)).json()).state, 'waiting') + for (const headers of [{host:'localhost'}, {'x-forwarded-for':'127.0.0.1','x-forwarded-host':'localhost','forwarded':'for=127.0.0.1;host=localhost'}, {authorization:'Bearer ' + 'x'.repeat(43)}]) { + assert.equal((await fetch(c.origin + '/api/session', {headers})).status, 401) + assert.equal((await fetch(c.origin + '/mobile/v1/health', {headers})).status, 401) + } + assert.equal(c.seen.length, 0) + await c.gateway.approve(claim.claimID) + const paired = await (await c.post('/mobile/v1/claim/status', claim)).json() + assert.equal((await fetch(c.origin + '/mobile/v1/health', {headers:{authorization:'Bearer '+paired.token}})).status, 200) + assert.equal((await c.post('/mobile/v1/claim', {desktopID:qr.desktopID,deviceName:'Late phone'})).status, 401) + await c.gateway.revoke(paired.device.id) + assert.equal((await fetch(c.origin + '/mobile/v1/health', {headers:{authorization:'Bearer '+paired.token}})).status, 401) +}) + +test('public link claims are bounded, expire, and a refreshed invitation invalidates older pending claims', async t => { + const c = await setup(t) + const qr = c.gateway.issuePairing('https://pc.tunnel.bharatcode.ai') + const body = {desktopID:qr.desktopID,deviceName:'Phone'} + assert.equal((await c.post('/mobile/v1/claim', {...body, desktopID:'pc_wrong'})).status, 401) + const first = await (await c.post('/mobile/v1/claim', body)).json() + for(let n=1;n<5;n++) assert.equal((await c.post('/mobile/v1/claim', body)).status, 201) + assert.equal((await c.post('/mobile/v1/claim', body)).status, 429) + c.gateway.issuePairing('https://pc.tunnel.bharatcode.ai') + await assert.rejects(c.gateway.approve(first.claimID)) + c.expire() + assert.equal((await c.post('/mobile/v1/claim', body)).status, 401) +}) diff --git a/desktop-connector/test/policy.test.mjs b/desktop-connector/test/policy.test.mjs index e0666b9..705b9a7 100644 --- a/desktop-connector/test/policy.test.mjs +++ b/desktop-connector/test/policy.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { allowed } from '../src/policy.mjs' +import { allowed, tunnelOrigin } from '../src/policy.mjs' test('project directory query encoding is allowed without allowing encoded API path traversal', () => { for (const directory of ['/work/fixture project', 'C:\\Projects\\Mobile']) { @@ -14,3 +14,8 @@ test('project directory query encoding is allowed without allowing encoded API p assert.equal(allowed('GET', '/api/question/request?location%5Bdirectory%5D=%00'), false) assert.equal(allowed('GET', '/api/question/request?unexpected=%2Fwork'), false) }) + +test('only well-formed self-hosted HTTPS tenant origins are accepted', () => { + assert.equal(tunnelOrigin('https://pc.tunnel.bharatcode.ai:443/'), 'https://pc.tunnel.bharatcode.ai') + for (const value of ['https://pc.opentunnel.xyz', 'https://tunnel.bharatcode.ai', 'https://pc..tunnel.bharatcode.ai', 'https://-pc.tunnel.bharatcode.ai', 'https://pc.tunnel.bharatcode.ai/../', 'https://pc.tunnel.bharatcode.ai#', ' https://pc.tunnel.bharatcode.ai', 'https://pc.tunnel.bharatcode.ai\\@evil.com']) assert.throws(() => tunnelOrigin(value), value) +}) diff --git a/desktop-connector/test/tunnel.test.mjs b/desktop-connector/test/tunnel.test.mjs new file mode 100644 index 0000000..c547fe3 --- /dev/null +++ b/desktop-connector/test/tunnel.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Effect } from 'effect' +import { OpenTunnelStorage } from '@opentunnel/client/effect' +import { removeTunnel, connectTunnel } from '../src/tunnel.mjs' + +test('reset removes a pending identity through the explicit API without waiting for a certificate', {timeout:2000}, async t => { + const root = await mkdtemp(join(tmpdir(),'bc-tunnel-')) + t.after(()=>rm(root,{recursive:true,force:true})) + const storage=OpenTunnelStorage.xdg({env:{XDG_DATA_HOME:root}}) + await Effect.runPromise(storage.savePending('bharatcode-mobile',{id:'tun_pending',hostname:'pc.tunnel.bharatcode.ai',token:'pending-private-token',privateKey:'pending-private-key',csr:'pending-csr'})) + const requests=[] + // This is the SDK's external fetch boundary; never mock its storage/provisioning logic. + t.mock.method(globalThis,'fetch',async (input, init)=>{ + const request = input instanceof Request ? input : new Request(input, init) + requests.push({url:request.url,method:request.method,authorization:request.headers.get('authorization')}) + if(request.method==='DELETE') return new Response(null,{status:204}) + return new Response(JSON.stringify({id:'tun_pending',state:{type:'pending'}}),{headers:{'content-type':'application/json'}}) + }) + await removeTunnel({storePath:root}) + assert.deepEqual(requests,[{url:'https://tunnel.bharatcode.ai/api/tunnel/tun_pending',method:'DELETE',authorization:'Bearer pending-private-token'}]) + assert.equal(await Effect.runPromise(storage.loadPending('bharatcode-mobile')),undefined) + assert.equal((await stat(root)).mode & 0o777,0o700) +}) + +test('invalid targets and already-cancelled connections never create a tunnel', async t => { + const root=await mkdtemp(join(tmpdir(),'bc-tunnel-')) + t.after(()=>rm(root,{recursive:true,force:true})) + const intercepted=t.mock.method(globalThis,'fetch',()=>{throw new Error('must not contact API')}) + for(const target of ['http://example.com:1234','http://user:pass@127.0.0.1:1234','http://127.0.0.1:1234/path']) await assert.rejects(connectTunnel({target,storePath:root})) + await assert.rejects(connectTunnel({target:'http://127.0.0.1:1234',storePath:root,signal:AbortSignal.abort()})) + assert.equal(intercepted.mock.callCount(),0) +}) diff --git a/desktop-integration/README.md b/desktop-integration/README.md index 7969bb3..baf5b08 100644 --- a/desktop-integration/README.md +++ b/desktop-integration/README.md @@ -2,7 +2,7 @@ The connector uses the existing authenticated BharatCode Desktop sidecar. It does not start another agent or share the sidecar's master password with the phone. -The integration currently targets Desktop commit `1e97842919d87a350b38ea5fcf55f952af072e2e`. +The integration currently targets Desktop commit `695c2d956e6bc6f27b881a1cdbe0a7acd0764bc3`. ```sh python desktop-integration/apply.py /path/to/bharatcode-desktop @@ -10,13 +10,15 @@ cd /path/to/bharatcode-desktop bun install ``` -Run the integrated development build with its normal Desktop sidecar. This source pin embeds native `/api/session` metadata alongside its compatibility message API. The gateway follows the Desktop UI's compatibility behavior for message history, prompts, requests, configured models, and live events, so both clients use the same conversation and execution engine. Do not set `OPENCODE_SIDECAR_V2=1` for this pin: its separate CLI service branch references an unavailable command. +Run the integrated development build with its normal, authenticated Desktop sidecar. The connector uses its existing compatibility message API and project-scoped events; it does not select a different runtime, change the account/catalog policy, or start another agent. The matching Desktop PR contains the generated source integration directly; this script keeps the Mobile repository’s connector copy reproducible against the documented pin. -This adds the connector workspace package, a trusted main-frame IPC action, and **Settings → Mobile devices** in both the default Settings v2 screen and the older layout. The embedded Desktop server explicitly selects the BharatCode product catalog before loading its runtime, including in a development-branded build; the default upstream development catalog does not expose BharatCode models. The action can create a pairing QR, approve or decline a phone, revoke one phone, and disable remote access. It follows the Desktop preload boundary and uses typed localization keys. +This adds the connector workspace package, a trusted main-frame IPC action, and **Settings → Mobile devices** in both the default Settings v2 screen and the older layout. The embedded Desktop server explicitly selects the BharatCode product catalog before loading its runtime, including in a development-branded build; the default upstream development catalog does not expose BharatCode models. The action can create a credential-free pairing link/QR, approve or decline a phone, revoke one phone, disconnect remote access, and revoke all phones/reset the tunnel. It follows the Desktop preload boundary and uses typed localization keys. The script checks the source revision and each edit context. It can be run again to refresh the connector and new UI modules. Review the resulting Desktop diff before building. Runtime tests for the connector live in `desktop-connector/test`; the Desktop package's own tests and type checks should also pass. -Remote access starts only when the user chooses **Connect phone**. Once enabled, the same tunnel identity is restored at the next launch until remote access is turned off. Tunnel identity, device verifiers and the enablement preference are kept in the Desktop app's private data directory. The connector rejects browser-origin calls and exposes only its mobile API policy. +Remote access starts only when the user chooses **Connect phone**. The SDK explicitly selects `api: "https://tunnel.bharatcode.ai"` and exposes certificate issuance and reconnect status. Issuance is cancellable and may take a few minutes. Once enabled, the same tunnel identity is restored at the next launch until remote access is turned off. Tunnel identity, device verifiers and the enablement preference are kept in the Desktop app's private data directory. The connector rejects browser-origin calls and exposes only its mobile API policy. The tunnel routes this gateway, never the sidecar or Desktop management IPC. See [the trust boundary](../docs/security/tunnel-trust-boundary.md). + +The October 27 follow-up isolates mobile storage errors from Desktop startup, cancels provisioning before Reset joins the action queue, and preserves damaged registries privately on explicit reset. It connects the native local runtime; WSL workspaces are outside the current scope. The matching Desktop source includes focused settings interaction tests and a physical-device acceptance checklist in `docs/mobile-access-acceptance.md`. The already-published Desktop beta does not include this source integration. Installing that beta alone is not proof that mobile pairing is available. diff --git a/desktop-integration/apply.py b/desktop-integration/apply.py index 61dafc5..236d863 100644 --- a/desktop-integration/apply.py +++ b/desktop-integration/apply.py @@ -8,7 +8,7 @@ ROOT = Path(__file__).resolve().parent.parent TARGET = Path(sys.argv[1]).resolve() -PIN = "1e97842919d87a350b38ea5fcf55f952af072e2e" +PIN = "695c2d956e6bc6f27b881a1cdbe0a7acd0764bc3" if subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=TARGET, text=True).strip() != PIN: raise SystemExit("This integration requires the documented Desktop source revision.") @@ -25,6 +25,7 @@ def edit(path, old, new): package.mkdir(exist_ok=True) shutil.copytree(ROOT / "desktop-connector/src", package / "src", dirs_exist_ok=True) shutil.copyfile(ROOT / "desktop-connector/package.json", package / "package.json") +shutil.copytree(ROOT / "desktop-connector/test", package / "test", dirs_exist_ok=True) desktop = TARGET / "packages/desktop/package.json" value = json.loads(desktop.read_text()) value["dependencies"]["@bharatcode/mobile-connector"] = "workspace:*" @@ -51,7 +52,7 @@ def edit(path, old, new): edit("packages/desktop/src/main/index.ts", ' setSidecarAuthorization(connection)', ' mobileConnection = connection\n setSidecarAuthorization(connection)') edit("packages/desktop/src/main/index.ts", ' const windows = restoreMainWindows()', ' void Effect.runPromise(Deferred.await(serverReady)).then(() => mobileAccess.restore()).catch(() => {})\n const windows = restoreMainWindows()') edit("packages/desktop/electron.vite.config.ts", ' externalizeDeps: { include: [nodePtyPkg] },', ' externalizeDeps: {\n include: [nodePtyPkg],\n exclude: ["@bharatcode/mobile-connector", "effect"],\n },') -edit("packages/app/src/i18n/en.ts", 'export const dict = {', 'import { MOBILE_ENGLISH } from "./mobile"\n\nexport const dict = {\n ...MOBILE_ENGLISH,') +edit("packages/app/src/i18n/bharatcode.ts", 'export const BHARATCODE_ENGLISH = {', 'import { MOBILE_ENGLISH } from "./mobile"\n\nexport const BHARATCODE_ENGLISH = {\n ...MOBILE_ENGLISH,') edit("packages/app/src/components/dialog-settings.tsx", 'export const DialogSettings:', 'import { SettingsMobile } from "./settings-mobile"\n\nexport const DialogSettings:') edit("packages/app/src/components/dialog-settings.tsx", ' ', ' {language.t("mobile.title")}\n ') edit("packages/app/src/components/dialog-settings.tsx", ' \n ', ' \n \n \n {language.t("mobile.title")}\n \n \n ') edit("packages/app/src/components/settings-v2/dialog-settings-v2.tsx", ' \n \n \n \n \n ({ busy: false, failed: false }) + const [state, setState] = createStore<{ value?: MobileState; busy: boolean; failed: boolean }>({ + busy: false, + failed: false, + }) let alive = true let polling = false + let operations = 0 + let revision = 0 const refresh = async () => { - if (polling || state.busy || !alive) return + if (polling || !alive) return polling = true - try { const value = await platform.mobileAccess?.({ type: "state" }); if (alive) setState({ value, failed: false }) } - catch { if (alive) setState("failed", true) } - finally { polling = false } + const token = revision + try { + const value = await platform.mobileAccess?.({ type: "state" }) + if (alive && token === revision) setState({ value, failed: false }) + } catch { + if (alive && token === revision) setState("failed", true) + } finally { + polling = false + } } const action = async (input: MobileAction) => { - if (state.busy) return + if (state.busy && input.type !== "disable" && input.type !== "reset") return + operations++ + const token = ++revision setState({ busy: true, failed: false }) - try { const value = await platform.mobileAccess?.(input); if (alive) setState("value", value) } - catch { if (alive) setState("failed", true) } - finally { if (alive) setState("busy", false) } + try { + const value = await platform.mobileAccess?.(input) + if (alive && token === revision) setState({ value, failed: false }) + } catch { + if (alive && token === revision) setState("failed", true) + } finally { + operations-- + if (alive) setState("busy", operations > 0) + } } - onMount(() => { void refresh(); const timer = setInterval(() => void refresh(), 1500); onCleanup(() => clearInterval(timer)) }) - onCleanup(() => { alive = false }) - return
-

{language.t("mobile.title")}

{language.t("mobile.description")}

- -
- {(image) =>
{language.t("mobile.qr")}

{language.t("mobile.expiry")}

}
- {(request) =>
{request.name}{request.confirmation}

{language.t("mobile.confirm")}

}
-

{language.t("mobile.paired")}

{language.t("mobile.empty")}

}>{(device) =>
{device.name}
}
-

{language.t(state.value?.enabled ? "mobile.enabled" : "mobile.disabled")}

-
+ onMount(() => { + void refresh() + const timer = setInterval(() => void refresh(), 1500) + onCleanup(() => clearInterval(timer)) + }) + onCleanup(() => { + alive = false + }) + return ( +
+
+

{language.t("mobile.title")}

+

{language.t("mobile.description")}

+
+ + + +

+ {language.t( + state.value?.status === "provisioning" + ? "mobile.status.provisioning" + : state.value?.status === "connected" + ? "mobile.status.connected" + : state.value?.status === "reconnecting" + ? "mobile.status.reconnecting" + : state.value?.status === "error" + ? "mobile.status.error" + : "mobile.status.off", + )} +

+ +

+ {language.t( + state.value?.progress === "generating-key" || state.value?.progress === "generating-csr" + ? "mobile.progress.key" + : state.value?.progress === "saving-identity" || state.value?.progress === "ready" + ? "mobile.progress.saving" + : state.value?.progress === "connecting" + ? "mobile.progress.connecting" + : "mobile.progress.certificate", + )} +

+
+
+ + + + +
+ + {(image) => ( +
+ {language.t("mobile.qr")} +

{language.t("mobile.expiry")}

+ +
+ )} +
+ + {(request) => ( +
+ {request.name} + {request.confirmation} +

{language.t("mobile.confirm")}

+
+ + +
+
+ )} +
+
+

{language.t("mobile.paired")}

+ {language.t("mobile.empty")}

} + > + + {(device) => ( +
+ {device.name} + +
+ )} +
+
+
+ +

{language.t("mobile.scope")}

+

+ {language.t(state.value?.enabled ? "mobile.enabled" : "mobile.disabled")} +

+
+ ) } diff --git a/desktop-integration/test_apply.py b/desktop-integration/test_apply.py index cc60711..aebcfff 100644 --- a/desktop-integration/test_apply.py +++ b/desktop-integration/test_apply.py @@ -17,8 +17,10 @@ def setUpClass(cls): cls.temp = tempfile.TemporaryDirectory() cls.addClassCleanup(cls.temp.cleanup) cls.target = Path(cls.temp.name) / "desktop" - subprocess.run(["git", "clone", "--quiet", "--shared", str(SOURCE), str(cls.target)], + subprocess.run(["git", "clone", "--quiet", "--no-checkout", str(SOURCE), str(cls.target)], check=True, capture_output=True, text=True) + subprocess.run(["git", "checkout", "--quiet", "695c2d956e6bc6f27b881a1cdbe0a7acd0764bc3"], + cwd=cls.target, check=True, capture_output=True, text=True) for _ in range(2): subprocess.run([sys.executable, str(ROOT / "desktop-integration/apply.py"), str(cls.target)], check=True, capture_output=True, text=True) @@ -31,12 +33,17 @@ def test_active_v2_settings_exposes_mobile_navigation_and_panel(self): self.assertIn('', source) self.assertIn('when={platform.mobileAccess}', source) - def test_embedded_desktop_uses_bharatcode_catalog_in_dev_channel(self): - source = (self.target / "packages/desktop/src/main/sidecar.ts").read_text() - prepare = source.split('function prepareSidecarEnv(', 1)[1].split('\n}', 1)[0] - self.assertIn('BHARATCODE_PRODUCT_POLICY: "shipped"', prepare) - self.assertLess(source.index('prepareSidecarEnv(command.password'), - source.index('await import("virtual:opencode-server")')) + def test_tunnel_is_main_owned_and_ipc_is_trusted(self): + main = (self.target / "packages/desktop/src/main/index.ts").read_text() + self.assertIn('createMobileAccess({', main) + self.assertIn('await mobileAccess.close()', main) + ipc = (self.target / "packages/desktop/src/main/ipc.ts").read_text() + self.assertIn('ipcMain.handle("mobile-access", accountAction(', ipc) + self.assertIn('event.senderFrame !== event.sender.mainFrame', ipc) + self.assertIn('!isOwnedRenderer(event.sender.id)', ipc) + renderer = (self.target / "packages/desktop/src/renderer/index.tsx").read_text() + self.assertIn('window.api.mobileAccess(input)', renderer) + self.assertNotIn('@opentunnel/client', renderer) if __name__ == "__main__": diff --git a/docs/security/tunnel-trust-boundary.md b/docs/security/tunnel-trust-boundary.md new file mode 100644 index 0000000..69c0115 --- /dev/null +++ b/docs/security/tunnel-trust-boundary.md @@ -0,0 +1,29 @@ +# Desktop–Mobile tunnel trust boundary + +This integration uses the existing OpenTunnel SDK against `https://tunnel.bharatcode.ai`, reviewed against `bharatcode-tunnel` commit `fe7dfa73b35ebb035ba42a62482ec230a7e1a8bf`, particularly the [client SDK documentation](https://github.com/BharatCode-ai/bharatcode-tunnel/blob/fe7dfa73b35ebb035ba42a62482ec230a7e1a8bf/packages/client/README.md) and [route-target trust boundary](https://github.com/BharatCode-ai/bharatcode-tunnel/blob/fe7dfa73b35ebb035ba42a62482ec230a7e1a8bf/docs/trust-boundary.md). It requires no new account or API/Chat service change. + +## Transport and application authority + +The relay forwards encrypted application traffic. The SDK terminates tenant TLS in Desktop main and forwards decrypted bytes to a dedicated loopback mobile gateway. The gateway uses a separate private connection credential to access the already-running Desktop sidecar. The sidecar itself, local management endpoints, account credentials, provider secrets and Desktop IPC are never tunnel routes. + +A tunnel URL, loopback TCP peer, `Host: localhost`, or any `Forwarded`/`X-Forwarded-*` header grants no authority. The gateway authenticates each application request using a device bearer credential and only forwards its explicit route allowlist. It constructs upstream authorization itself and does not forward visitor cookies, credentials or forwarding headers. Configuration/credential/account endpoints and the global event stream are outside the mobile policy. Selected conversation events and sanitized model metadata are projected by the existing connector. + +Desktop management travels through preload IPC. Only an owned renderer's main frame can invoke mobile actions. The tunnel client runs in main. Mobile-access IPC does not return the sidecar password, tunnel token/private key, phone bearer token or pending claim secret. Desktop initialization returns the sidecar URL with null username/password fields. Main retains the local sidecar credential and adds authorization only to requests from an owned Desktop renderer for that exact sidecar origin; credentials are not carried through redirects. This initialization and authorization policy are not exposed to the phone or tunnel. Remote revocation cannot be performed through a public management endpoint; a phone can revoke its own credential. + +## Pairing and device access + +Desktop explicitly opens a five-minute pairing window. Its QR/link contains only a public Desktop identifier and HTTPS tenant origin. A link does not authenticate its holder; pending pairing requests can arrive from anyone who knows the address while that window is open. Desktop shows the phone name and random six-digit confirmation. The owner must compare the code on the phone before approving; phone names alone are untrusted. + +The phone posts a claim request over HTTPS. The response contains a fresh high-entropy claim ID and claim secret; polling uses a JSON POST body. Pending claims are bounded to five, with at most ten accepted attempts per minute per Desktop. Refreshing the QR invalidates pending claims; expiry and decline prevent approval. A successful approval closes the pairing window and declines competing waiting requests. The legacy JSON QR's one-use code remains supported, but that code is never encoded into the new link. + +Only explicit trusted Desktop approval creates a phone credential. It is delivered once in the HTTPS response body, stored in native SecureStore on Mobile, and represented only by its SHA-256 verifier in the Desktop registry. Subsequent requests and streaming use the authorization header. Credentials and claim secrets are not query parameters, cookies or diagnostic output. Native fetch explicitly omits cookies and refuses redirects. Both clients accept only well-formed HTTPS tenant origins below `tunnel.bharatcode.ai`, rejecting the control apex, upstream service, other domains, credentials, paths, query/fragment, unexpected ports and URL parser normalization tricks. Opened app links accept exactly `origin` and `desktopID` once each. + +Revocation removes the verifier and terminates that device's active requests/streams. Further requests receive HTTP 401 and require a new Desktop-approved pairing. Disabling remote access closes the gateway/tunnel, clears pending invitations, and persists the disabled preference. Previously authorized phones remain registered for deliberate re-enablement. Reset additionally revokes every phone and asks the tunnel service to remove the identity; private tunnel state is retained if server removal fails so removal can be retried. + +## Persistence and operations + +The tunnel token, TLS private key, certificate and pending issuance state stay in the private Desktop user-data tree, outside the renderer. Directories use mode 0700 and SDK secret files use mode 0600 on POSIX; native platform user-data permissions also apply. Mobile bearer credentials are separate from SQLite conversation metadata/drafts. Access tokens, private keys and claim secrets must not appear in support logs, screenshots or PR evidence. + +The SDK's cancellable Effect interface keeps status/cancel actions available while issuance waits for DNS/certificate readiness. Pending issuance is resumable. A service-specific `mobile-access/tunnel-bharatcode-ai` store prevents credentials from the old upstream service being sent to the new API. Later starts restore the same tunnel/certificate and phone registry until explicitly disabled or reset. Connectivity state is not proof of an authenticated application request. + +AWS, Route 53, Azure and ZeroSSL credentials belong solely to the hosted service. Neither app includes them. Applications continue normal HTTPS certificate validation. Source checks, real hosted tunnel tests, native/emulator tests and physical-device tests are reported as separate evidence tiers; see the dated verification report for actual coverage. diff --git a/docs/superpowers/plans/2026-10-25-self-hosted-tunnel.md b/docs/superpowers/plans/2026-10-25-self-hosted-tunnel.md new file mode 100644 index 0000000..432949b --- /dev/null +++ b/docs/superpowers/plans/2026-10-25-self-hosted-tunnel.md @@ -0,0 +1,72 @@ +# Self-hosted tunnel integration plan + +> **For agentic workers:** Use superpowers:executing-plans. Primary implements; mandated gpt-6-luna/max delegate owns packaging and runtime qualification. + +**Goal:** Connect the existing Mobile client to the existing Desktop sidecar through https://tunnel.bharatcode.ai, using explicit Desktop-approved pairing. + +**Architecture:** Keep the loopback mobile gateway, route allowlist, device verifier registry and existing sidecar. Run the OpenTunnel SDK in Desktop main with its API explicitly selected and private persistent identity/certificate storage. Pairing links contain public Desktop identity/origin only; authorization requires a short-lived pairing window, matching confirmation on both screens, explicit Desktop approval and a device token delivered in an HTTPS response body. + +**Tech Stack:** Existing Expo/React Native/TypeScript, Electron/Solid, Node >=22, @opentunnel/client 0.5.2. + +**Spec:** User's 2026-10-25 instructions and bharatcode-tunnel fe7dfa73b35ebb035ba42a62482ec230a7e1a8bf packages/client/README.md and docs/trust-boundary.md. + +## Global constraints + +- No account requirement, alternative transport, API/Chat service change, second agent, AWS/ZeroSSL credentials, or release publication. +- The routed gateway requires application authentication regardless of loopback peer, Host or forwarding headers; never route the sidecar directly. +- No device tokens, sidecar credentials or pairing claim secrets in URLs, logs or cookies. Use credential-free deep links and native credential storage. +- Preserve selected Mobile design and Desktop localization/main-preload-renderer boundaries. +- User authorized completion, separate Desktop/Mobile PRs and push after verification; keep qualification claims exact. + +## Review focus + +- Host/forwarding-header spoofing and browser origins cannot bypass authentication or reach local management endpoints. +- Expired/replayed/declined claims and revocation during streaming cannot grant or retain access. +- Starting/slow certificate issuance can be cancelled; progress remains visible without blocking status reads. +- Desktop restart retains the same tunnel/certificate and device credentials; disabled remote access stays disabled. +- Deep links reject credentials, malformed hosts, duplicate parameters, unexpected fields and expired pairing windows. + +## Task 1: Tunnel policy and lifecycle + +Files: desktop-connector/src/{policy,tunnel,desktop-access,index.d}.mjs (declarations index.d.ts), desktop-integration/{mobile-types,mobile-english,settings-mobile}.tsx/ts; connector tests. +- [x] Add failing allowlist, SDK API/progress/storage/cancellation and restart/disable tests. +- [x] Select only HTTPS tenant hostnames under tunnel.bharatcode.ai; reject upstream and malformed origins. +- [x] Explicitly configure SDK API; provision with progress callbacks before connecting; private persistent identity; cancellable creation. +- [x] Expose connected/reconnecting/provisioning/off/error status and separate disconnect, unpair, and tunnel reset controls. +- [x] Verify full connector suite. + +## Task 2: Credential-free pairing links and mobile transport + +Files: src/connection/{pairing-payload,api-client}.ts, app/pair.tsx, src/features/pairing/PairingScreen.tsx, desktop-connector/src/gateway.mjs and related tests. +- [x] Add failing tests for public pairing links, active pairing windows, pending claim bounds, approval and revocation; retain JSON scan compatibility. +- [x] Encode only origin and Desktop identity in bharatcode://pair links; open links at pairing screen and submit claim by POST only. +- [x] Allow public link enrollment only during an explicitly opened pairing window, with bounded pending attempts and explicit matching-code Desktop approval. A link never grants API access. +- [x] Omit ambient cookies in native fetch and retain redirect rejection; distinguish offline/reconnecting/revoked states. +- [x] Verify mobile typecheck/lint/security/full tests and connector tests. + +## Task 3: Current Desktop integration and security review + +Files: desktop-integration/apply.py, desktop-integration/test_apply.py and generated Desktop package/app/main/preload integration in isolated .local/tunnel-desktop, based on current origin/dev. +- [x] Apply integration to current Desktop revision without unrelated sidecar/product-policy changes. +- [x] Test trusted main-frame IPC, idempotent generator, localized settings, app/desktop typechecks and package suites. +- [x] Run an independent whole-diff correctness/security review against the tunnel trust boundary; fix actionable findings with regressions. + +## Task 4: Qualification and delivery + +- [x] Hand concrete implementation and source results to mandated qualification delegate. +- [x] Delegate tests live HTTPS pairing, authenticated requests/streaming, negative auth/Host/cookie boundaries, reconnect, isolated Desktop restart and revocation; report any unavailable native/physical verification. +- [x] Record exact evidence and limitations, review diffs for secrets/unrelated changes. +- [x] Commit and push focused branches; create and attach separate Desktop and Mobile PRs. No merge or release publication. + +## Execution record + +- Primary implemented the connector, Mobile link/transport changes and current Desktop integration; the user authorized implementation and push after verification. +- Source baselines passed 49 Mobile and 35 connector tests. The final implementation passes 53 Mobile tests, 15 security regressions, and 44 connector tests, plus Mobile lint/typecheck, Desktop/app typechecks, 168 Desktop tests, two generator and three browser-helper tests. +- Independent review: no actionable auth bypass; reset could await unfinished issuance, and phone-initiated unpair left a stale Desktop list. Both were reproduced then fixed with regressions. The stale-list issue was treated as functional correctness because a revoked phone must not remain presented as paired. +- Migration regression: a dedicated service identity directory prevents replaying upstream credentials to the self-hosted API. +- Existing app-suite failure reproduced on clean Desktop base: 748 pass, one i18n parity failure for four unrelated ui.message.outputLimit keys. Keep this visible in verification/PR evidence; do not change UI/session copy or suppress the test. +- Shared English fallback follows the existing BharatCode locale mechanism; additional languages require reviewed translations. + +- Final hosted/native qualification: first issuance and certificate reuse, real-sidecar stream revocation, actual Electron restart with identical persistent identity, Android emulator saved-credential restart/network recovery and revoked UI. Visible signed-in Desktop settings, physical camera/iOS and native incremental text rendering remain separate, unverified tiers. + +- Delivery: [Desktop PR #60](https://github.com/BharatCode-ai/bharatcode-desktop/pull/60) targets dev from the existing arzvaak fork because organization access is read-only; [Mobile PR #1](https://github.com/BharatCode-ai/bharatcode-mobile/pull/1) targets main. Both are attached to the task. Mobile push/PR source checks passed. Desktop pre-push workspace typechecks passed; no GitHub checks were reported for that PR. diff --git a/docs/verification/pairing-fixes-2026-10-28.md b/docs/verification/pairing-fixes-2026-10-28.md new file mode 100644 index 0000000..5939109 --- /dev/null +++ b/docs/verification/pairing-fixes-2026-10-28.md @@ -0,0 +1,29 @@ +# Pairing fixes verified October 28, 2026 + +## Changes + +- Ignore only synthesized cold-launch root intents (`bharatcode://` and `bharatcode:///`); retain validation of actual pairing intents. +- Update `expo-camera` from 58.0.12 to 58.0.13. In the affected optimized Android build, ML Kit's reflected component registrar constructors were stripped by R8. The preview worked, but the analyzer failed to initialize and no scan callback arrived. The upstream fix preserves the no-argument constructors; release optimization stays enabled. See [Expo PR 52377](https://github.com/expo/expo/pull/52377). +- Keep the mirrored Desktop connector/UI changes in sync: isolate optional mobile storage errors, cancel pending/queued/restoring pairing before reset, retain damaged registry backups only on explicit reset, reject stale UI completions, and give the QR a four-module clear border. + +The experimental service-side one-time pairing-code flow was reverted. This submission uses the existing hosted OpenTunnel service and requires no new server deployment. + +During release preparation, the live Expo compatibility gate required the current SDK 58 patch set. The final source now pins Expo 58.0.21 and resolves Expo Camera 58.0.14, which retains the 58.0.13 constructor fix. Related Expo modules and Jest Expo follow the supported patch versions. TypeScript, lint, 76 Jest tests, 15 security tests, `expo install --check`, and the high-severity npm audit gate passed after that alignment. The audit reports 15 low-severity dependency findings; it is not a zero-finding audit. + +## Source checks + +Fresh checks on October 28 passed: TypeScript, ESLint, 76 Jest tests, 15 certificate/security tests, 52 connector tests, two integration-generator tests and three Linux browser-helper tests. The generator was exercised twice against its documented Desktop pin using `BHARATCODE_DESKTOP_SOURCE` set to a clone containing that pin. This distinguishes the test's clone prerequisite from implementation failures. + +The optimized diagnostic APK retained public no-argument constructors for `BarcodeRegistrar`, `VisionCommonRegistrar`, and `CommonComponentRegistrar`. Both the 58.0.13 source module and its packaged prebuilt Android AAR include the constructor keep rule. + +## Physical Android result + +On October 28, the user scanned the installed Desktop QR with a OnePlus CPH2691 running Android 17. The phone reached matching-code approval, the user approved on Desktop, and real Desktop conversations loaded. This was a physical camera scan and user-operated approval, rather than a pasted link or an automated IPC approval. + +The phone ran the separate `BharatCode Camera QA` package, built from Mobile source `0d799e09a9d5a275f4958ba88eb1c8cadadfa6c4` with temporary diagnostic counters and `expo-camera` built from source. R8 remained enabled. Installed APK SHA-256: `8462f4085100d318051b704bd6b345a0b992036244813115ee2fb231469f1653`. The existing installed Desktop and its existing sidecar handled pairing; this is not acceptance of a newly packaged Desktop build. + +No camera images, QR contents or credentials were recorded by the counters. No phone permissions were changed by automation, and the existing app data and Desktop profile were preserved. This does not establish iOS, store signing, or all-platform release acceptance. + +## Release boundary + +The source/test and physical diagnostic-APK results above do not certify an untested replacement artifact. Packaging, installation, public releases and store submission must be reported with their own source, artifact and acceptance evidence. Earlier transport, streaming, network-recovery and revocation results are recorded in [the October 25 report](self-hosted-tunnel-2026-10-25.md). diff --git a/docs/verification/self-hosted-tunnel-2026-10-25.md b/docs/verification/self-hosted-tunnel-2026-10-25.md new file mode 100644 index 0000000..102304e --- /dev/null +++ b/docs/verification/self-hosted-tunnel-2026-10-25.md @@ -0,0 +1,88 @@ +# Self-hosted tunnel integration verification — 2026-10-25 + +Later fixes and physical camera acceptance are recorded in [the October 28 follow-up](pairing-fixes-2026-10-28.md). The results below describe the earlier source and artifacts. + +The implementation uses the existing OpenTunnel SDK against `https://tunnel.bharatcode.ai` and retains explicit Desktop-approved pairing. Tunnel service reference: `fe7dfa73b35ebb035ba42a62482ec230a7e1a8bf`. Desktop base: `695c2d956e6bc6f27b881a1cdbe0a7acd0764bc3`; Mobile base: `5403d872dbb85d9b42856312a9c503edf7982cae`. + +## Source verification + +| Gate | Result | +| --- | --- | +| Mobile TypeScript and ESLint | Passed | +| Mobile Jest suite | 71 passed, 12 suites | +| Existing certificate/security regressions | 15 passed | +| Mobile and connector npm audits | 0 known vulnerabilities | +| Expo dependency compatibility | Dependencies up to date | +| Connector Node suite | 44 passed | +| Desktop integration generator, applied twice to a clean pin | 2 passed | +| Linux browser helper regressions | 3 passed | +| Desktop package TypeScript and release configuration | Passed | +| App package TypeScript | Passed | +| Full Desktop workspace typecheck | 19 tasks passed in isolated sibling checkout | +| Desktop package source suite | 168 passed; focused owned-renderer authorization checks: 8 passed / 40 assertions | +| App package unit suite | 748 passed, 1 pre-existing failure; see below | + +`packages/app/src/i18n/parity.test.ts` fails because the Arabic UI dictionary lacks `ui.message.outputLimit`, `ui.message.outputLimit.reasoning`, `ui.message.outputLimit.partial`, and `ui.message.outputLimit.tool`. A fresh clean clone at the Desktop base reproduces the exact 748-pass/1-fail result. This integration does not modify those UI files or suppress the failure. The mobile settings keys use the repository's existing shared English fallback pending reviewed translations. + +One parallel Desktop test run observed a timing-sensitive failure in the unchanged `src/main/stdio.test.ts` test `compiled guard survives real Node stdout and stderr pipe disconnects`: its unguarded child delivered an extra `alive` message before exiting. The focused six-test stdio suite and subsequent full 168-test Desktop suite both passed. No test was suppressed and those stdio files were not changed. + +## Security review + +An independent read-only code reviewer checked the pinned tunnel trust-boundary document, SDK lifecycle, pairing/approval, gateway route/authentication boundaries, persistence, streaming revocation, native link parser, and trusted Desktop IPC. No actionable authentication bypass was found in that scope. This was a focused code review, not a repository-wide security audit. + +The review identified a reset path that could wait for a pending certificate. Reset now uses the stored authenticated tunnel identity to delete directly through the SDK API, with a 30-second operation bound. A regression exercises actual pending storage and the SDK HTTP request, without completing a certificate. Another regression verifies Desktop immediately reflects phone-initiated unpairing. The migration regression pins the separate self-hosted identity store so upstream credentials cannot be reused against the new service. + +A final connection-state regression reproduced idle background activity failures leaving Desktop marked connected; the Mobile client now shows offline/reconnecting, retries identity health, retains pairing, and clears the notice on recovery. + +Other source regressions exercise expired/replayed/declined claims, bounded public-link enrollment, mandatory explicit approval, invalid origins and malformed/deceptive URLs, wrong device credentials, browser origins, spoofed localhost/forwarding headers, sensitive route denial, provider-secret projection, registry persistence, revocation during requests/streams, readable status and cancellable slow issuance. Native fetch explicitly omits cookies and rejects redirects. See [the documented boundary](../security/tunnel-trust-boundary.md). + +The installed-runtime follow-up exposed two Mobile bugs: a warm pairing link could open the screen without submitting its claim, and long plain replies created a native Text view per Markdown leaf. Native intent routing now validates the original URI before passing it to pairing, with a per-delivery identifier so the same public link can be retried after cancellation. Plain paragraph rendering now joins text leaves while preserving rich Markdown children and heading styles. Regressions cover real store claim submission, repeated intents, malformed links without credential propagation, all 600 reply lines with bounded native view growth, rich spans, and three heading levels. Independent review caught the initial heading-style regression; the revised implementation resolves it. A sustained real-model run also exposed UI starvation from synchronously publishing every streamed text delta. The store now accumulates all deltas and publishes at most once per 100 ms, avoiding a full history reload for each text delta while lifecycle events still reconcile authoritative state. A 200-delta regression reproduces 200 notifications before the fix; the revised path retains every character with bounded notifications. Separate coverage ensures queued text cannot publish after navigation or revocation, and durable replay still reconciles text through history. Independent source review found no actionable issue in this change. The final exact `npm run check` passed TypeScript, ESLint, all 71 Jest tests and all 15 certificate/security checks. + +## Runtime qualification + +The designated `gpt-6-luna`/max qualification delegates own runtime builds and native/hosted testing. Source tests above do not prove real certificate issuance, physical camera scanning, network changes or native streaming. + +Observed hosted results before the UI/native handoff: + +- The real hosted service completed first-time certificate issuance in approximately six minutes. The SDK delivered 195 progress callbacks; pending identity state remained private and persisted through issuance. +- A subsequent connection with the stored certificate attached in approximately 300 ms. +- The public pairing link contained only origin/Desktop identity. A claim remained pending until explicit Desktop approval with matching confirmation, then authenticated health and protected session requests succeeded. +- Real hosted SSE delivered the expected projected assistant text delta from an authenticated deterministic sidecar fixture. This proves the transport/gateway path; it is not a production model-generation test. +- No bearer, wrong bearer, cookie-only, forwarded-header-only, and `Host: localhost` plus spoofed forwards returned 401. A browser Origin with a valid bearer returned 403. +- Closing/recreating the connector in the same Node process retained the same origin, Desktop/device IDs, accepted phone bearer and digest of all five identity/certificate files. This is connector lifecycle retention evidence; a full Desktop process restart is recorded separately below. +- Re-pairing issued a working replacement credential while the old revoked credential remained rejected. Reset deleted the hosted test identity. The initial harness then made a redundant authenticated fetch after reset, which failed because the tunnel had been removed; this is a harness-order limitation, not a passing post-reset authenticated request. + +Integrated Desktop results: + +- A second fresh identity provisioned through the real Electron main-process/preload IPC in approximately five minutes. Status snapshots showed certificate progress while issuance remained pending. The test used the existing Desktop sidecar, rather than creating another agent. +- Native Android claimed the public link and received access only after a matching-code approval through the actual owned-renderer IPC. Authenticated health and session requests then succeeded. The QA Desktop profile was signed out, so visible Settings/approval UI remained behind its existing account gate; approval was invoked through trusted IPC rather than by borrowing credentials or bypassing that gate. +- On the real Desktop tenant, unauthenticated, wrong-token, cookie-only, forwarded-header and localhost-Host spoof requests were rejected. A browser-origin request with a valid bearer was rejected. +- A disposable approved device created an empty session in the real isolated sidecar, then opened its hosted SSE endpoint: HTTP 200 and the initial connected comment arrived. Revoking that device through Desktop IPC removed it, made the old bearer receive 401, and actually aborted the stream after 13 buffered bytes drained. No provider/model call was made. +- A full isolated Electron stop and fresh process launch restored remote access as enabled and connected, with the same public origin, Desktop ID, one saved Android device, and identical SHA-256 digests for the five identity/certificate files and device registry. Android recovered with its existing credential and no new pairing. + +Android emulator results (API 35, x86_64): + +- An isolated development APK used a separate QA application ID. Opening the Desktop link reached native pairing, matching-code approval completed, and authenticated Desktop chats/session data rendered. +- A launcher relaunch restored the saved computer from native storage and reconnected without a new claim. A Metro JavaScript reload left the app on its existing pairing route with a Back to chats action; this was route state, rather than credential loss. +- After the full Desktop process restart, Android showed Offline and the saved-draft reconnect banner, then returned to Connected with the original credential. +- Enabling airplane mode on the QA emulator caused an actual background request failure and visible Offline/saved-draft notice within two seconds. Restoring its network returned the same paired computer to Connected within six seconds. +- An authenticated empty real-sidecar conversation rendered its composer and file/working-change controls after recovery, without sending a prompt. Native SSE server acceptance and incremental text rendering were not directly observed in this run; the separate hosted fixture and real-sidecar stream tests above cover those transport/termination boundaries. +- Revoking the phone through Desktop IPC produced “This phone is no longer authorized. Pair again.” in native UI. The saved computer metadata remained; no new pairing was performed after revocation. A subsequent native force-stop and launcher relaunch created a new app process and restored the same revoked state within approximately six seconds. +- A malformed link was rejected natively before a claim. Warm link dispatch worked. Cold link dispatch first opened the Expo development launcher until its remembered Metro server was selected; standalone cold-start behavior remains unverified. + +The initial isolated-profile run above did not verify production model generation or the signed-in installed Desktop. The follow-up below uses the installed native application and its existing account/session. Physical camera scanning, physical Android/iOS and signed distribution remain unverified. No existing installed Mobile app was replaced by the separate QA package. + +## Installed native Desktop and real-model follow-up + +The follow-up used the installed native Fedora Desktop build, its existing signed-in profile and existing sidecar, plus the separate API 35 Android development QA package. One user-approved controlled Desktop restart restored the same tunnel identity/certificate and paired-device registry. That Desktop process remained running throughout subsequent testing. Approval used the actual owned-renderer preload IPC with matching confirmation codes; pointer-driven Desktop approval UI is not claimed. Three pre-existing paired devices were preserved; disposable QA devices were handled separately. + +- A public pairing link submitted a claim and connected Android only after explicit matching-code Desktop approval. Opening the real public link in the already-running Android app also passed: it automatically submitted a pending claim, displayed the matching confirmation and connected only after approval. This used the native-intent fix above; Metro needed its route context refreshed to include the newly added file in the development bundle. No manual paste was used for that final warm-link test. +- Real account-backed Qwen 3.8 27B requests ran through the phone's authenticated HTTPS tunnel, gateway and existing Desktop sidecar. The completed answers included the requested 1–80, 1–200, 1–120 and 1–1000 sequences. These were model calls, rather than fixture SSE events. +- During the 200-line request, Android Wi-Fi/data were disabled after admission. Mobile showed Offline and its saved-draft/reconnect notice. Desktop completed generation while the phone was offline. Restoring the network recovered the paired connection, cleared the notice and displayed the completed answer; the authoritative session retained one user/assistant pair for that request, without duplicate admission. +- The 1000-line stress run exposed UI starvation described above. After the rendering/store fixes, Mobile rendered all 1000 lines in one numeric TextView and Back navigation worked. The first live sampler omitted UIAutomator errors, so its zero counts are invalid measurements and are not used as proof of missing text or successful incremental growth. +- Incremental real-model rendering passed on the revised source without instrumentation. A read-only React DevTools probe measured the current assistant's committed `Message` props growing from 46 to 114 text characters, with screenshots showing the visible tail advance from line 18 to line 40. The matching assistant's authenticated Desktop lookup explicitly reported incomplete after each screenshot. These pre-revocation samples were approximately 8.24 and 10.17 seconds after sending the prompt. No accessibility-idle assumption or final-response timing was used to infer streaming. +- Desktop revoked only the disposable QA phone while that response was still incomplete. Mobile automatically switched to Offline with “This phone is no longer authorized. Pair again.” in approximately 1.97 seconds, and native accessibility confirmed Send was visible but disabled. Neither app was restarted. Twelve characters already in flight rendered before the phone's text stabilized at 126 characters. After Desktop finished the 1,893-character answer, eleven further observations over 20.27 seconds confirmed the phone stayed offline/unauthorized at that same partial text, with no further reply growth. Revocation does not retract buffered bytes or automatically cancel work owned by Desktop. The device list returned from four entries to the original three. Earlier fresh requests with revoked credentials also returned the re-pair notice. + +Qualification processes ran under a shared 6 GiB memory cap and two-core CPU budget; the emulator child was capped at 4 GiB with 1.5 GiB guest RAM. Earlier attempts that exceeded smaller emulator child caps were discarded. The later UI starvation was observed without a new memory-limit/OOM event. + +No release publication or store submission is part of this work. Physical Android/iOS, camera scanning and signed distribution remain separate qualification tiers. diff --git a/package-lock.json b/package-lock.json index 9851375..a03bab8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,20 +9,20 @@ "version": "0.2.0", "dependencies": { "@expo/vector-icons": "^15.0.2", - "expo": "58.0.19", - "expo-asset": "~58.0.18", - "expo-camera": "~58.0.12", + "expo": "58.0.21", + "expo-asset": "~58.0.19", + "expo-camera": "~58.0.14", "expo-clipboard": "~58.0.4", - "expo-constants": "~58.0.13", + "expo-constants": "~58.0.14", "expo-crypto": "~58.0.7", - "expo-dev-client": "~58.0.15", - "expo-font": "~58.0.10", - "expo-linking": "~58.0.14", - "expo-router": "~58.0.26", + "expo-dev-client": "~58.0.17", + "expo-font": "~58.0.11", + "expo-linking": "~58.0.15", + "expo-router": "~58.0.28", "expo-secure-store": "~58.0.2", "expo-sqlite": "~58.0.14", "expo-status-bar": "~58.0.4", - "expo-system-ui": "~58.0.6", + "expo-system-ui": "~58.0.7", "react": "19.3.0", "react-dom": "19.3.0", "react-native": "0.88.0", @@ -40,7 +40,7 @@ "eslint": "^9.39.5", "eslint-config-expo": "~58.0.4", "jest": "~30.5.2", - "jest-expo": "~58.0.12", + "jest-expo": "~58.0.13", "node-forge": "file:vendor/node-forge", "prettier": "^3.9.10", "typescript": "~6.0.3" @@ -1777,16 +1777,16 @@ } }, "node_modules/@expo/config": { - "version": "58.0.8", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-58.0.8.tgz", - "integrity": "sha512-jbel1j208xyWFzy2XUUEo/sSnu+7hMunbw//rNyGrjM73rBQouWl3IRKo/p6p4BhRUgoUDJTqmm+AY9LSzfp2A==", + "version": "58.0.9", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-58.0.9.tgz", + "integrity": "sha512-rN21dSqt6jfzpnVaBwCZVIvRFxoWEit+11AY+dSyaVkkwFWnVga0KOkFTQydtYgIZmP/vdm4QJhKeQkoUf4oMQ==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~58.0.10", + "@expo/config-plugins": "~58.0.11", "@expo/config-types": "^58.0.3", "@expo/json-file": "^11.2.1", "@expo/platforms": "^58.0.1", - "@expo/require-utils": "^58.0.4", + "@expo/require-utils": "^58.0.5", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", @@ -1796,15 +1796,15 @@ } }, "node_modules/@expo/config-plugins": { - "version": "58.0.10", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-58.0.10.tgz", - "integrity": "sha512-oW0geRREubSsqV2d0EwkK0rRcAoB7a68CnJx7s8Al33HoTtEg7KLTQNcKlSTmmwoUESQQvAmzNqAZy4ajuCBKA==", + "version": "58.0.11", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-58.0.11.tgz", + "integrity": "sha512-lenBm4ajWMPW+Hol3xiHHoSZEO/mA3T7X/NU3XNqm96XepwjXh2CMpIvROGLoh0amfVEIXIW3Qj3gnDS3cK/nw==", "license": "MIT", "dependencies": { "@expo/config-types": "^58.0.3", "@expo/json-file": "~11.2.1", "@expo/plist": "^0.10.1", - "@expo/require-utils": "^58.0.4", + "@expo/require-utils": "^58.0.5", "@expo/sdk-runtime-versions": "^1.0.1", "chalk": "^4.1.2", "debug": "^4.3.5", @@ -1867,9 +1867,9 @@ } }, "node_modules/@expo/fingerprint": { - "version": "0.21.6", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.21.6.tgz", - "integrity": "sha512-EKh7ak+SYwXjvX/4P7BcyeLxip2z9KAsnN/55VP10g8H1Ua4Dm/BgLAhgBcbzbsYretYmNEynAcf9oKCK9pjxA==", + "version": "0.21.7", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.21.7.tgz", + "integrity": "sha512-rE9QN4TXGZxS3PC1yW77D1B6JNnuv3n7hXVUZxFEDDndPKFnQPC0XkqFyIz21oXbtrvThROCbV9CDVJiycCntg==", "license": "MIT", "dependencies": { "@expo/env": "^2.5.3", @@ -1889,12 +1889,12 @@ } }, "node_modules/@expo/image-utils": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.12.5.tgz", - "integrity": "sha512-nsFYd7GfDdwZa+S+p+EkVCkh7UKKNf0kfUXDQ53HbyMmNFD3psb5JYwU/2N+uZG5wOEfKGfQ5u7pk/wn2gMlBA==", + "version": "0.12.6", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.12.6.tgz", + "integrity": "sha512-AWzD/c3mM/a+sDbRgOSNzBpkUW8sUVUFcwnZ7xqkkhc5Hi9Fms0pDGw24JLxccgA6cC6bbJ8sZiQbnXBf75onA==", "license": "MIT", "dependencies": { - "@expo/require-utils": "^58.0.4", + "@expo/require-utils": "^58.0.5", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", @@ -1904,12 +1904,12 @@ } }, "node_modules/@expo/inline-modules": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.2.7.tgz", - "integrity": "sha512-jjVtmz1LDhohT19U1fwMSL1kVUVZ9ewHdlEoDwSoZd3QELpyUR54nL677nHUbz0nNA5eh/zk38nByi03T06rLg==", + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.2.8.tgz", + "integrity": "sha512-7x+mbe6/rWCD6iWSPl8s7Jipdr+Ss9f6raFdHSl8oIouSL09pszJb7bD15oLje6AkioFVe/lgq/QOqKB7w8bWw==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~58.0.10" + "@expo/config-plugins": "~58.0.11" } }, "node_modules/@expo/json-file": { @@ -1923,12 +1923,12 @@ } }, "node_modules/@expo/local-build-cache-provider": { - "version": "58.0.8", - "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-58.0.8.tgz", - "integrity": "sha512-vM2YZgQ7XSWuVZmPQgK764lj2PiP9+Y3m/tOpVTI0mHHpCoImBu7R+4KQNPqW87r44rhevduggi8kZ0kaoNNxQ==", + "version": "58.0.9", + "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-58.0.9.tgz", + "integrity": "sha512-C1QZ4GDljraOtARoZPvImxA9gKLd9kb/w1fRpc+x2sCZ1qXxWmqoVh+ISuwe6niB1BvzBEDA8KH0QQJj3r9nLQ==", "license": "MIT", "dependencies": { - "@expo/config": "~58.0.8", + "@expo/config": "~58.0.9", "chalk": "^4.1.2" } }, @@ -1983,6 +1983,71 @@ "metro-transform-worker": "0.87.1" } }, + "node_modules/@expo/metro-config": { + "version": "58.0.19", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-58.0.19.tgz", + "integrity": "sha512-z8tOnyiY5jg2sbrCAD60N+G9AX6sZQyMrfrhL4DK4pkwyt5TODkxk61G0Y0k9RidXpvjguqftB7v/w8UK5wCGg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~58.0.9", + "@expo/env": "~2.5.3", + "@expo/json-file": "~11.2.1", + "@expo/metro": "~58.0.0", + "@expo/platforms": "^58.0.1", + "@expo/require-utils": "^58.0.5", + "@expo/spawn-async": "^1.8.0", + "@jridgewell/gen-mapping": "^0.3.13", + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.5.5", + "@jridgewell/trace-mapping": "^0.3.31", + "@react-native/js-polyfills": "0.88.1", + "2g": "^1.0.1", + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "hermes-parser": "^0.36.0", + "jsc-safe-url": "^0.2.4", + "lightningcss": "^1.30.1", + "noxcturnal": "^0.1.11", + "picomatch": "^4.0.4", + "postcss": "^8.5.23", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "expo": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/@expo/metro-config/node_modules/@react-native/js-polyfills": { + "version": "0.88.1", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.88.1.tgz", + "integrity": "sha512-B62O6zVMYW7kK8dxIuCwpdJ5RYcIvET6ItScm0sfsTibJs+llS0krUVSAOHnEUn3lbFMfw+o+sNdWREOAc6FfQ==", + "license": "MIT", + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/@expo/metro-config/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@expo/metro-file-map": { "version": "58.0.5", "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-58.0.5.tgz", @@ -2023,9 +2088,9 @@ } }, "node_modules/@expo/modules-macros-linux-arm64": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@expo/modules-macros-linux-arm64/-/modules-macros-linux-arm64-0.22.0.tgz", - "integrity": "sha512-FGXyxpZMEJ4pDPCeCBjfBjftCbk03P/1Rf6UkFoL22joqu4XXt+XUsjfTzyKBzP9pAXL39FVdbNClwk529CEEQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@expo/modules-macros-linux-arm64/-/modules-macros-linux-arm64-0.27.1.tgz", + "integrity": "sha512-8bwGkFZ5FaLzSXe37t8ksw4GJ7flvRhBSmmFfkZlETjm8nAPrU5D+VF8YkSmRUoQ5DA3PWLLwnYK+WhvzJ0+fA==", "cpu": [ "arm64" ], @@ -2039,9 +2104,9 @@ ] }, "node_modules/@expo/modules-macros-linux-x64": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@expo/modules-macros-linux-x64/-/modules-macros-linux-x64-0.22.0.tgz", - "integrity": "sha512-4yN4cZTCUWkq1Olr0/lA57gFan8+MEo7K3K0cx0bB3TUEqHgL/hyJN79vQKXlp9NWTnOxO7J5uwqMQkQwfh6aw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@expo/modules-macros-linux-x64/-/modules-macros-linux-x64-0.27.1.tgz", + "integrity": "sha512-63BZcm/aWPNe4VC9jBxKOME7abiM6rCdn6NiZTTmlEMWmBaoRWJEKj16tT/Gjkf25y9+dMwadQ9B+LNMbh5nPA==", "cpu": [ "x64" ], @@ -2055,9 +2120,9 @@ ] }, "node_modules/@expo/modules-macros-win32-arm64": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@expo/modules-macros-win32-arm64/-/modules-macros-win32-arm64-0.22.0.tgz", - "integrity": "sha512-nLDXRGZWKNERzP4zH9LdfdZ3jvPBQcQE7LX5UnWW4cnDE/GoiVyUKdXQFK+KRnMD6We5gxqhIOU6SLj74h6fHA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@expo/modules-macros-win32-arm64/-/modules-macros-win32-arm64-0.27.1.tgz", + "integrity": "sha512-BcIJibqB7YxuUTf7R7gk2gbPYr6ngM0t+hTeoAhpsgS5xUlYU/TVjgrYpYOFTyYfWwgnDUv75WrJxbvVolK5xQ==", "cpu": [ "arm64" ], @@ -2068,9 +2133,9 @@ ] }, "node_modules/@expo/modules-macros-win32-x64": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@expo/modules-macros-win32-x64/-/modules-macros-win32-x64-0.22.0.tgz", - "integrity": "sha512-IT5/YsBTj2gnfNlxDfv3sC51rBudiSHbPFBKoPBbpwmTCMcQg1rmWVdUoiy7LLC48NNzm86wyMxHpSI5lkW1Gg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@expo/modules-macros-win32-x64/-/modules-macros-win32-x64-0.27.1.tgz", + "integrity": "sha512-jguCWgOW2dcLY7VaReBCxU48FBX1g4DhzETm/DpD+WwxZOweiBRSwk7zaOTiTG2rifBYu4y9VYHxHrONHfOLBw==", "cpu": [ "x64" ], @@ -2124,27 +2189,33 @@ } }, "node_modules/@expo/prebuild-config": { - "version": "58.0.16", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-58.0.16.tgz", - "integrity": "sha512-Yhr+1wy4YzLZjbi+F4ss+kUdPN5WnnJ8lwVwOC2wQOsnjnfnyrDImGoEhbnTvzLHRCN8+gEtqKhW9ZSosYYUxQ==", + "version": "58.0.17", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-58.0.17.tgz", + "integrity": "sha512-QSMM9YiHQnRENUIgP7wgZt71WKPLUMLz5QFm4KWA2aa20a808F6b+HV/ZzfX47MWWt39RjyT9pUcLv/dgEp0UA==", "license": "MIT", "dependencies": { - "@expo/config": "~58.0.8", - "@expo/config-plugins": "~58.0.10", + "@expo/config": "~58.0.9", + "@expo/config-plugins": "~58.0.11", "@expo/config-types": "^58.0.3", - "@expo/image-utils": "^0.12.5", + "@expo/image-utils": "^0.12.6", "@expo/json-file": "^11.2.1", - "@react-native/normalize-colors": "0.88.0", + "@react-native/normalize-colors": "0.88.1", "debug": "^4.3.1", - "expo-modules-autolinking": "~58.0.15", + "expo-modules-autolinking": "~58.0.16", "resolve-from": "^5.0.0", "semver": "^7.7.4" } }, + "node_modules/@expo/prebuild-config/node_modules/@react-native/normalize-colors": { + "version": "0.88.1", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.88.1.tgz", + "integrity": "sha512-2SAKiLyZRf+peBLkhhJWGY5vC26w1Uj9QBgUZvjAnOxjgeEKUGBE5riPS0R7+YoqSrRL0w8ucwZB4M+vvy9HMg==", + "license": "MIT" + }, "node_modules/@expo/require-utils": { - "version": "58.0.4", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-58.0.4.tgz", - "integrity": "sha512-DvjS5NCitXYcad2Woe1EMHJKcbhTbE77HX0BgOQGL2LFHE3q2H0n8kCWDPBOCVJ63Drap3c35nwoJwF5TQL7Tw==", + "version": "58.0.5", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-58.0.5.tgz", + "integrity": "sha512-14dGubYUBmJjK9aqVSGnwSDs9uaxfzvtqJugtMCEWyuTa/eIvToUbzUwpKCSek6S/hYSEYcgds23vDJhXSBpbQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -4289,9 +4360,9 @@ } }, "node_modules/@noxcturnal/noxcturnal-darwin-arm64": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-darwin-arm64/-/noxcturnal-darwin-arm64-0.1.9.tgz", - "integrity": "sha512-CJWyUWfp+/vnLTdaFoYHXV7abCA1qO0VClPgFRjv2FbMFQ81XhWZcG9hPdt4FzSZ1oqqiQqlvpk7hA/Nh8ML9g==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-darwin-arm64/-/noxcturnal-darwin-arm64-0.1.11.tgz", + "integrity": "sha512-tqvZOVmSoiJzm7QjQ+whPxCP45J2C1zCAZnzc1HhwmJrFIWw/qyA8o9Cup0aVqnjNVAN+as9GHX+DbAmkCbGlQ==", "cpu": [ "arm64" ], @@ -4305,9 +4376,9 @@ } }, "node_modules/@noxcturnal/noxcturnal-darwin-x64": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-darwin-x64/-/noxcturnal-darwin-x64-0.1.9.tgz", - "integrity": "sha512-53D55Ai3rAUf8USjnCjgSzegtuc4nkvcR9EIiTXd4QgRLUtK2IY0q4IRdWU8Yrnlq/O5/GdGmmD6+t3ymQMjqg==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-darwin-x64/-/noxcturnal-darwin-x64-0.1.11.tgz", + "integrity": "sha512-V2HXkK5iKFBSc2GmeZE2I2wGxBc0kMQUHxxIUBSjuO8enKIby6cEQj8WW5EAuIli622Sqw1V7f0IIz8mXB4M9g==", "cpu": [ "x64" ], @@ -4321,9 +4392,9 @@ } }, "node_modules/@noxcturnal/noxcturnal-linux-arm64-gnu": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-linux-arm64-gnu/-/noxcturnal-linux-arm64-gnu-0.1.9.tgz", - "integrity": "sha512-ingSEdcjdZBiGfjpnTNSlEE6db247fUsdRF3NJI/0anYuJa4WlNnm+z8as2m9dFATr+qmZLjBXgUFfnc6ZRQAg==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-linux-arm64-gnu/-/noxcturnal-linux-arm64-gnu-0.1.11.tgz", + "integrity": "sha512-Vf9Qbke9Hq5ToFt88XicQN0FJJuepOnPKspcKDICeYYXS2b5wQnU7uhf33QWzr1KPQhn0L0IWUCwBNhMVn6E6w==", "cpu": [ "arm64" ], @@ -4340,9 +4411,9 @@ } }, "node_modules/@noxcturnal/noxcturnal-linux-arm64-musl": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-linux-arm64-musl/-/noxcturnal-linux-arm64-musl-0.1.9.tgz", - "integrity": "sha512-i45wjmtleHdQYCshqCiAIxdD57gaApdUTgu5M99FBsddpYHXIEHvdwA78aZiqMwjLNz723tFdGB52DwbhzgzEA==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-linux-arm64-musl/-/noxcturnal-linux-arm64-musl-0.1.11.tgz", + "integrity": "sha512-HlwlrcfBE7JBiwWaq32R8eN4APJHQCDoQlapeK+t0wxY9s9YDMDtAnG+v7RrEz05nu7Aci21x/s8qIAA5jE+NA==", "cpu": [ "arm64" ], @@ -4359,9 +4430,9 @@ } }, "node_modules/@noxcturnal/noxcturnal-linux-x64-gnu": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-linux-x64-gnu/-/noxcturnal-linux-x64-gnu-0.1.9.tgz", - "integrity": "sha512-IRV8YsMCT3QqbYEE5dFGGBe58Ivkz5bEUpdXC110uTfzdeRSYbv3Iq/PbdVcfyoXp1HXtBwV6bdIofDQTEPlTg==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-linux-x64-gnu/-/noxcturnal-linux-x64-gnu-0.1.11.tgz", + "integrity": "sha512-kIzTkH4rEIn8vFl42lzfK8qmKaXMwHM7txbI+XkhYKjS+2WV08yslBpwHaPGtGLXPEfx+xUTUgY5K3KVET7HKw==", "cpu": [ "x64" ], @@ -4378,9 +4449,9 @@ } }, "node_modules/@noxcturnal/noxcturnal-linux-x64-musl": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-linux-x64-musl/-/noxcturnal-linux-x64-musl-0.1.9.tgz", - "integrity": "sha512-M7FT6J5R2XDWFioyFlxXb+RvnjNBpK2p1rtrtHHh6m/g1nHcNqvvTYjMQzrTHbMyOdQGlaV3zyALhURtX4kgQw==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-linux-x64-musl/-/noxcturnal-linux-x64-musl-0.1.11.tgz", + "integrity": "sha512-KpC0H+v+y/v0j4EmGiopzZ2OKo01JIBrHTjTi+veYAhZbpUfwZfShdTTHEj5OjdQ5VgaJU8yaBXmX48jgAACOg==", "cpu": [ "x64" ], @@ -4397,9 +4468,9 @@ } }, "node_modules/@noxcturnal/noxcturnal-win32-arm64-msvc": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-win32-arm64-msvc/-/noxcturnal-win32-arm64-msvc-0.1.9.tgz", - "integrity": "sha512-EAaydqx5mgJfQn+V7LHyhpsdFkAYSwVMqz8AD3Hs/1slMvkwJgPjZv2KvZbgiJPGBaYhRg4X+CaOFW75LVx9iw==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-win32-arm64-msvc/-/noxcturnal-win32-arm64-msvc-0.1.11.tgz", + "integrity": "sha512-Q0Q0ll/qPzakp/GBmVDB/XthqyCkfM2CE5gZ78t9Ix/bkZDe5O0G7OyNkXRsGu49VcorxoGbBgSyF6CK+OaYlQ==", "cpu": [ "arm64" ], @@ -4413,9 +4484,9 @@ } }, "node_modules/@noxcturnal/noxcturnal-win32-x64-msvc": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-win32-x64-msvc/-/noxcturnal-win32-x64-msvc-0.1.9.tgz", - "integrity": "sha512-Vj1SNQ+ECsih/RSj+KSC3bXcrkGgETl6GOIIWIg6p5x6KXdLYPID9vyOjwlJa8B0Dmo6uK/ZNlIJX76bxc0WLg==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@noxcturnal/noxcturnal-win32-x64-msvc/-/noxcturnal-win32-x64-msvc-0.1.11.tgz", + "integrity": "sha512-as4HUwZkQegCcGoFzaLzlZbBqdx8BGgFhAoQsTsdJCUbNTxVH0f3R/AcRLOUJ6odfko0E2SSuJc70FI0zQ/IDg==", "cpu": [ "x64" ], @@ -5099,6 +5170,7 @@ "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.88.0.tgz", "integrity": "sha512-leGEnCcS7Q0hTxvzvhNtr8zGQpI1gfWqXjzC4UedsFmNMv6kIo4s70o0XbtM30ZXbpfWe4BcSC8NKWuak5sI4A==", "license": "MIT", + "peer": true, "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.88.0" @@ -5313,15 +5385,15 @@ } }, "node_modules/@react-native/jest-preset": { - "version": "0.88.0", - "resolved": "https://registry.npmjs.org/@react-native/jest-preset/-/jest-preset-0.88.0.tgz", - "integrity": "sha512-dN97NNqBsShAcxGH8/mNSBgYYc7Xk8q+Hez9qG9iockB4GPir5/Aw45e14sn7iLYTu2HkY0Uq2h3WlMY+ekRxw==", + "version": "0.88.1", + "resolved": "https://registry.npmjs.org/@react-native/jest-preset/-/jest-preset-0.88.1.tgz", + "integrity": "sha512-ue9L2ejEA+i176kz1uadJah75orz9YvP4L515yuAqqvjNNSwTRSkErUsdg0hS2QbDVTx+2C/D5gMUWCirLKT+w==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", - "@react-native/js-polyfills": "0.88.0", + "@react-native/js-polyfills": "0.88.1", "babel-jest": "^29.7.0", "jest-environment-node": "^29.7.0", "regenerator-runtime": "^0.13.2" @@ -5333,11 +5405,23 @@ "react": "^19.2.3" } }, + "node_modules/@react-native/jest-preset/node_modules/@react-native/js-polyfills": { + "version": "0.88.1", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.88.1.tgz", + "integrity": "sha512-B62O6zVMYW7kK8dxIuCwpdJ5RYcIvET6ItScm0sfsTibJs+llS0krUVSAOHnEUn3lbFMfw+o+sNdWREOAc6FfQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, "node_modules/@react-native/js-polyfills": { "version": "0.88.0", "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.88.0.tgz", "integrity": "sha512-Iq6v4+EWdOLv0M1+BpADQJBVfInl6FkABXCIBSi0FbvHahEYT+Y4UJ7Cm96p18gbEN1VKzqz3nbBbwLA6bx0rQ==", "license": "MIT", + "peer": true, "engines": { "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" } @@ -6899,9 +6983,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "58.0.20", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-58.0.20.tgz", - "integrity": "sha512-pdOe2B3pR41uYvfMIWUxwz8YMqXuszMEBKTCCLnf2nvypybMEZ30z38J0IcwUCQ2H+hrWAXpueQzWQsdooXWOA==", + "version": "58.0.22", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-58.0.22.tgz", + "integrity": "sha512-2/pbzxDh4wd4MrBU4lFqFWEfCQooCVjnh6JJZnPLmWisOy5+r4qaOItcH+zaiO4hP2CyYwnyotg1QKhgsmAMEw==", "license": "MIT", "dependencies": { "@babel/generator": "^7.20.5", @@ -6940,7 +7024,7 @@ "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/preset-typescript": "^7.23.0", - "@react-native/babel-plugin-codegen": "0.88.0", + "@react-native/babel-plugin-codegen": "0.88.1", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.4", "babel-plugin-syntax-hermes-parser": "^0.36.0", @@ -6950,7 +7034,7 @@ "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", - "expo-widgets": "^58.0.23", + "expo-widgets": "^58.0.25", "react-refresh": ">=0.14.0 <1.0.0" }, "peerDependenciesMeta": { @@ -6965,6 +7049,61 @@ } } }, + "node_modules/babel-preset-expo/node_modules/@react-native/babel-plugin-codegen": { + "version": "0.88.1", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.88.1.tgz", + "integrity": "sha512-2r7aeE3dXu3hawHNYHgazWQl9MJNjcnA/gxLApFK2gPMxBCLiQOHVUSaemDQz4mKPs5svulbhtDehxo6LzTzpQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@react-native/codegen": "0.88.1" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/babel-preset-expo/node_modules/@react-native/codegen": { + "version": "0.88.1", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.88.1.tgz", + "integrity": "sha512-URPgPhnsxqeQSLyTRVzhZZ8iNXtZdR1Jop6lqaQacymvl3byghXisNIRR1YyG6PbXUfzPan5Jmz/kPhUUsZ2Yw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.29.0", + "flow-parser": "0.327.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "tinyglobby": "^0.2.15", + "yargs": "^17.6.2" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/babel-preset-expo/node_modules/flow-estree": { + "version": "0.327.0", + "resolved": "https://registry.npmjs.org/flow-estree/-/flow-estree-0.327.0.tgz", + "integrity": "sha512-f9vK751YUzD0qaHSpKc5/qQM6gqOlgGS+4ZRqTNcZOpVqn3mC55yEQ6dUG2K1eaVm5FbKkz9CFWe6XNvrkQFQg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/babel-preset-expo/node_modules/flow-parser": { + "version": "0.327.0", + "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.327.0.tgz", + "integrity": "sha512-/MYZ5hXWf5ozpbdfUDmazMgQXviLxWEiRLH3MppEGDdW0itRVqmB5UaU7QX+SBCDNiq+gEesSVn8nlxmPDyDkw==", + "license": "MIT", + "dependencies": { + "flow-estree": "0.327.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/babel-preset-jest": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", @@ -9173,33 +9312,33 @@ } }, "node_modules/expo": { - "version": "58.0.19", - "resolved": "https://registry.npmjs.org/expo/-/expo-58.0.19.tgz", - "integrity": "sha512-+MHH9Bu7OFWsAl8Ox44KgqbVIe72xbxzAi3vLPtVf14o5zHE39iCD//uw/4nyBlqZii6cFUZsvtJDbeRo5OhMA==", + "version": "58.0.21", + "resolved": "https://registry.npmjs.org/expo/-/expo-58.0.21.tgz", + "integrity": "sha512-2Q8jp0fKLrDMou4aKWZ5V2cCVfcwOScuV8GBMkogiXIv7bCwwN4CIkUIq5QsX7DHXJxbruXLTXEgrp6J+iF4YA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "^58.1.15", - "@expo/config": "~58.0.8", - "@expo/config-plugins": "~58.0.10", + "@expo/cli": "^58.1.17", + "@expo/config": "~58.0.9", + "@expo/config-plugins": "~58.0.11", "@expo/devtools": "~58.0.2", "@expo/dom-webview": "~58.0.2", - "@expo/fingerprint": "^0.21.6", - "@expo/local-build-cache-provider": "^58.0.8", + "@expo/fingerprint": "^0.21.7", + "@expo/local-build-cache-provider": "^58.0.9", "@expo/log-box": "^58.0.13", "@expo/log-box-utils": "^58.0.2", "@expo/metro": "~58.0.0", - "@expo/metro-config": "~58.0.17", + "@expo/metro-config": "~58.0.19", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~58.0.20", - "expo-asset": "~58.0.18", - "expo-constants": "~58.0.13", - "expo-file-system": "~58.0.11", - "expo-font": "~58.0.10", + "babel-preset-expo": "~58.0.22", + "expo-asset": "~58.0.19", + "expo-constants": "~58.0.14", + "expo-file-system": "~58.0.13", + "expo-font": "~58.0.11", "expo-keep-awake": "~58.0.3", - "expo-modules-autolinking": "~58.0.15", - "expo-modules-cli": "~0.3.2", - "expo-modules-core": "~58.0.26", + "expo-modules-autolinking": "~58.0.16", + "expo-modules-cli": "~0.4.0", + "expo-modules-core": "~58.0.28", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.2.0" @@ -9241,13 +9380,13 @@ } }, "node_modules/expo-asset": { - "version": "58.0.18", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-58.0.18.tgz", - "integrity": "sha512-Cc49mpDd81hIIKw1DVDkL52a/S6MJlHLA+LirUHNR7qJJfopoHrSypg9rRuqvCUVW7/JKvXKqbFX43NynpVzmg==", + "version": "58.0.19", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-58.0.19.tgz", + "integrity": "sha512-9tYdhiPUJKA3OjnQQJtcP4Yrdc9TPR1JQXTomcLdDSV4XgHSXRpsnp5zsIfM6S/y30QsBFHS6G3WIBaocCiPtA==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.12.5", - "expo-constants": "~58.0.13" + "@expo/image-utils": "^0.12.6", + "expo-constants": "~58.0.14" }, "peerDependencies": { "expo": "*", @@ -9256,9 +9395,9 @@ } }, "node_modules/expo-camera": { - "version": "58.0.12", - "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-58.0.12.tgz", - "integrity": "sha512-2tqW97u6T9CUBDYc3VSrKj0BBXDYKyBOSjqOSiqidVkJMm/iKy4hngitTu7EMi2EqELqh90bGUkOeqhpYfBsdg==", + "version": "58.0.14", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-58.0.14.tgz", + "integrity": "sha512-yEiMuJ2d7A+EqsP8rlGIvgaUzdk/rW9CXR6R7WKqludxU3H+JcSUbPC3ldr3ZpmaYlgXP3x+oOO9BfQy7yKK2A==", "license": "MIT", "dependencies": { "barcode-detector": "^3.0.0" @@ -9287,13 +9426,13 @@ } }, "node_modules/expo-constants": { - "version": "58.0.13", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-58.0.13.tgz", - "integrity": "sha512-zUj1rcnYg0O2wKF9L32ToEtFujc5OkslZJ4lWeg+fEwoPMLVwldn6kdEkX8zx31KgSwF94Ey1mIFdFX8Brp73Q==", + "version": "58.0.14", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-58.0.14.tgz", + "integrity": "sha512-ogn/7xLmvkEVYX6SimDkcNDn/d8qZpCTQdBWPnMI+Ry7Sfch0Dz2GIcweqddK4hrkvrSY01TS20BZfMlCpJ75g==", "license": "MIT", "dependencies": { "@expo/env": "~2.5.3", - "@expo/require-utils": "^58.0.4" + "@expo/require-utils": "^58.0.5" }, "peerDependencies": { "expo": "*", @@ -9310,13 +9449,13 @@ } }, "node_modules/expo-dev-client": { - "version": "58.0.15", - "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-58.0.15.tgz", - "integrity": "sha512-Pdj4lOZiie160ZLeRhw6qgis7KZaXDQTXJrUx+VY+fjVco7AljP7V+Ufo9XmvUhE7xqBCpNxgaDQEe6ZnZMdHA==", + "version": "58.0.17", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-58.0.17.tgz", + "integrity": "sha512-9kGl91OWp4oxNuxAxg3C6UxgLFC78JpK4mRmNNo3B3K6OB40rZ5m6PqtqlxcZ1BWvGV8SGGoALxaYrxP68uw5A==", "license": "MIT", "dependencies": { - "expo-dev-launcher": "~58.0.15", - "expo-dev-menu": "~58.0.11", + "expo-dev-launcher": "~58.0.17", + "expo-dev-menu": "~58.0.12", "expo-dev-menu-interface": "~58.0.1", "expo-manifests": "~58.0.1", "expo-updates-interface": "~58.0.1" @@ -9326,13 +9465,13 @@ } }, "node_modules/expo-dev-launcher": { - "version": "58.0.15", - "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-58.0.15.tgz", - "integrity": "sha512-EleuxH/A7bQZrmmXKjKEZNRy8mEvLxzaKhxJISukYZwFiccNDq0d65wfrpcABr3msrcOD5z0KpxLPXgrwoO26w==", + "version": "58.0.17", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-58.0.17.tgz", + "integrity": "sha512-z2/F90do1q7Jgs6hnsY6Xjc1Sd7vKPMvw7Pp5Iv6M2qGHWprfydMCaUcwOkAQFoXqE2rLvd5hC1aSDFbH4oqRQ==", "license": "MIT", "dependencies": { "@expo/schema-utils": "^58.0.2", - "expo-dev-menu": "~58.0.11", + "expo-dev-menu": "~58.0.12", "expo-manifests": "~58.0.1" }, "peerDependencies": { @@ -9341,9 +9480,9 @@ } }, "node_modules/expo-dev-menu": { - "version": "58.0.11", - "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-58.0.11.tgz", - "integrity": "sha512-kOqJ2wGsmM+tYf96mEy06WSBbX/cOx2KgmaouuqQX24/zpBPKlcLUQGgV75iGBXIxS6sczBO7qBp/MifFOud3w==", + "version": "58.0.12", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-58.0.12.tgz", + "integrity": "sha512-TahJsEFlYEGXLXt3SY10VkhImSHtT2Xa14eAujtmM3NefCMGqgEYj0xTqcg8O2RB4ZrGameYslnR4GkkSxPASQ==", "license": "MIT", "dependencies": { "expo-dev-menu-interface": "~58.0.1" @@ -9362,10 +9501,20 @@ "expo": "*" } }, + "node_modules/expo-file-system": { + "version": "58.0.13", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-58.0.13.tgz", + "integrity": "sha512-+/1aFErkawyky5UdWI06zYeO2DeHUnfaWnYHeiI8M8Z/sB6L66gI3WUxqtvUvKJoaM0Ti1CximD3x3JwteLYIg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-font": { - "version": "58.0.10", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-58.0.10.tgz", - "integrity": "sha512-13orvRCiXg3EuirjMznWTKwFzhKHvtHRkUdgbvolbkyA1+EAbYDpp7LJMnvDnbTFU/m8Kb5awvOiaGzPYQ/ojg==", + "version": "58.0.11", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-58.0.11.tgz", + "integrity": "sha512-6r7s6xk2L2orvum+x+TY1fzEcMtuCBRQTLgC/Su2uLzNPpT4L+E+S4IAGy8ZIjeVvwk2j2pj+HiZ23O7eh1PGQ==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -9391,12 +9540,12 @@ "license": "MIT" }, "node_modules/expo-linking": { - "version": "58.0.14", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-58.0.14.tgz", - "integrity": "sha512-cYzi8CTdllCTnEk+jmlaFXUZQudGlYv0KHw/DBvJIvsh4FYKMv/De2FlMObaXPU7XnGpRkBwuBKfIyAYjSDORw==", + "version": "58.0.15", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-58.0.15.tgz", + "integrity": "sha512-N6jmeKRsMO+DXmAH1AnStVEXK4FpL3fMzam9dJWIhd/FlMt/DCNRGUn2cqM7kNsDPSHGYGBIOCGsrW+Akp0xwA==", "license": "MIT", "dependencies": { - "expo-constants": "~58.0.13", + "expo-constants": "~58.0.14", "invariant": "^2.2.4" }, "peerDependencies": { @@ -9418,13 +9567,13 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "58.0.15", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-58.0.15.tgz", - "integrity": "sha512-eTeWBS2RWdp2V/LlhjI5JI/5ACMfrdOax9iBpOshDOAZHsncbAKN2fkpmvyAtvVbj1DUJ04ejINUTyDDLgo2yg==", + "version": "58.0.16", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-58.0.16.tgz", + "integrity": "sha512-YglXoBBc3QwqqFfFQD1XVfpxXdF9RkuZOVxWTn5uHPiyvXSMFiVO16GnV+VSd+njKld1EJt7vQucCsDiLp4xVA==", "license": "MIT", "dependencies": { "@expo/platforms": "^58.0.1", - "@expo/require-utils": "^58.0.4", + "@expo/require-utils": "^58.0.5", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" @@ -9434,9 +9583,9 @@ } }, "node_modules/expo-modules-cli": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/expo-modules-cli/-/expo-modules-cli-0.3.2.tgz", - "integrity": "sha512-iSIZeNaqi5ku0H3pk2fAyIlM25TWveRr9Xcc+bIZSokhZ0ERLtT5Xc1qVZwszSo0GbXXEoA8zwiGnvaw1DydCQ==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/expo-modules-cli/-/expo-modules-cli-0.4.0.tgz", + "integrity": "sha512-NuvnnEFL+UMuQSjykg5ZhV0oxkOww8exJbZh+75pFF7DJhE3kQtfnOd4Yq4G6PrkVnqbeuKsqf6zX8V9p65ZYw==", "license": "MIT", "dependencies": { "@babel/parser": "^7.20.0", @@ -9450,13 +9599,13 @@ } }, "node_modules/expo-modules-core": { - "version": "58.0.26", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-58.0.26.tgz", - "integrity": "sha512-HOuHt0nCBVv3E8+yMGeR6N6xa4WH+81CLqU/pWjDykt2NUBYGfXVMaWPsjwuBfeYOB/DcJTJR40yq+y9Ku/YcQ==", + "version": "58.0.28", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-58.0.28.tgz", + "integrity": "sha512-2FCnT8mIubdQAHVtX9fVMN9qajF7hGDC3CjxxBG0HlyK7NEsxUDKkukiNVzqQpwiI+jJx7ysG0YvATwKKlu1sw==", "license": "MIT", "dependencies": { - "expo-modules-jsi": "~58.0.19", - "expo-modules-macros": "0.22.0", + "expo-modules-jsi": "~58.0.21", + "expo-modules-macros": "0.27.1", "invariant": "^2.2.4" }, "peerDependencies": { @@ -9471,30 +9620,30 @@ } }, "node_modules/expo-modules-jsi": { - "version": "58.0.19", - "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-58.0.19.tgz", - "integrity": "sha512-dmYjRQnkSUdul6jZN3+yq6ZPKwiatUzLJW/pcXYx16q7QJW3i3+4RZ+MnxNeLNgg1rLrCR9F+dIgeh01+FxaFQ==", + "version": "58.0.21", + "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-58.0.21.tgz", + "integrity": "sha512-6FG3jKt89s+qolbalas+OE5gQjDYu/sfV7HVAFEw3JVtkS1+MVfCvQLxp3RAm85bVNO0vgfnbn5z6A8WSDWalA==", "license": "MIT", "peerDependencies": { "react-native": "*" } }, "node_modules/expo-modules-macros": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/expo-modules-macros/-/expo-modules-macros-0.22.0.tgz", - "integrity": "sha512-IHY+FkILfG19qsyf9G/z7dcWVgsyqdkWsvvob3n5nWKa2meCCAGeEjp42N68fG8Ta6dn/bsceuma7uh2i6ho9g==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/expo-modules-macros/-/expo-modules-macros-0.27.1.tgz", + "integrity": "sha512-xWNI+LpK4Pdmj52ygNYlxefZDhw4mpe6s5yVCLXpqtOyU3PPrFGJJJnDyBpOsOv+mXiYLeaszEEJEpDKBObZ7A==", "license": "MIT", "optionalDependencies": { - "@expo/modules-macros-linux-arm64": "0.22.0", - "@expo/modules-macros-linux-x64": "0.22.0", - "@expo/modules-macros-win32-arm64": "0.22.0", - "@expo/modules-macros-win32-x64": "0.22.0" + "@expo/modules-macros-linux-arm64": "0.27.1", + "@expo/modules-macros-linux-x64": "0.27.1", + "@expo/modules-macros-win32-arm64": "0.27.1", + "@expo/modules-macros-win32-x64": "0.27.1" } }, "node_modules/expo-router": { - "version": "58.0.26", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-58.0.26.tgz", - "integrity": "sha512-NaiGYRmAiwsRLeGLU4yEMeEDHzF1W8rOu1LKjpNgfVEWkPNlpqVjyXzgkPErg7xosQUBs33Dik3uKtz5PpDSUw==", + "version": "58.0.28", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-58.0.28.tgz", + "integrity": "sha512-figS9IpRn1h3iYUii1PY7ka/6XSIQ0EBKR5HqKcDl8gadN4hGfS90WFU9gjpquIdM72lS3Dxm6CT2vGQmsiQSg==", "license": "MIT", "dependencies": { "@expo/log-box": "^58.0.13", @@ -9503,7 +9652,7 @@ "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", - "@react-native/normalize-colors": "0.88.0", + "@react-native/normalize-colors": "0.88.1", "client-only": "^0.0.1", "expo-glass-effect": "^58.0.5", "expo-server": "^58.0.6", @@ -9520,11 +9669,11 @@ "peerDependencies": { "@expo/log-box": "^58.0.13", "@expo/metro-runtime": "^58.0.15", - "@expo/ui": "^58.0.23", + "@expo/ui": "^58.0.25", "@testing-library/react-native": ">= 14.0.0", "expo": "*", - "expo-constants": "^58.0.13", - "expo-linking": "^58.0.14", + "expo-constants": "^58.0.14", + "expo-linking": "^58.0.15", "expo-symbols": "^58.0.4", "react": "*", "react-dom": "*", @@ -9567,6 +9716,12 @@ } } }, + "node_modules/expo-router/node_modules/@react-native/normalize-colors": { + "version": "0.88.1", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.88.1.tgz", + "integrity": "sha512-2SAKiLyZRf+peBLkhhJWGY5vC26w1Uj9QBgUZvjAnOxjgeEKUGBE5riPS0R7+YoqSrRL0w8ucwZB4M+vvy9HMg==", + "license": "MIT" + }, "node_modules/expo-router/node_modules/react-is": { "version": "19.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.1.tgz", @@ -9617,12 +9772,12 @@ } }, "node_modules/expo-system-ui": { - "version": "58.0.6", - "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-58.0.6.tgz", - "integrity": "sha512-K1+GIYhNDc4J0lJAL0m8giesP1dgfavhN+joA6FQ45r1FXAWwS1PaueCiCJaq5hB6z5iAYT7b9XqYp76UU65yw==", + "version": "58.0.7", + "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-58.0.7.tgz", + "integrity": "sha512-XxrMY93bT8MpZUlc6uRKzkeUoXhBi/Eq8j3qx2pjk0ARCV+0Ed1eqzmgxuAoKZ+10cyOZVSke5Hhi78NVFReuQ==", "license": "MIT", "dependencies": { - "@react-native/normalize-colors": "0.88.0", + "@react-native/normalize-colors": "0.88.1", "debug": "^4.3.2" }, "peerDependencies": { @@ -9636,6 +9791,12 @@ } } }, + "node_modules/expo-system-ui/node_modules/@react-native/normalize-colors": { + "version": "0.88.1", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.88.1.tgz", + "integrity": "sha512-2SAKiLyZRf+peBLkhhJWGY5vC26w1Uj9QBgUZvjAnOxjgeEKUGBE5riPS0R7+YoqSrRL0w8ucwZB4M+vvy9HMg==", + "license": "MIT" + }, "node_modules/expo-updates-interface": { "version": "58.0.1", "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-58.0.1.tgz", @@ -9646,38 +9807,38 @@ } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "58.1.15", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-58.1.15.tgz", - "integrity": "sha512-+gBvfvmttvNqs8ceJe1SdiWBa6reocb/ZC8jMxsF8qFlCijAUQNqFMGCPkT/6zasFkQLUL8ZOFhf/9DKj2XaLg==", + "version": "58.1.17", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-58.1.17.tgz", + "integrity": "sha512-2WKvwrq9Sr0jj4vFVt7dPtbHoxFzaSp4s5Bsk1FDmCQjZhQlWZjb7j6igBYCs0nBzZZ4UyBdSMY2hwmDhRmPQA==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.7", - "@expo/config": "~58.0.8", - "@expo/config-plugins": "~58.0.10", + "@expo/config": "~58.0.9", + "@expo/config-plugins": "~58.0.11", "@expo/devcert": "^1.2.2", "@expo/env": "~2.5.3", - "@expo/image-utils": "^0.12.5", - "@expo/inline-modules": "^0.2.7", + "@expo/image-utils": "^0.12.6", + "@expo/inline-modules": "^0.2.8", "@expo/json-file": "^11.2.1", "@expo/log-box": "^58.0.13", "@expo/log-box-utils": "^58.0.2", "@expo/metro": "~58.0.0", - "@expo/metro-config": "58.0.17", + "@expo/metro-config": "58.0.19", "@expo/metro-file-map": "^58.0.5", "@expo/osascript": "^2.8.1", "@expo/package-manager": "^1.14.2", "@expo/platforms": "^58.0.1", "@expo/plist": "^0.10.1", - "@expo/prebuild-config": "^58.0.16", - "@expo/require-utils": "^58.0.4", - "@expo/router-server": "^58.0.17", + "@expo/prebuild-config": "^58.0.17", + "@expo/require-utils": "^58.0.5", + "@expo/router-server": "^58.0.19", "@expo/schema-utils": "^58.0.2", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.1", "@expo/xcpretty": "^4.4.6", - "@react-native/asset-utils": "0.88.0", - "@react-native/dev-middleware": "0.88.0", - "@react-native/js-polyfills": "0.88.0", + "@react-native/asset-utils": "0.88.1", + "@react-native/dev-middleware": "0.88.1", + "@react-native/js-polyfills": "0.88.1", "2g": "^1.0.1", "accepts": "^1.3.8", "agent-cli-detector": "0.1.7", @@ -9733,9 +9894,9 @@ } }, "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { - "version": "58.0.17", - "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-58.0.17.tgz", - "integrity": "sha512-9xZqpWIvb9/2ZH038WrWAtXQJsVep1ZG9FYh7cpoz6RGRo9SkmG97kMkQW7slKzL85Ppx8iW+68EHK9SQf/Zog==", + "version": "58.0.19", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-58.0.19.tgz", + "integrity": "sha512-yyqiU830tiKybH6Vr0BqXbcSCdCj5+lK/MR6l/DG5Jbv3s4jXKqa8vb4QuWRO2mMvk0xUeJO7RNyWEtGF9TYEA==", "license": "MIT", "dependencies": { "debug": "^4.3.4" @@ -9743,8 +9904,8 @@ "peerDependencies": { "@expo/metro-runtime": "^58.0.15", "expo": "*", - "expo-constants": "^58.0.13", - "expo-font": "^58.0.10", + "expo-constants": "^58.0.14", + "expo-font": "^58.0.11", "expo-router": "*", "expo-server": "^58.0.6", "react": "*", @@ -9791,57 +9952,96 @@ } } }, - "node_modules/expo/node_modules/@expo/metro-config": { - "version": "58.0.17", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-58.0.17.tgz", - "integrity": "sha512-T2i8chQ5PsF6zgS10AixIteBlPu8oMAcqoD9MPk6CBQvcAdM4bE/x9VuDxuqKcAvFg+bRZMq1JfGAjJcRSmKkQ==", + "node_modules/expo/node_modules/@expo/ws-tunnel": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.1.tgz", + "integrity": "sha512-mljsRffbOHzmSimYQbQu9h9NR3alwCCwEEouRGFL4QRTt8dNvsNtKEPa/5jHmtDLDtsh4xXsWKwzjN9BTpeXHQ==", + "license": "MIT", + "peerDependencies": { + "ws": "^8.0.0" + } + }, + "node_modules/expo/node_modules/@react-native/asset-utils": { + "version": "0.88.1", + "resolved": "https://registry.npmjs.org/@react-native/asset-utils/-/asset-utils-0.88.1.tgz", + "integrity": "sha512-BLb5FF7NgggGYiCsA7KKkK2qFhn0K+6iGXOI+4Ucd+G7Robzw9Gt4WsWwxLt+Od+G+/hyqfz8ASokyWFZ6VcLw==", + "license": "MIT", + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/expo/node_modules/@react-native/debugger-frontend": { + "version": "0.88.1", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.88.1.tgz", + "integrity": "sha512-4ZjH2093hZWWgsYs5ExqjzJX5uCS4R5yZa17eRHsQClUc6RjlV61kLdIk722sQReLL+NYtBn2RJtdcQtLCwiug==", + "license": "BSD-3-Clause", + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/expo/node_modules/@react-native/debugger-shell": { + "version": "0.88.1", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.88.1.tgz", + "integrity": "sha512-vrOLlxkp/417qcSUiS3x3gmdoQHYA/26oEKIWPB4EekJG0uJxKXsMR0tHIDiJWO0JH/2BO3M+507DjR1U74jQg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.20.0", - "@babel/generator": "^7.20.5", - "@expo/config": "~58.0.8", - "@expo/env": "~2.5.3", - "@expo/json-file": "~11.2.1", - "@expo/metro": "~58.0.0", - "@expo/platforms": "^58.0.1", - "@expo/require-utils": "^58.0.4", - "@expo/spawn-async": "^1.8.0", - "@jridgewell/gen-mapping": "^0.3.13", - "@jridgewell/remapping": "^2.3.5", - "@jridgewell/sourcemap-codec": "^1.5.5", - "@jridgewell/trace-mapping": "^0.3.31", - "@react-native/js-polyfills": "0.88.0", - "2g": "^1.0.1", - "browserslist": "^4.25.0", - "chalk": "^4.1.0", - "debug": "^4.3.2", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "hermes-parser": "^0.36.0", - "jsc-safe-url": "^0.2.4", - "lightningcss": "^1.30.1", - "noxcturnal": "^0.1.8", - "picomatch": "^4.0.4", - "postcss": "^8.5.23", - "resolve-from": "^5.0.0" + "cross-spawn": "^7.0.6", + "debug": "^4.4.0", + "fb-dotslash": "0.5.8" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/expo/node_modules/@react-native/dev-middleware": { + "version": "0.88.1", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.88.1.tgz", + "integrity": "sha512-0smOWE3NCdEYUwPiEdiwVXjTdvXcwSejviCM0YZB0Dj6HaQ+uu4hpSrvgERDevr5dvtSaqr8N4TdYZCLXiVgFw==", + "license": "MIT", + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.88.1", + "@react-native/debugger-shell": "0.88.1", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^8.4.2", + "serve-static": "^1.16.2", + "ws": "^7.5.10" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/expo/node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" }, "peerDependencies": { - "expo": "*" + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" }, "peerDependenciesMeta": { - "expo": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { "optional": true } } }, - "node_modules/expo/node_modules/@expo/ws-tunnel": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.1.tgz", - "integrity": "sha512-mljsRffbOHzmSimYQbQu9h9NR3alwCCwEEouRGFL4QRTt8dNvsNtKEPa/5jHmtDLDtsh4xXsWKwzjN9BTpeXHQ==", + "node_modules/expo/node_modules/@react-native/js-polyfills": { + "version": "0.88.1", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.88.1.tgz", + "integrity": "sha512-B62O6zVMYW7kK8dxIuCwpdJ5RYcIvET6ItScm0sfsTibJs+llS0krUVSAOHnEUn3lbFMfw+o+sNdWREOAc6FfQ==", "license": "MIT", - "peerDependencies": { - "ws": "^8.0.0" + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" } }, "node_modules/expo/node_modules/accepts": { @@ -9857,16 +10057,6 @@ "node": ">= 0.6" } }, - "node_modules/expo/node_modules/expo-file-system": { - "version": "58.0.11", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-58.0.11.tgz", - "integrity": "sha512-accVZyXeA8RYUNk52ydkcHPnQaYMVJWlWvdF8pU0xLvt7ZPbaOgGzbgbeyTswjlsKSTmrAsKos4prx0PVM4ERA==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-keep-awake": { "version": "58.0.3", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-58.0.3.tgz", @@ -12771,9 +12961,9 @@ } }, "node_modules/jest-expo": { - "version": "58.0.12", - "resolved": "https://registry.npmjs.org/jest-expo/-/jest-expo-58.0.12.tgz", - "integrity": "sha512-JkFTMxtSCBTewlbPn+ZbEKp11nmNYNFpRdMeFGQfVFG75rqOasLimlE/r+lIQ1eI1vZoWlszQEJNa66pIcpkkA==", + "version": "58.0.13", + "resolved": "https://registry.npmjs.org/jest-expo/-/jest-expo-58.0.13.tgz", + "integrity": "sha512-gmQUfdG83oiOSasPRP0Qp0WcEEihVBkqr3fd5kT5nHDBEH7liw/e4s0+UhdV7LoOLLtgPIgYd4ksEhdy97oWqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12796,7 +12986,7 @@ "jest": "bin/jest.js" }, "peerDependencies": { - "@react-native/jest-preset": "^0.88.0", + "@react-native/jest-preset": "^0.88.1", "expo": "*", "jest": "^29.0.0 || ^30.0.0", "react-native": "*", @@ -16895,9 +17085,9 @@ } }, "node_modules/noxcturnal": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/noxcturnal/-/noxcturnal-0.1.9.tgz", - "integrity": "sha512-TzgJ3xjHpa/WLBNLnyj1eiy2lpNggv73RW0Fo9+xTGPMwdkIk2+G/H4w9uGBDjMZr7FccjS0CWyPq8dFYVnwGQ==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/noxcturnal/-/noxcturnal-0.1.11.tgz", + "integrity": "sha512-nKgZ4mQpWz5Nry5KRqRcyCv37Uc24VH9UkNsIJJ58sC7ubyXtaiP4zfjOAsGiTVZIJ6rcfkmbYS0P4plVhdHEQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -16907,14 +17097,14 @@ "node": ">=18" }, "optionalDependencies": { - "@noxcturnal/noxcturnal-darwin-arm64": "0.1.9", - "@noxcturnal/noxcturnal-darwin-x64": "0.1.9", - "@noxcturnal/noxcturnal-linux-arm64-gnu": "0.1.9", - "@noxcturnal/noxcturnal-linux-arm64-musl": "0.1.9", - "@noxcturnal/noxcturnal-linux-x64-gnu": "0.1.9", - "@noxcturnal/noxcturnal-linux-x64-musl": "0.1.9", - "@noxcturnal/noxcturnal-win32-arm64-msvc": "0.1.9", - "@noxcturnal/noxcturnal-win32-x64-msvc": "0.1.9" + "@noxcturnal/noxcturnal-darwin-arm64": "0.1.11", + "@noxcturnal/noxcturnal-darwin-x64": "0.1.11", + "@noxcturnal/noxcturnal-linux-arm64-gnu": "0.1.11", + "@noxcturnal/noxcturnal-linux-arm64-musl": "0.1.11", + "@noxcturnal/noxcturnal-linux-x64-gnu": "0.1.11", + "@noxcturnal/noxcturnal-linux-x64-musl": "0.1.11", + "@noxcturnal/noxcturnal-win32-arm64-msvc": "0.1.11", + "@noxcturnal/noxcturnal-win32-x64-msvc": "0.1.11" } }, "node_modules/npm-package-arg": { @@ -17588,9 +17778,9 @@ } }, "node_modules/postcss": { - "version": "8.5.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", - "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "funding": [ { "type": "opencollective", diff --git a/package.json b/package.json index 54e483a..406388e 100644 --- a/package.json +++ b/package.json @@ -17,20 +17,20 @@ }, "dependencies": { "@expo/vector-icons": "^15.0.2", - "expo": "58.0.19", - "expo-asset": "~58.0.18", - "expo-camera": "~58.0.12", + "expo": "58.0.21", + "expo-asset": "~58.0.19", + "expo-camera": "~58.0.14", "expo-clipboard": "~58.0.4", - "expo-constants": "~58.0.13", + "expo-constants": "~58.0.14", "expo-crypto": "~58.0.7", - "expo-dev-client": "~58.0.15", - "expo-font": "~58.0.10", - "expo-linking": "~58.0.14", - "expo-router": "~58.0.26", + "expo-dev-client": "~58.0.17", + "expo-font": "~58.0.11", + "expo-linking": "~58.0.15", + "expo-router": "~58.0.28", "expo-secure-store": "~58.0.2", "expo-sqlite": "~58.0.14", "expo-status-bar": "~58.0.4", - "expo-system-ui": "~58.0.6", + "expo-system-ui": "~58.0.7", "react": "19.3.0", "react-dom": "19.3.0", "react-native": "0.88.0", @@ -48,7 +48,7 @@ "eslint": "^9.39.5", "eslint-config-expo": "~58.0.4", "jest": "~30.5.2", - "jest-expo": "~58.0.12", + "jest-expo": "~58.0.13", "prettier": "^3.9.10", "typescript": "~6.0.3", "node-forge": "file:vendor/node-forge" diff --git a/src/connection/api-client.test.ts b/src/connection/api-client.test.ts index 417a1d2..2116a9d 100644 --- a/src/connection/api-client.test.ts +++ b/src/connection/api-client.test.ts @@ -2,16 +2,17 @@ import { ApiClient } from "./api-client"; const fake = jest.fn(); test("sends JSON and credentials only to normalized hosted origin with redirect error", async () => { fake.mockResolvedValue(new Response('{"data":[]}', { status: 200 })); - const api = new ApiClient("https://pc.opentunnel.xyz", "secret", fake); + const api = new ApiClient("https://pc.tunnel.bharatcode.ai", "secret", fake); await api.request("POST", "/api/session", { body: {}, query: { "location[directory]": "/project x" }, }); expect(fake.mock.calls[0][0]).toBe( - "https://pc.opentunnel.xyz/api/session?location%5Bdirectory%5D=%2Fproject+x", + "https://pc.tunnel.bharatcode.ai/api/session?location%5Bdirectory%5D=%2Fproject+x", ); expect(fake.mock.calls[0][1]).toMatchObject({ redirect: "error", + credentials: "omit", headers: { authorization: "Bearer secret", "content-type": "application/json", @@ -23,7 +24,7 @@ test("sends JSON and credentials only to normalized hosted origin with redirect }); test("401 is distinct from a transient connection failure", async () => { fake.mockResolvedValue(new Response("{}", { status: 401 })); - const api = new ApiClient("https://pc.opentunnel.xyz", "x", fake); + const api = new ApiClient("https://pc.tunnel.bharatcode.ai", "x", fake); await expect(api.request("GET", "/mobile/v1/health")).rejects.toMatchObject({ status: 401, }); diff --git a/src/connection/api-client.ts b/src/connection/api-client.ts index 81077bd..0617b27 100644 --- a/src/connection/api-client.ts +++ b/src/connection/api-client.ts @@ -56,6 +56,7 @@ export class ApiClient implements API { const response = await this.fetcher(url.toString(), { method, redirect: "error", + credentials: "omit", signal: controller.signal, headers: { accept: stream ? "text/event-stream" : "application/json", diff --git a/src/connection/connection.test.ts b/src/connection/connection.test.ts index 61c576c..7cbe5bd 100644 --- a/src/connection/connection.test.ts +++ b/src/connection/connection.test.ts @@ -2,7 +2,7 @@ import { validateOrigin, parsePairingPayload } from "./pairing-payload"; import { decodeSse } from "./sse"; const now = 1_000_000; -const payload = (origin = "https://pc.opentunnel.xyz") => +const payload = (origin = "https://pc.tunnel.bharatcode.ai") => JSON.stringify({ v: 1, origin, @@ -11,18 +11,24 @@ const payload = (origin = "https://pc.opentunnel.xyz") => expiresAt: now + 300_000, }); test("normalizes only hosted tunnel origins", () => { - expect(validateOrigin("https://pc.opentunnel.xyz:443/")).toBe( - "https://pc.opentunnel.xyz", + expect(validateOrigin("https://pc.tunnel.bharatcode.ai:443/")).toBe( + "https://pc.tunnel.bharatcode.ai", ); for (const origin of [ - "http://pc.opentunnel.xyz", - "https://opentunnel.xyz", - "https://pc.opentunnel.xyz.evil.com", - "https://pc.opentunnel.xyz/path", - "https://u:p@pc.opentunnel.xyz", - "https://pc.opentunnel.xyz?x=1", - "https://pc.opentunnel.xyz#", - "https://pc.opentunnel.xyz:444", + "http://pc.tunnel.bharatcode.ai", + "https://tunnel.bharatcode.ai", + "https://pc.opentunnel.xyz", + "https://pc.tunnel.bharatcode.ai\\@evil.com", + "https://pc..tunnel.bharatcode.ai", + "https://-pc.tunnel.bharatcode.ai", + "https://pc.tunnel.bharatcode.ai/../", + " https://pc.tunnel.bharatcode.ai", + "https://pc.tunnel.bharatcode.ai.evil.com", + "https://pc.tunnel.bharatcode.ai/path", + "https://u:p@pc.tunnel.bharatcode.ai", + "https://pc.tunnel.bharatcode.ai?x=1", + "https://pc.tunnel.bharatcode.ai#", + "https://pc.tunnel.bharatcode.ai:444", ]) expect(() => validateOrigin(origin)).toThrow(); }); @@ -67,3 +73,22 @@ test("rejects unbounded event frames", async () => { }; await expect(consume()).rejects.toThrow("Event exceeds"); }); + +test("opens only credential-free Desktop pairing links", () => { + const link = + "bharatcode://pair?origin=https%3A%2F%2Fpc.tunnel.bharatcode.ai&desktopID=pc_test"; + expect(parsePairingPayload(link, now)).toEqual({ + v: 1, + origin: "https://pc.tunnel.bharatcode.ai", + desktopID: "pc_test", + }); + for (const value of [ + link + "&token=secret", + link + "&desktopID=pc_other", + link + "#secret", + link.replace("bharatcode:", "https:"), + link.replace("pc_test", "evil"), + link.replace("pair?", "pair/path?"), + ]) + expect(() => parsePairingPayload(value, now)).toThrow(); +}); diff --git a/src/connection/pairing-intent.test.ts b/src/connection/pairing-intent.test.ts new file mode 100644 index 0000000..71ccea4 --- /dev/null +++ b/src/connection/pairing-intent.test.ts @@ -0,0 +1,55 @@ +import { redirectSystemPath } from "../../app/+native-intent"; + +const link = + "bharatcode://pair?origin=https%3A%2F%2Fpc.tunnel.bharatcode.ai&desktopID=pc_one"; +const route = (path: string, initial = false) => + new URL(redirectSystemPath({ path, initial }), "https://router.invalid"); + +test.each(["bharatcode://", "bharatcode:///"])( + "a cold launcher root opens the app without submitting a pairing claim: %s", + (path) => expect(redirectSystemPath({ path, initial: true })).toBe("/"), +); + +test.each([ + "bharatcode://?token=do-not-copy", + "bharatcode:///?token=do-not-copy", + "bharatcode:///pair", +])("cold malformed links still fail closed: %s", (path) => { + const target = route(path, true); + expect(target.searchParams.get("link")).toBe("invalid"); + expect(target.href).not.toContain("do-not-copy"); +}); + +test("native cold and warm pairing intents retain the validated public URI", () => { + for (const initial of [true, false]) { + const target = route(link, initial); + expect(target.pathname).toBe("/pair"); + expect(target.searchParams.get("link")).toBe(link); + } +}); + +test("opening the same public link again delivers a new pairing attempt", () => { + const first = route(link), + second = route(link); + expect(first.searchParams.get("delivery")).not.toBe( + second.searchParams.get("delivery"), + ); +}); + +test.each([ + link + "&token=do-not-copy", + link + "&desktopID=pc_two", + link.replace("https%3A", "http%3A"), + link.replace("//pair", "//user:do-not-copy@pair"), + link.replace("//pair", "///pair"), +])("invalid native pairing intents cannot reach claim data: %s", (input) => { + const target = route(input); + expect(target.pathname).toBe("/pair"); + expect(target.searchParams.get("link")).toBe("invalid"); + expect(target.href).not.toContain("do-not-copy"); +}); + +test.each(["/pair", "/(tabs)/chats", "exp://127.0.0.1:8081/--/"])( + "ordinary and development routes remain usable: %s", + (path) => expect(redirectSystemPath({ path, initial: true })).toBe(path), +); diff --git a/src/connection/pairing-payload.ts b/src/connection/pairing-payload.ts index ef47bdb..3e24823 100644 --- a/src/connection/pairing-payload.ts +++ b/src/connection/pairing-payload.ts @@ -2,29 +2,18 @@ export interface PairingPayload { v: 1; origin: string; desktopID: string; - code: string; - expiresAt: number; + code?: string; + expiresAt?: number; } export function validateOrigin(value: string): string { - let url: URL; - try { - url = new URL(value); - } catch { - throw new Error("Scan a valid BharatCode Desktop pairing QR."); - } if ( - url.protocol !== "https:" || - !url.hostname.endsWith(".opentunnel.xyz") || - url.hostname === "opentunnel.xyz" || - url.port || - url.username || - url.password || - url.pathname !== "/" || - value.includes("?") || - value.includes("#") || - value.includes("\\") + typeof value !== "string" || + !/^https:\/\/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+tunnel\.bharatcode\.ai(?::443)?\/?$/i.test( + value, + ) ) throw new Error("Scan a valid BharatCode Desktop pairing QR."); + const url = new URL(value); return url.origin; } export function parsePairingPayload( @@ -32,6 +21,30 @@ export function parsePairingPayload( now = Date.now(), ): PairingPayload { let p: PairingPayload; + if (text.startsWith("bharatcode:")) { + const url = new URL(text); + if ( + url.protocol !== "bharatcode:" || + url.hostname !== "pair" || + url.pathname !== "" || + url.hash || + url.username || + url.password || + url.port || + [...url.searchParams.keys()].some( + (key) => !["origin", "desktopID"].includes(key), + ) || + url.searchParams.getAll("origin").length !== 1 || + url.searchParams.getAll("desktopID").length !== 1 || + !/^pc_[A-Za-z0-9_-]+$/.test(url.searchParams.get("desktopID") ?? "") + ) + throw new Error("This link is not a BharatCode Desktop pairing link."); + return { + v: 1, + origin: validateOrigin(url.searchParams.get("origin") ?? ""), + desktopID: url.searchParams.get("desktopID")!, + }; + } try { p = JSON.parse(text); } catch { @@ -44,8 +57,8 @@ export function parsePairingPayload( !/^pc_[A-Za-z0-9_-]+$/.test(p.desktopID ?? "") || !/^[A-Za-z0-9_-]{43}$/.test(p.code ?? "") || !Number.isSafeInteger(p.expiresAt) || - p.expiresAt <= now || - p.expiresAt - now > 360_000 + p.expiresAt! <= now || + p.expiresAt! - now > 360_000 ) throw new Error( "Pairing code is invalid or expired. Refresh it on Desktop.", diff --git a/src/features/conversation/Message.test.tsx b/src/features/conversation/Message.test.tsx new file mode 100644 index 0000000..9449919 --- /dev/null +++ b/src/features/conversation/Message.test.tsx @@ -0,0 +1,87 @@ +import { render } from "@testing-library/react-native"; +import { Message } from "./Message"; +import type { ChatMessage } from "../../domain/types"; + +jest.mock("@expo/vector-icons", () => ({ Ionicons: () => null })); +jest.mock("expo-clipboard", () => ({ setStringAsync: jest.fn() })); + +function textViews(tree: unknown): number { + if (!tree || typeof tree !== "object") return 0; + const node = tree as { type?: string; children?: unknown[] }; + return ( + (node.type === "Text" ? 1 : 0) + + (node.children ?? []).reduce( + (sum, child) => sum + textViews(child), + 0, + ) + ); +} + +test("long plain replies preserve every line without a native text view per token", async () => { + const reply: ChatMessage = { + id: "reply", + role: "assistant", + text: "1\n2\n3", + tools: [], + }; + const screen = await render(); + try { + const baseline = textViews(screen.toJSON()); + expect(baseline).toBeGreaterThan(0); + const text = Array.from({ length: 600 }, (_, i) => String(i + 1)).join( + "\n", + ); + await screen.rerender(); + expect(screen.getByText(text)).toBeTruthy(); + expect(textViews(screen.toJSON())).toBeLessThanOrEqual(baseline + 10); + } finally { + await screen.unmount(); + } +}); + +test("formatted reply spans keep their Markdown styling", async () => { + const screen = await render( + , + ); + try { + expect(screen.getByText("bold")).toHaveStyle({ fontWeight: "bold" }); + expect(screen.getByText("code")).toHaveStyle({ + fontFamily: "JetBrainsMono", + }); + expect(screen.getByText(/Next line\./)).toBeTruthy(); + } finally { + await screen.unmount(); + } +}); + +test.each([ + ["#", 25, 32], + ["##", 21, 29], + ["###", 18, 26], +])( + "plain %s headings keep their heading styles", + async (prefix, fontSize, lineHeight) => { + const screen = await render( + , + ); + try { + expect(screen.getByText("Heading")).toHaveStyle({ fontSize, lineHeight }); + } finally { + await screen.unmount(); + } + }, +); diff --git a/src/features/conversation/Message.tsx b/src/features/conversation/Message.tsx index b962906..65d834d 100644 --- a/src/features/conversation/Message.tsx +++ b/src/features/conversation/Message.tsx @@ -175,9 +175,20 @@ export const Message = memo(function Message({ /> ), code_block: (node) => , - textgroup: (node, children) => ( + textgroup: (node, children, parents) => ( - {children} + {parents.every((parent) => + ["paragraph", "body"].includes(parent.type), + ) && + node.children.every((child) => + ["text", "softbreak", "hardbreak"].includes(child.type), + ) + ? node.children + .map((child) => + child.type === "text" ? child.content : "\n", + ) + .join("") + : children} ), }} diff --git a/src/features/pairing/PairingScreen.test.tsx b/src/features/pairing/PairingScreen.test.tsx index 5188a71..5a7638f 100644 --- a/src/features/pairing/PairingScreen.test.tsx +++ b/src/features/pairing/PairingScreen.test.tsx @@ -1,4 +1,4 @@ -import { render } from "@testing-library/react-native"; +import { fireEvent, render } from "@testing-library/react-native"; import PairingScreen from "./PairingScreen"; import { StoreProvider } from "../../state/react"; import { AppStore } from "../../state/app-store"; @@ -6,9 +6,13 @@ import { ConversationStore, MemoryDatabase, } from "../../storage/conversation-store"; +let mockParams: Record = {}; const replace = jest.fn(); const mockRouter = { replace, back: jest.fn() }; -jest.mock("expo-router", () => ({ useRouter: () => mockRouter })); +jest.mock("expo-router", () => ({ + useRouter: () => mockRouter, + useLocalSearchParams: () => mockParams, +})); jest.mock("@expo/vector-icons", () => ({ Ionicons: () => null })); jest.mock("expo-camera", () => ({ CameraView: () => null, @@ -36,3 +40,55 @@ test("Add computer stays on pairing even when another computer is selected", asy expect(replace).not.toHaveBeenCalled(); store.dispose(); }); + +test("routed pairing deliveries start and retry a real claim without a URL listener", async () => { + mockParams = { + link: "bharatcode://pair?origin=https%3A%2F%2Fpc.tunnel.bharatcode.ai&desktopID=pc_one", + delivery: "first", + }; + let claims = 0; + const store = new AppStore({ + persistence: new ConversationStore(new MemoryDatabase()), + credentials: { + get: async () => undefined, + set: async () => {}, + remove: async () => {}, + }, + apiFactory: () => ({ + request: async (_method, path) => + (path === "/mobile/v1/claim" + ? { + confirmation: String(123455 + ++claims), + claimID: "claim_test", + claimSecret: "secret_test", + expiresAt: Date.now() + 300_000, + } + : { state: "waiting" }) as never, + events: async function* () {}, + liveEvents: async function* () {}, + close() {}, + }), + }); + const screen = await render( + + + , + ); + try { + expect( + await screen.findByText("Waiting for approval on your computer"), + ).toBeTruthy(); + expect(screen.getByText("123456")).toBeTruthy(); + await fireEvent.press(screen.getByLabelText("Cancel pairing")); + mockParams = { ...mockParams, delivery: "second" }; + await screen.rerender( + + + , + ); + expect(await screen.findByText("123457")).toBeTruthy(); + } finally { + store.dispose(); + mockParams = {}; + } +}); diff --git a/src/features/pairing/PairingScreen.tsx b/src/features/pairing/PairingScreen.tsx index 8bacb75..d7b8be2 100644 --- a/src/features/pairing/PairingScreen.tsx +++ b/src/features/pairing/PairingScreen.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { Linking, ScrollView, TextInput, View, Platform } from "react-native"; import { CameraView, useCameraPermissions } from "expo-camera"; -import { useRouter } from "expo-router"; +import { useLocalSearchParams, useRouter } from "expo-router"; import { Brand, Button, @@ -20,6 +20,20 @@ export default function PairingScreen() { const router = useRouter(); const initialDevice = useRef(state.device); const scanned = useRef(false); + const { link, delivery } = useLocalSearchParams<{ + link?: string | string[]; + delivery?: string | string[]; + }>(); + const opened = useRef(null); + useEffect(() => { + if (link === undefined || Platform.OS === "web") return; + const intent = JSON.stringify([link, delivery]); + if (intent === opened.current) return; + opened.current = intent; + void store.pair( + typeof link === "string" && typeof delivery === "string" ? link : "", + ); + }, [link, delivery, store]); useEffect(() => () => store.cancelPairing(), [store]); const [scan, setScan] = useState(false), [help, setHelp] = useState(false), diff --git a/src/state/app-store.test.ts b/src/state/app-store.test.ts index 49243db..69ff95b 100644 --- a/src/state/app-store.test.ts +++ b/src/state/app-store.test.ts @@ -9,7 +9,7 @@ const device: DesktopDevice = { id: "pc_one", name: "Computer", platform: "Linux", - origin: "https://pc.opentunnel.xyz", + origin: "https://pc.tunnel.bharatcode.ai", credentialID: "dev_one", }; function setup() { @@ -403,6 +403,134 @@ test("a lost live stream cannot abort an in-flight prompt request", async () => store.dispose(); } }); +test("a burst of streamed text preserves every delta with bounded UI notifications", async () => { + jest.useFakeTimers(); + const { store, api } = setup(); + let emit!: () => void; + api.liveEvents = async function* (_session, signal) { + await new Promise((resolve) => (emit = resolve)); + for (let i = 0; i < 200; i++) + yield { + type: "session.next.text.delta", + data: { + sessionID: "ses_one", + assistantMessageID: "reply", + textID: "text", + delta: `${i}\n`, + }, + }; + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }), + ); + }; + try { + await store.connectDevice(device); + await store.openConversation("ses_one"); + const snapshots: string[] = []; + store.subscribe(() => { + const text = store.getSnapshot().messages.at(-1)?.text; + if (text) snapshots.push(text); + }); + emit(); + await jest.advanceTimersByTimeAsync(100); + expect(store.getSnapshot().messages.at(-1)?.text).toBe( + Array.from({ length: 200 }, (_, i) => `${i}\n`).join(""), + ); + expect(snapshots.length).toBeGreaterThan(0); + expect(snapshots.length).toBeLessThanOrEqual(2); + } finally { + store.dispose(); + jest.useRealTimers(); + } +}); + +test("queued streamed text cannot publish into another conversation or after revocation", async () => { + jest.useFakeTimers(); + const { store, api } = setup(); + let emit!: () => void; + let revoke!: () => void; + api.liveEvents = async function* () { + await new Promise((resolve) => (emit = resolve)); + yield { + type: "session.next.text.delta", + data: { + sessionID: "ses_one", + assistantMessageID: "reply", + textID: "text", + delta: "private reply", + }, + }; + await new Promise((_resolve, reject) => { + revoke = () => reject(new ApiError(401, "Pair again")); + }); + }; + try { + await store.connectDevice(device); + await store.openConversation("ses_one"); + emit(); + await jest.advanceTimersByTimeAsync(0); + store.backToChats(); + await jest.advanceTimersByTimeAsync(100); + expect(store.getSnapshot().messages).toEqual([]); + await store.openConversation("ses_one"); + emit(); + await jest.advanceTimersByTimeAsync(0); + revoke(); + await jest.advanceTimersByTimeAsync(0); + expect(store.getSnapshot().online).toBe(false); + expect(store.getSnapshot().error).toBe("Pair again"); + const messages = store.getSnapshot().messages; + await jest.advanceTimersByTimeAsync(100); + expect(store.getSnapshot().messages).toBe(messages); + expect(store.getSnapshot().online).toBe(false); + } finally { + store.dispose(); + jest.useRealTimers(); + } +}); +test("durable replay text deltas still refresh history", async () => { + jest.useFakeTimers(); + const { store, api } = setup(); + const request = api.request; + let updated = false; + api.request = async (...args) => + args[1].endsWith("/health") + ? ({ + protocol: 1, + desktop: { id: device.id }, + capabilities: { replay: true }, + } as never) + : updated && args[1].endsWith("/message") + ? ({ + data: [ + { id: "replayed", type: "assistant", text: "Replayed text" }, + ], + } as never) + : request(...args); + let emit!: () => void; + api.events = async function* (_session, _after, signal) { + await new Promise((resolve) => (emit = resolve)); + yield { + seq: 1, + type: "session.next.text.delta", + data: { sessionID: "ses_one" }, + }; + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }), + ); + }; + try { + await store.connectDevice(device); + await store.openConversation("ses_one"); + updated = true; + emit(); + await jest.advanceTimersByTimeAsync(200); + expect(store.getSnapshot().messages.at(-1)?.text).toBe("Replayed text"); + } finally { + store.dispose(); + jest.useRealTimers(); + } +}); test("restoring saved computers finishes before remote health so cold start is not blocked by the network", async () => { const { store, persistence, api } = setup(); await persistence.db.set("devices", JSON.stringify([device])); @@ -436,3 +564,56 @@ test("restoring saved computers finishes before remote health so cold start is n await restoring; } }); + +test("a public pairing link submits identity by POST and waits for Desktop approval", async () => { + const { store, api } = setup(); + const request = api.request; + api.request = jest.fn(async (...args: Parameters) => { + if (args[1] === "/mobile/v1/claim") + return { + claimID: "claim", + claimSecret: "private", + confirmation: "123456", + expiresAt: Date.now() + 300000, + }; + if (args[1] === "/mobile/v1/claim/status") return { state: "waiting" }; + return request(...args); + }) as API["request"]; + await store.pair( + "bharatcode://pair?origin=https%3A%2F%2Fpc.tunnel.bharatcode.ai&desktopID=pc_one", + ); + expect(api.request).toHaveBeenCalledWith("POST", "/mobile/v1/claim", { + body: { desktopID: "pc_one", deviceName: "Phone" }, + }); + expect(store.getSnapshot().confirmation).toBe("123456"); + expect(store.getSnapshot().devices).toEqual([]); + store.dispose(); +}); + +test("idle Desktop network loss becomes offline and retries health without losing pairing", async () => { + jest.useFakeTimers(); + const { store, api } = setup(); + try { + await store.connectDevice(device); + const original = api.request; + let offline = true; + api.request = async (...args) => { + if (offline && args[1] === "/api/session/active") + throw new TypeError("Network request failed"); + return original(...args); + }; + await store.activity(); + expect(store.getSnapshot().selected).toBeUndefined(); + expect(store.getSnapshot().online).toBe(false); + expect(store.getSnapshot().notice).toContain("reconnecting"); + expect(store.getSnapshot().device?.id).toBe("pc_one"); + offline = false; + await jest.advanceTimersByTimeAsync(4100); + expect(store.getSnapshot().online).toBe(true); + expect(store.getSnapshot().notice).toBeUndefined(); + expect(store.getSnapshot().device?.id).toBe("pc_one"); + } finally { + store.dispose(); + jest.useRealTimers(); + } +}); diff --git a/src/state/app-store.ts b/src/state/app-store.ts index 4f55f57..5ecd93d 100644 --- a/src/state/app-store.ts +++ b/src/state/app-store.ts @@ -98,6 +98,7 @@ export class AppStore { private poll?: ReturnType; private retry?: ReturnType; private refreshTimer?: ReturnType; + private liveTimer?: ReturnType; private searchTimer?: ReturnType; private pairTimer?: ReturnType; private refreshing = false; @@ -143,6 +144,7 @@ export class AppStore { ); } private fail(e: unknown) { + if (e instanceof ApiError && e.status === 401) this.cancelLiveUpdate(); this.patch({ error: e instanceof Error @@ -200,6 +202,7 @@ export class AppStore { clearTimeout(this.retry); clearTimeout(this.refreshTimer); this.refreshTimer = undefined; + this.cancelLiveUpdate(); clearTimeout(this.searchTimer); this.refreshing = false; this.refreshAgain = false; @@ -266,7 +269,7 @@ export class AppStore { ); } this.replay = record(result.capabilities).replay === true; - this.patch({ online: true, error: undefined }); + this.patch({ online: true, error: undefined, notice: undefined }); await this.refreshSessions(); this.schedulePoll(rev); } @@ -370,12 +373,21 @@ export class AppStore { this.state.selected, }); } catch (e) { - if ( - rev === this.clientRevision && - e instanceof ApiError && - e.status === 401 - ) - this.fail(e); + if (rev !== this.clientRevision || this.suspended || this.disposed) + return; + if (e instanceof ApiError && e.status === 401) this.fail(e); + else if ( + !(e instanceof ApiError) || + e.status >= 500 || + e.status === 408 + ) { + this.patch({ + online: false, + error: undefined, + notice: "Desktop is offline or reconnecting. Your draft is saved.", + }); + this.scheduleHealth(rev); + } } } async newChat() { @@ -495,6 +507,7 @@ export class AppStore { this.conversation = new AbortController(); clearTimeout(this.refreshTimer); this.refreshTimer = undefined; + this.cancelLiveUpdate(); clearTimeout(this.retry); this.refreshing = false; this.refreshAgain = false; @@ -616,6 +629,20 @@ export class AppStore { }); }, 180); } + private cancelLiveUpdate() { + clearTimeout(this.liveTimer); + this.liveTimer = undefined; + } + private queueLiveUpdate(rev: number) { + if (this.liveTimer) return; + // Preserve every delta in the projection, but let native layout and input + // run between Markdown renders when the stream delivers a burst of tokens. + this.liveTimer = setTimeout(() => { + this.liveTimer = undefined; + if (this.context(rev) && !this.suspended && this.state.online) + this.patch({ messages: this.projection.messages() }); + }, 100); + } private subscribeEvents(rev: number) { const api = this.api, selected = this.state.selected; @@ -631,12 +658,15 @@ export class AppStore { if (!this.context(rev) || signal.aborted) return; if (live) { if (this.projection.apply(event, selected.id)) - this.patch({ messages: this.projection.messages() }); + this.queueLiveUpdate(rev); } else { const seq = event.seq ?? event.durable?.seq; if (seq && seq > this.seenSequence) this.seenSequence = seq; } - this.queueRefresh(rev); + // Text deltas are already projected locally. Lifecycle/other events + // still reconcile the authoritative page and pending agent requests. + if (!live || event.type !== "session.next.text.delta") + this.queueRefresh(rev); } if (!signal.aborted) this.lost(rev, new Error("Connection interrupted")); @@ -885,7 +915,9 @@ export class AppStore { this.pairing = api; const claim = await api.request("POST", "/mobile/v1/claim", { body: { - code: payload.code, + ...(payload.code + ? { code: payload.code } + : { desktopID: payload.desktopID }), deviceName: this.deps.deviceName ?? "Phone", }, }); @@ -1004,7 +1036,7 @@ export class AppStore { clearTimeout(this.retry); clearTimeout(this.refreshTimer); this.refreshTimer = undefined; - this.refreshTimer = undefined; + this.cancelLiveUpdate(); this.refreshing = false; if (!this.state.preview) this.patch({ online: false }); }