diff --git a/apps/ios/Sources/Bridge/BridgeConnectionController.swift b/apps/ios/Sources/Bridge/BridgeConnectionController.swift index 915fa44ea..bb3b2443a 100644 --- a/apps/ios/Sources/Bridge/BridgeConnectionController.swift +++ b/apps/ios/Sources/Bridge/BridgeConnectionController.swift @@ -164,6 +164,9 @@ final class BridgeConnectionController { : UserDefaults.standard.bool(forKey: "camera.enabled") if cameraEnabled { caps.append("camera") } + let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey) + if voiceWakeEnabled { caps.append("voiceWake") } + return caps } diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 5eda0faa3..8748e89c6 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -2975,7 +2975,7 @@ export async function startGatewayServer(port = 18789): Promise { deviceFamily: live?.deviceFamily ?? n.deviceFamily, modelIdentifier: live?.modelIdentifier ?? n.modelIdentifier, remoteIp: live?.remoteIp ?? n.remoteIp, - caps: live?.caps, + caps: live?.caps ?? n.caps, connected: Boolean(live), }; }); diff --git a/src/infra/bridge/server.test.ts b/src/infra/bridge/server.test.ts index 566e89d22..359639ed2 100644 --- a/src/infra/bridge/server.test.ts +++ b/src/infra/bridge/server.test.ts @@ -459,6 +459,10 @@ describe("node bridge server", () => { expect(node?.modelIdentifier).toBe("iPad14,5"); expect(node?.caps).toEqual(["canvas", "camera"]); + const after = await listNodePairing(baseDir); + const paired = after.paired.find((p) => p.nodeId === "n-caps"); + expect(paired?.caps).toEqual(["canvas", "camera"]); + socket.destroy(); await server.close(); }); diff --git a/src/infra/bridge/server.ts b/src/infra/bridge/server.ts index c2b80fc98..6db362105 100644 --- a/src/infra/bridge/server.ts +++ b/src/infra/bridge/server.ts @@ -7,6 +7,7 @@ import { listNodePairing, type NodePairingPendingRequest, requestNodePairing, + updatePairedNodeMetadata, verifyNodeToken, } from "../node-pairing.js"; @@ -258,6 +259,34 @@ export async function startNodeBridgeServer( return; } + const inferCaps = (frame: { + platform?: string; + deviceFamily?: string; + }): string[] | undefined => { + const platform = String(frame.platform ?? "").trim().toLowerCase(); + const family = String(frame.deviceFamily ?? "").trim().toLowerCase(); + if (platform.includes("ios") || platform.includes("ipados")) { + return ["canvas", "camera"]; + } + if (platform.includes("android")) { + return ["canvas", "camera"]; + } + if (family === "ipad" || family === "iphone" || family === "ios") { + return ["canvas", "camera"]; + } + if (family === "android") { + return ["canvas", "camera"]; + } + return undefined; + }; + + const caps = + (Array.isArray(hello.caps) + ? hello.caps.map((c) => String(c)).filter(Boolean) + : undefined) ?? + verified.node.caps ?? + inferCaps(hello); + isAuthenticated = true; const existing = connections.get(nodeId); if (existing?.socket && existing.socket !== socket) { @@ -274,11 +303,22 @@ export async function startNodeBridgeServer( version: verified.node.version ?? hello.version, deviceFamily: verified.node.deviceFamily ?? hello.deviceFamily, modelIdentifier: verified.node.modelIdentifier ?? hello.modelIdentifier, - caps: Array.isArray(hello.caps) - ? hello.caps.map((c) => String(c)).filter(Boolean) - : undefined, + caps, remoteIp: remoteAddress, }; + await updatePairedNodeMetadata( + nodeId, + { + displayName: nodeInfo.displayName, + platform: nodeInfo.platform, + version: nodeInfo.version, + deviceFamily: nodeInfo.deviceFamily, + modelIdentifier: nodeInfo.modelIdentifier, + remoteIp: nodeInfo.remoteIp, + caps: nodeInfo.caps, + }, + opts.pairingBaseDir, + ); connections.set(nodeId, { socket, nodeInfo, invokeWaiters }); send({ type: "hello-ok", serverName } satisfies BridgeHelloOkFrame); await opts.onAuthenticated?.(nodeInfo); @@ -335,6 +375,9 @@ export async function startNodeBridgeServer( version: req.version, deviceFamily: req.deviceFamily, modelIdentifier: req.modelIdentifier, + caps: Array.isArray(req.caps) + ? req.caps.map((c) => String(c)).filter(Boolean) + : undefined, remoteIp: remoteAddress, }, opts.pairingBaseDir, diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index 1fba1ba13..00596888a 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -11,6 +11,7 @@ export type NodePairingPendingRequest = { version?: string; deviceFamily?: string; modelIdentifier?: string; + caps?: string[]; remoteIp?: string; isRepair?: boolean; ts: number; @@ -24,6 +25,7 @@ export type NodePairingPairedNode = { version?: string; deviceFamily?: string; modelIdentifier?: string; + caps?: string[]; remoteIp?: string; createdAtMs: number; approvedAtMs: number; @@ -178,6 +180,7 @@ export async function requestNodePairing( version: req.version, deviceFamily: req.deviceFamily, modelIdentifier: req.modelIdentifier, + caps: req.caps, remoteIp: req.remoteIp, isRepair, ts: Date.now(), @@ -207,6 +210,7 @@ export async function approveNodePairing( version: pending.version, deviceFamily: pending.deviceFamily, modelIdentifier: pending.modelIdentifier, + caps: pending.caps, remoteIp: pending.remoteIp, createdAtMs: existing?.createdAtMs ?? now, approvedAtMs: now, @@ -244,3 +248,30 @@ export async function verifyNodeToken( if (!node) return { ok: false }; return node.token === token ? { ok: true, node } : { ok: false }; } + +export async function updatePairedNodeMetadata( + nodeId: string, + patch: Partial>, + baseDir?: string, +) { + await withLock(async () => { + const state = await loadState(baseDir); + const normalized = normalizeNodeId(nodeId); + const existing = state.pairedByNodeId[normalized]; + if (!existing) return; + + const next: NodePairingPairedNode = { + ...existing, + displayName: patch.displayName ?? existing.displayName, + platform: patch.platform ?? existing.platform, + version: patch.version ?? existing.version, + deviceFamily: patch.deviceFamily ?? existing.deviceFamily, + modelIdentifier: patch.modelIdentifier ?? existing.modelIdentifier, + remoteIp: patch.remoteIp ?? existing.remoteIp, + caps: patch.caps ?? existing.caps, + }; + + state.pairedByNodeId[normalized] = next; + await persistState(state, baseDir); + }); +}