diff --git a/ios/truapi-host/Sources/TrUAPIHost/Resources/truapi-container.js b/ios/truapi-host/Sources/TrUAPIHost/Resources/truapi-container.js index 0a78c43a5..c9a54165e 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/Resources/truapi-container.js +++ b/ios/truapi-host/Sources/TrUAPIHost/Resources/truapi-container.js @@ -94,5 +94,4 @@ } return _createElement(tagName, options); }); - freezeAndDelete(window, "RTCPeerConnection"); })(); diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi.swift index ff633c5b1..9df7082d8 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi.swift @@ -5718,7 +5718,10 @@ public enum RemotePermission: Equatable, Hashable { */domains: [String] ) /** - * WebRTC media access. + * WebRTC access. Advertised and persistable, but host enforcement is not + * yet implemented: the lockdown container leaves `RTCPeerConnection` + * available to products, and camera/microphone capture is gated by the OS + * permission prompts rather than by this permission. */ case webRtc /** diff --git a/js/container/src/index.ts b/js/container/src/index.ts index a914568a0..6147cf7ad 100644 --- a/js/container/src/index.ts +++ b/js/container/src/index.ts @@ -121,7 +121,4 @@ freezeValue(document, 'createElement', (tagName: string, options?: ElementCreati return _createElement(tagName, options); }); -// --- WebRTC: no permission path in TrUAPI mode --- -freezeAndDelete(window, 'RTCPeerConnection'); - export {}; diff --git a/playground/src/lib/auto-test.ts b/playground/src/lib/auto-test.ts index 1a1e90282..c20a58bf9 100644 --- a/playground/src/lib/auto-test.ts +++ b/playground/src/lib/auto-test.ts @@ -1,6 +1,7 @@ import { runExample, type LogEntry, type RunResult } from "./example-runner"; import { getClientSync } from "@parity/truapi/sandbox"; import type { MethodInfo, ServiceInfo } from "./services"; +import { WEBRTC_SERVICE_NAME } from "./webrtc-check"; import type { DiagnosisStatus } from "@/shared/diagnosis"; export const DIAGNOSIS_ID = "__diagnosis__"; @@ -20,8 +21,16 @@ const SSO_TIMEOUT_MS = 60_000; // preimage cap, leaving time for the result to cross the iframe boundary. const LIVE_ALLOCATION_TIMEOUT_MS = 420_000; -// Services skipped wholesale in the diagnosis until hosts wire them up. -const SKIPPED_SERVICES = new Set(["Coin Payment", "Payment"]); +// Services skipped wholesale in the diagnosis, keyed to the reason shown on the +// skipped rows. +const SKIPPED_SERVICES = new Map([ + ["Coin Payment", "Coin Payment service not yet wired up by hosts"], + ["Payment", "Payment service not yet wired up by hosts"], + [ + WEBRTC_SERVICE_NAME, + "WebRTC needs a live camera/microphone permission grant; run it interactively from the method browser", + ], +]); // Methods that trigger a host permission/signing prompt, so they need the // longer signing-class timeout to allow for the user to respond. const LONG_TIMEOUT_METHODS = new Set([ @@ -62,11 +71,9 @@ async function runOne({ }: RunOneOpts): Promise { const id = `${serviceName}/${method.name}`; - if (SKIPPED_SERVICES.has(serviceName)) { - onUpdate(id, { - status: "skipped", - output: `${serviceName} service not yet wired up by hosts`, - }); + const skipReason = SKIPPED_SERVICES.get(serviceName); + if (skipReason !== undefined) { + onUpdate(id, { status: "skipped", output: skipReason }); return; } if (!method.exampleSource) { diff --git a/playground/src/lib/services.ts b/playground/src/lib/services.ts index cfa3da55b..522ac7d8d 100644 --- a/playground/src/lib/services.ts +++ b/playground/src/lib/services.ts @@ -5,11 +5,14 @@ import type { ProductExecutionKind, ServiceInfo, } from "@parity/truapi/playground/services-types"; +import { WEBRTC_SERVICE } from "./webrtc-check"; export type { MethodInfo, ProductExecutionKind, ServiceInfo }; export { servicesForExecution }; -export const services: ServiceInfo[] = servicesForExecution( - generatedServices, - "Spa", -); +// Generated SPA-compatible services plus the synthetic WebRTC browser-capability +// method, which is exercised the same way (an example) but is not a wire method. +export const services: ServiceInfo[] = [ + ...servicesForExecution(generatedServices, "Spa"), + WEBRTC_SERVICE, +]; diff --git a/playground/src/lib/webrtc-check.ts b/playground/src/lib/webrtc-check.ts new file mode 100644 index 000000000..b3cce222c --- /dev/null +++ b/playground/src/lib/webrtc-check.ts @@ -0,0 +1,81 @@ +import type { ServiceInfo } from "@parity/truapi/playground/services-types"; + +// A first-class playground method backed by an example that combines TrUAPI +// permission calls with the browser WebRTC APIs. It runs through the same +// example runner as every other method (`truapi` and `assert`/`console` are +// injected; globals like `navigator`/`RTCPeerConnection` are reachable in the +// runner's function scope), so it shows up in the method browser, ⌘K, and the +// diagnosis alike. +// +// The example follows the product model — request every device permission via +// TrUAPI first, then use the capability: +// 1. requestDevicePermission("Camera") and ("Microphone") — device permissions; +// 2. only once granted, getUserMedia + RTCPeerConnection.createOffer. + +export const WEBRTC_SERVICE_NAME = "WebRTC"; +export const WEBRTC_METHOD_NAME = "peer_connection"; + +const WEBRTC_EXAMPLE_SOURCE = `// Fail fast: WebRTC media capture requires a secure context, so bail before +// prompting for any permission if the origin isn't secure. +console.log("secure context:", window.isSecureContext); +assert( + window.isSecureContext, + "Not a secure context — WebRTC media capture requires HTTPS or a localhost/loopback origin.", +); + +// Permission phase — request the camera and microphone through TrUAPI up front. +const camera = await truapi.permissions.requestDevicePermission("Camera"); +assert(camera.isOk(), "camera permission request failed:", camera); +assert(camera.value.granted, "camera permission denied"); +console.log("camera granted"); + +const microphone = await truapi.permissions.requestDevicePermission("Microphone"); +assert(microphone.isOk(), "microphone permission request failed:", microphone); +assert(microphone.value.granted, "microphone permission denied"); +console.log("microphone granted"); + +// Capability phase — permissions granted, now access the capability. +assert( + typeof RTCPeerConnection !== "undefined", + "RTCPeerConnection is unavailable — the host has not wired the WebRTC bridge (fail-closed).", +); +assert( + typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia, + "navigator.mediaDevices.getUserMedia is unavailable in this host.", +); + +const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true, +}); +console.log( + "media captured:", + stream.getTracks().map((t) => t.kind).join(", "), +); + +const pc = new RTCPeerConnection(); +try { + for (const track of stream.getTracks()) pc.addTrack(track, stream); + const offer = await pc.createOffer(); + assert(!!offer.sdp, "createOffer returned an empty SDP"); + console.log("offer created:", offer.type, offer.sdp.length + " bytes of SDP"); +} finally { + pc.close(); + stream.getTracks().forEach((t) => t.stop()); +}`; + +/** Synthetic WebRTC method — browsable, runnable, and part of the diagnosis. */ +export const WEBRTC_SERVICE: ServiceInfo = { + name: WEBRTC_SERVICE_NAME, + methods: [ + { + name: WEBRTC_METHOD_NAME, + type: "unary", + description: + "Requests camera + microphone (device permissions) through TrUAPI, " + + "then — once granted — opens an RTCPeerConnection and creates an " + + "offer with the captured media.", + exampleSource: WEBRTC_EXAMPLE_SOURCE, + }, + ], +}; diff --git a/rust/crates/truapi/src/v01/permissions.rs b/rust/crates/truapi/src/v01/permissions.rs index 5ffc93d33..b78fb3f9a 100644 --- a/rust/crates/truapi/src/v01/permissions.rs +++ b/rust/crates/truapi/src/v01/permissions.rs @@ -52,7 +52,10 @@ pub enum RemotePermission { /// Domain patterns requested by the product. domains: Vec, }, - /// WebRTC media access. + /// WebRTC access. Advertised and persistable, but host enforcement is not + /// yet implemented: the lockdown container leaves `RTCPeerConnection` + /// available to products, and camera/microphone capture is gated by the OS + /// permission prompts rather than by this permission. #[display("WebRTC connections")] WebRtc, /// Submitting transactions on behalf of the user via `remote_chain_transaction_broadcast`.