-
{language.t("mobile.title")}
{language.t("mobile.description")}
-
{language.t(state.value?.errorKey === "mobile.error.reconnecting" ? "mobile.error.reconnecting" : state.failed ? "mobile.error.action" : "mobile.error.connection")}
-
{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.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?.errorKey === "mobile.error.storage"
+ ? "mobile.error.storage"
+ : state.value?.errorKey === "mobile.error.reconnecting"
+ ? "mobile.error.reconnecting"
+ : state.failed
+ ? "mobile.error.action"
+ : "mobile.error.connection",
+ )}
+
+
+
+ {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.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/packages/app/src/i18n/mobile.ts b/packages/app/src/i18n/mobile.ts
index f26c00f7d..38736e716 100644
--- a/packages/app/src/i18n/mobile.ts
+++ b/packages/app/src/i18n/mobile.ts
@@ -10,9 +10,11 @@ export const MOBILE_ENGLISH = {
"mobile.progress.connecting": "Connecting the secure tunnel…",
"mobile.cancel": "Cancel connection",
"mobile.reset": "Revoke all phones and reset tunnel",
- "mobile.resetConfirm": "Revoke all phones and remove this tunnel? Reconnecting will require fresh pairing and a new certificate.",
+ "mobile.resetConfirm":
+ "Revoke all phones and remove this tunnel? Reconnecting will require fresh pairing and a new certificate.",
"mobile.title": "Mobile devices",
- "mobile.description": "Continue your BharatCode conversations from your phone. Your computer must stay online with BharatCode running.",
+ "mobile.description":
+ "Continue your BharatCode conversations from your phone. Your computer must stay online with BharatCode running.",
"mobile.connect": "Connect phone",
"mobile.preparing": "Preparing secure connection…",
"mobile.refresh": "Refresh QR",
@@ -29,7 +31,11 @@ export const MOBILE_ENGLISH = {
"mobile.empty": "No phones paired yet.",
"mobile.revoke": "Revoke access",
"mobile.revokeConfirm": "Revoke this phone’s access? It will need a new pairing code to reconnect.",
- "mobile.error.connection": "The secure connection could not be established. Check your internet connection and retry.",
+ "mobile.scope": "Connects to the local Desktop runtime. WSL workspaces are not available on mobile yet.",
+ "mobile.error.storage":
+ "Mobile-device storage is unavailable. Your existing device data has been kept. Check storage access and try again.",
+ "mobile.error.connection":
+ "The secure connection could not be established. Check your internet connection and retry.",
"mobile.error.reconnecting": "OpenTunnel is reconnecting. Keep BharatCode running.",
"mobile.error.action": "This mobile-device action could not be completed. Refresh and try again.",
} as const
diff --git a/packages/mobile-connector/src/desktop-access.mjs b/packages/mobile-connector/src/desktop-access.mjs
index 0d5611666..9d804105b 100644
--- a/packages/mobile-connector/src/desktop-access.mjs
+++ b/packages/mobile-connector/src/desktop-access.mjs
@@ -1,104 +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, removeTunnel } 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, pairingExpiresAt, errorKey, starting, controller, epoch = 0
- let devices = initial.list(), requests = []
- let enabled = false, status = 'off', progress
- 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, 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 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()
+ status = "provisioning"
+ errorKey = undefined
+ progress = "creating-tunnel"
+ emit()
starting = (async () => {
+ 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-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)
+ 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
- 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' }
+ 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 { 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) { if (token === epoch) { status = '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 shutdown(disable) {
++epoch
controller?.abort()
- if (starting) { try { await starting } catch {} }
- 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 = []
- devices = (await openDevices(storePath)).list()
+ 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') return snapshot()
- if (input.type === 'pair') {
- if (status === 'error' && enabled) await shutdown(false)
+ 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()
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: 2, errorCorrectionLevel: 'M' })
- } else if (input.type === 'disable') { await shutdown(true) }
- else if (input.type === 'reset') {
+ requests = []
+ pairingExpiresAt = invitation.expiresAt
+ pairing =
+ "bharatcode://pair?" + new URLSearchParams({ origin: invitation.origin, desktopID: invitation.desktopID })
+ qrImage = await QRCode.toDataURL(pairing, { width: 300, margin: 2, errorCorrectionLevel: "M" })
+ } else if (input.type === "disable") {
+ await shutdown(true)
+ } else if (input.type === "reset") {
await shutdown(true)
- const registry = await openDevices(storePath)
- for (const device of registry.list()) await registry.remove(device.id)
- devices = []
- 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') {
+ 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) {
// Status and cancellation must remain available during multi-minute issuance.
- if (input?.type === 'state') return Promise.resolve(snapshot())
- if (input?.type === 'disable') controller?.abort()
- const operation = actions.then(() => run(input)); actions = operation.catch(() => {}); return operation
+ 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)
},
- async restore() { try { if (JSON.parse(await readFile(flagPath, 'utf8')).enabled === true) await enable() } catch (error) { if (error.code !== 'ENOENT') { status = 'error'; errorKey = 'mobile.error.connection'; emit() } } },
- close: () => shutdown(false),
}
}
diff --git a/packages/mobile-connector/src/devices.mjs b/packages/mobile-connector/src/devices.mjs
index 716608033..09625fe1f 100644
--- a/packages/mobile-connector/src/devices.mjs
+++ b/packages/mobile-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/packages/mobile-connector/src/files.mjs b/packages/mobile-connector/src/files.mjs
index 4a481740d..dc057403a 100644
--- a/packages/mobile-connector/src/files.mjs
+++ b/packages/mobile-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/packages/mobile-connector/test/desktop-access.test.mjs b/packages/mobile-connector/test/desktop-access.test.mjs
index ae0647f1b..b09043c7c 100644
--- a/packages/mobile-connector/test/desktop-access.test.mjs
+++ b/packages/mobile-connector/test/desktop-access.test.mjs
@@ -1,115 +1,346 @@
-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"
-test('Desktop starts no tunnel until enabled, and disabling closes both resources', async t => {
- const directory = await mkdtemp(join(tmpdir(), 'bc-access-'))
+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 }))
- let connects = 0, closes = 0
+ 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: () => ({ 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* () {})() } },
+ 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(new URL(access.snapshot().pairing).hostname, 'pair')
- 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(
+ "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')
+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())
+ 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)
+ 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}))
+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)
+ 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}))
+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'))
+ 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"))
})
From 2271cc604c186e1bb2990e726e2fbe265d2c5688 Mon Sep 17 00:00:00 2001
From: arzvaak <66210729+arzvaak@users.noreply.github.com>
Date: Wed, 28 Oct 2026 20:10:25 +0530
Subject: [PATCH 05/10] fix(desktop): preserve the pairing QR quiet zone
---
bun.lock | 3 +
packages/mobile-connector/package.json | 10 +-
.../mobile-connector/src/desktop-access.mjs | 2 +-
.../test/desktop-access.test.mjs | 124 +++++++++++++++---
4 files changed, 122 insertions(+), 17 deletions(-)
diff --git a/bun.lock b/bun.lock
index 3b3422588..e0ac63f8b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -363,6 +363,9 @@
"effect": "^4.0.0",
"qrcode": "1.5.4",
},
+ "devDependencies": {
+ "pngjs": "^5.0.0",
+ },
},
"packages/opencode": {
"name": "bharatcode",
diff --git a/packages/mobile-connector/package.json b/packages/mobile-connector/package.json
index 04fc4df89..11b3d1e64 100644
--- a/packages/mobile-connector/package.json
+++ b/packages/mobile-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/packages/mobile-connector/src/desktop-access.mjs b/packages/mobile-connector/src/desktop-access.mjs
index 9d804105b..d1fd02f6d 100644
--- a/packages/mobile-connector/src/desktop-access.mjs
+++ b/packages/mobile-connector/src/desktop-access.mjs
@@ -185,7 +185,7 @@ export async function createMobileAccess(options) {
pairingExpiresAt = invitation.expiresAt
pairing =
"bharatcode://pair?" + new URLSearchParams({ origin: invitation.origin, desktopID: invitation.desktopID })
- qrImage = await QRCode.toDataURL(pairing, { width: 300, margin: 2, errorCorrectionLevel: "M" })
+ qrImage = await QRCode.toDataURL(pairing, { width: 300, margin: 4, errorCorrectionLevel: "M" })
} else if (input.type === "disable") {
await shutdown(true)
} else if (input.type === "reset") {
diff --git a/packages/mobile-connector/test/desktop-access.test.mjs b/packages/mobile-connector/test/desktop-access.test.mjs
index b09043c7c..be9a84847 100644
--- a/packages/mobile-connector/test/desktop-access.test.mjs
+++ b/packages/mobile-connector/test/desktop-access.test.mjs
@@ -4,6 +4,52 @@ import { mkdtemp, rm, mkdir, writeFile, readFile, readdir } from "node:fs/promis
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("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 }))
+ 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 () => {},
+ }),
+ })
+ 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-"))
@@ -36,7 +82,10 @@ test("malformed optional mobile state cannot reject Desktop initialization or ov
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 })
+ const access = await createMobileAccess({
+ userDataPath: directory,
+ getConnection: () => undefined,
+ })
await access.restore()
assert.equal(access.snapshot().enabled, false)
assert.equal(access.snapshot().status, "error")
@@ -50,7 +99,10 @@ test("unavailable optional mobile directory does not reject initialization or cl
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 })
+ 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")
@@ -69,7 +121,10 @@ test("reset cancels pending issuance before joining the action queue", { timeout
removed = false
const access = await createMobileAccess({
userDataPath: directory,
- getConnection: () => ({ url: "http://127.0.0.1:4096", password: "private" }),
+ getConnection: () => ({
+ url: "http://127.0.0.1:4096",
+ password: "private",
+ }),
tunnelFactory: async ({ signal }) => {
begin()
await new Promise((_, reject) =>
@@ -128,11 +183,16 @@ test("reset cancels a queued pair before it starts provisioning", async (t) => {
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 })
+ 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" }),
+ getConnection: () => ({
+ url: "http://127.0.0.1:4096",
+ password: "private",
+ }),
tunnelFactory: async () => ({
origin: "https://pc.tunnel.bharatcode.ai",
events: (async function* () {})(),
@@ -156,7 +216,10 @@ test("explicit reset preserves a damaged registry backup and allows fresh pairin
await writeFile(join(root, "devices.json"), damaged)
const access = await createMobileAccess({
userDataPath: directory,
- getConnection: () => ({ url: "http://127.0.0.1:4096", password: "private" }),
+ getConnection: () => ({
+ url: "http://127.0.0.1:4096",
+ password: "private",
+ }),
removeTunnel: async () => {},
tunnelFactory: async () => ({
origin: "https://pc.tunnel.bharatcode.ai",
@@ -182,7 +245,11 @@ test("Desktop starts no tunnel until enabled, and disabling closes both resource
closes = 0
const access = await createMobileAccess({
userDataPath: directory,
- getConnection: () => ({ url: "http://127.0.0.1:4096", username: "opencode", password: "private" }),
+ getConnection: () => ({
+ url: "http://127.0.0.1:4096",
+ username: "opencode",
+ password: "private",
+ }),
tunnelFactory: async () => {
connects++
return {
@@ -234,7 +301,10 @@ test(
const ready = new Promise((resolve) => (began = resolve))
const access = await createMobileAccess({
userDataPath: directory,
- getConnection: () => ({ url: "http://127.0.0.1:4096", password: "private" }),
+ getConnection: () => ({
+ url: "http://127.0.0.1:4096",
+ password: "private",
+ }),
tunnelFactory: async ({ signal, onProgress }) => {
onProgress("waiting-certificate")
began()
@@ -262,10 +332,17 @@ test("Desktop emits credential-free links, restores enabled identity and clears
removed = 0
const options = {
userDataPath: directory,
- getConnection: () => ({ url: "http://127.0.0.1:4096", password: "private" }),
+ 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 () => {} }
+ return {
+ origin: "https://pc.tunnel.bharatcode.ai",
+ events: (async function* () {})(),
+ close: async () => {},
+ }
},
removeTunnel: async () => {
removed++
@@ -295,10 +372,17 @@ test("Desktop status reflects a phone unpairing itself immediately", async (t) =
let target
const access = await createMobileAccess({
userDataPath: directory,
- getConnection: () => ({ url: "http://127.0.0.1:4096", password: "private" }),
+ 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 () => {} }
+ return {
+ origin: "https://pc.tunnel.bharatcode.ai",
+ events: (async function* () {})(),
+ close: async () => {},
+ }
},
})
t.after(() => access.close())
@@ -311,7 +395,10 @@ test("Desktop status reflects a phone unpairing itself immediately", async (t) =
body: JSON.stringify(body),
})
const claim = await (
- await post("/mobile/v1/claim", { desktopID: link.searchParams.get("desktopID"), deviceName: "Phone" })
+ 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()
@@ -334,10 +421,17 @@ test("self-hosted identities use a service-specific store, never the old upstrea
let actual
const access = await createMobileAccess({
userDataPath: directory,
- getConnection: () => ({ url: "http://127.0.0.1:4096", password: "private" }),
+ 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 () => {} }
+ return {
+ origin: "https://pc.tunnel.bharatcode.ai",
+ events: (async function* () {})(),
+ close: async () => {},
+ }
},
})
t.after(() => access.close())
From 932d85a0bb47b35931f9bffc7af44c2b8a2e3019 Mon Sep 17 00:00:00 2001
From: arzvaak <66210729+arzvaak@users.noreply.github.com>
Date: Wed, 28 Oct 2026 21:57:42 +0530
Subject: [PATCH 06/10] docs(desktop): record pairing reliability and camera
acceptance
---
docs/mobile-access-fixes-2026-10-28.md | 27 +++++++++++++++++++
docs/mobile-access-verification-2026-10-25.md | 2 ++
2 files changed, 29 insertions(+)
create mode 100644 docs/mobile-access-fixes-2026-10-28.md
diff --git a/docs/mobile-access-fixes-2026-10-28.md b/docs/mobile-access-fixes-2026-10-28.md
new file mode 100644
index 000000000..45d185542
--- /dev/null
+++ b/docs/mobile-access-fixes-2026-10-28.md
@@ -0,0 +1,27 @@
+# Mobile-access follow-up, October 28, 2026
+
+## Desktop changes
+
+The optional mobile feature no longer reads its directory during controller construction or lets malformed/unavailable storage reject Desktop initialization. Settings reports a fixed storage error without exposing or overwriting saved data. Explicit reset preserves a private backup of a damaged registry before rebuilding it.
+
+Reset/disable invalidate pending provisioning before entering the action queue. Queued pair and restore operations check cancellation before opening new resources. Failed persistence closes a newly opened tunnel. Settings keeps reset usable during provisioning and ignores late responses from superseded operations. File enumeration also avoids opening directories as regular files.
+
+The pairing QR has a four-module clear border, verified from its generated PNG pixels. This rendering fix is separate from the Mobile camera initialization failure fixed by Expo Camera 58.0.13.
+
+## Verification
+
+Fresh October 28 checks passed: 52 connector tests; 168 Desktop tests; Desktop package and release typechecks; two generator tests and three Linux browser-helper tests in the Mobile companion. The earlier renderer qualification passed five settings Playwright cases, 55 browser tests, 30 sidecar route tests, and app/e2e typechecks.
+
+The earlier app unit run had 748 passes and one Arabic translation-parity failure reproduced on the clean base, involving four existing `ui.message.outputLimit` keys. No unrelated translations were changed and no failure was suppressed. Earlier detailed results remain in [the October 25 report](mobile-access-verification-2026-10-25.md).
+
+## 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 previous Linux AppImage was built from `6331ba9`, before the QR-border change. It must not be represented as the final artifact. Official publication follows `.github/workflows/README.md`: build an exact-source cohort, record manual platform acceptance, then publish those same accepted bytes. A source PR or the physical Mobile result above does not replace that gate.
diff --git a/docs/mobile-access-verification-2026-10-25.md b/docs/mobile-access-verification-2026-10-25.md
index 0801f84c0..48032aa9d 100644
--- a/docs/mobile-access-verification-2026-10-25.md
+++ b/docs/mobile-access-verification-2026-10-25.md
@@ -1,5 +1,7 @@
# Self-hosted tunnel integration verification — 2026-10-25
+Later fixes and physical camera acceptance are recorded in [the October 28 follow-up](mobile-access-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`.
## October 27 correction
From d6763f9552979d0ed78d76f69dab96a8d6ff87ab Mon Sep 17 00:00:00 2001
From: arzvaak <66210729+arzvaak@users.noreply.github.com>
Date: Wed, 28 Oct 2026 22:05:38 +0530
Subject: [PATCH 07/10] docs: add pairing qualification media
---
.../assets/mobile-access/after-provisioning.png | Bin 0 -> 41473 bytes
.../mobile-access/before-provisioning.png | Bin 0 -> 36481 bytes
docs/mobile-access-fixes-2026-10-28.md | 8 ++++++++
3 files changed, 8 insertions(+)
create mode 100644 docs/assets/mobile-access/after-provisioning.png
create mode 100644 docs/assets/mobile-access/before-provisioning.png
diff --git a/docs/assets/mobile-access/after-provisioning.png b/docs/assets/mobile-access/after-provisioning.png
new file mode 100644
index 0000000000000000000000000000000000000000..4ab90b9282aa06e700f61f069deb03fa9580a054
GIT binary patch
literal 41473
zcmd?RXH?V8*Z1pXLq$MEK&cASn@H~}2uP7$Ly;map_c$5U<0H}mo9`32@*<3LY3Zo
z3xwVRgx>2xuls-B&pB_-S?jzx>pXciE18+hWbe$({_OAkf;HYLlHH-bbLGkvvbRd|
zT34>z_<7~Zb+KDlFW+(gxOU^pm3vp-%D>X_N?AMe2LYBoZ5*Y?r!Xd@K