diff --git a/.github/workflows/iphone-lidar.yml b/.github/workflows/iphone-lidar.yml new file mode 100644 index 00000000..8f077e78 --- /dev/null +++ b/.github/workflows/iphone-lidar.yml @@ -0,0 +1,70 @@ +name: iPhone LiDAR integration + +on: + push: + branches: [main] + paths: + - 'integrations/iphone-lidar/**' + - 'docs/adr/ADR-340-iphone-lidar-sensor-bridge.md' + - '.github/workflows/iphone-lidar.yml' + pull_request: + paths: + - 'integrations/iphone-lidar/**' + - 'docs/adr/ADR-340-iphone-lidar-sensor-bridge.md' + - '.github/workflows/iphone-lidar.yml' + +permissions: + contents: read + +jobs: + web: + name: Node relay and codec + runs-on: ubuntu-latest + defaults: + run: + working-directory: integrations/iphone-lidar/web + steps: + - name: Checkout code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + cache: npm + cache-dependency-path: integrations/iphone-lidar/web/package-lock.json + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Run tests + run: npm test + + - name: Audit runtime dependencies + run: npm audit --omit=optional --audit-level=high + + ios: + name: iOS 17 compile + runs-on: macos-15 + steps: + - name: Checkout code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + + - name: Compile native sources with strict concurrency + shell: bash + run: | + set -euo pipefail + sdk="$(xcrun --sdk iphoneos --show-sdk-path)" + build_dir="$RUNNER_TEMP/ruview-lidar-build" + mkdir -p "$build_dir" + cd "$build_dir" + xcrun swiftc \ + -parse-as-library \ + -target arm64-apple-ios17.0 \ + -sdk "$sdk" \ + -module-name RuViewLiDAR \ + -strict-concurrency=complete \ + -warnings-as-errors \ + -emit-module \ + -emit-module-path "$build_dir/RuViewLiDAR.swiftmodule" \ + -c "$GITHUB_WORKSPACE"/integrations/iphone-lidar/native/RuViewLiDAR/*.swift diff --git a/docs/adr/ADR-340-iphone-lidar-sensor-bridge.md b/docs/adr/ADR-340-iphone-lidar-sensor-bridge.md index bbddddb5..e3996c47 100644 --- a/docs/adr/ADR-340-iphone-lidar-sensor-bridge.md +++ b/docs/adr/ADR-340-iphone-lidar-sensor-bridge.md @@ -36,13 +36,13 @@ Rules: ## Performance target -A typical 256 x 192 Float32 depth map is about 196 KB before confidence and metadata. Downsampling to 128 x 96 and encoding each sample as two byte depth plus one byte confidence yields about 36.9 KB raw. At 15 FPS the raw sensor payload is about 553 KB/s. Base64 raises this to roughly 737 KB/s before JSON metadata. +`[SYNTHETIC]` A 256 x 192 Float32 depth map is about 196 KB before confidence and metadata. Downsampling to 128 x 96 and encoding each sample as two byte depth plus one byte confidence yields about 36.9 KB raw. At 15 FPS the raw sensor payload is about 553 KB/s. Base64 raises this to roughly 737 KB/s before JSON metadata. These values are arithmetic sizing estimates, not device measurements. -Target local network latency is below 150 ms p95. A later binary WebSocket or QUIC transport can remove base64 overhead and reduce bandwidth by about 25 percent. +The `[CLAIMED target]` for local network latency is below 150 ms p95. A later binary WebSocket or QUIC transport can remove base64 overhead; the exact end-to-end reduction must be measured before it is claimed. ## Security -The development relay is intentionally local and unauthenticated. It is not a production trust boundary. +The development relay is LAN-facing, requires a random per-run bearer token, bounds message size, and restricts the files it serves. Its default `ws://` transport is not encrypted, so it is not a production trust boundary. Production requires WSS, authenticated sensor identity, replay protection, message size limits, per tenant authorization, provenance receipts, and explicit retention policy before persistence. @@ -55,3 +55,5 @@ The main limitation is that Apple provides processed scene depth rather than the ## Acceptance criteria A physical LiDAR capable iPhone must stream live geometry to the browser viewer with monotonically increasing sequence numbers, no RGB payload, valid confidence maps, and below 150 ms p95 local network latency over a 60 second run. + +CI type-checking and simulator runs do not satisfy this criterion. Until a captured physical-device run records the environment and results, the hardware behavior and latency remain unverified. diff --git a/integrations/iphone-lidar/README.md b/integrations/iphone-lidar/README.md index 274aee07..ecedbedd 100644 --- a/integrations/iphone-lidar/README.md +++ b/integrations/iphone-lidar/README.md @@ -1,6 +1,6 @@ # RuView iPhone LiDAR -This integration turns a LiDAR capable iPhone into a RuView geometry sensor and exposes the stream to any modern browser. +This experimental integration provides the native and browser components needed to use a LiDAR-capable iPhone as a RuView geometry sensor. The native source is type-checked against the iOS SDK in CI; physical-device validation is tracked separately below. ## Architecture @@ -35,32 +35,39 @@ The app requests `ARWorldTrackingConfiguration` with `.sceneDepth`, checks `supp ```bash cd integrations/iphone-lidar/web -npm install +npm ci npm test npm start ``` -Open `http://HOST:8787` in a browser. In the iPhone app set the endpoint to: +The relay prints a random per-run access token. Open the printed browser URL and set the iPhone endpoint to the printed native URL. They have this form: ```text -ws://HOST:8787/ws/lidar +http://HOST:8787/?token=TOKEN +ws://HOST:8787/ws/lidar?token=TOKEN ``` -For production use TLS and `wss://`. +Set `RUVIEW_LIDAR_TOKEN` to supply the token explicitly. The token only prevents unauthenticated peers from joining the development relay; because `ws://` does not encrypt it, production use requires TLS and `wss://`. ## Wire format Schema: `ruview.lidar.depth.v1` -Depth is downsampled by 2 in each dimension by default and streamed at a maximum of 15 FPS. Each depth sample is encoded as little endian UInt16 millimeters plus one UInt8 confidence value. This reduces the depth payload from roughly 196 KB per 256 x 192 Float32 frame to roughly 37 KB per 128 x 96 frame before base64 and JSON overhead. +Depth is downsampled by 2 in each dimension by default and streamed at a maximum of 15 FPS. Each depth sample is encoded as little endian UInt16 millimeters plus one UInt8 confidence value. `[SYNTHETIC]` Arithmetic sizing reduces the depth payload from roughly 196 KB per 256 x 192 Float32 frame to roughly 37 KB per 128 x 96 frame before base64 and JSON overhead. -At 15 FPS that is approximately 0.75 MB/s after base64 overhead, versus roughly 8 MB/s for uncompressed Float32 JSON at full resolution. +`[SYNTHETIC]` At 15 FPS that is approximately 0.75 MB/s after base64 overhead, versus roughly 8 MB/s for uncompressed Float32 JSON at full resolution. These are sizing estimates, not device or network measurements. ## Privacy and governance The initial implementation labels provenance as `source=live` and `privacyClass=geometry-only`. It sends depth geometry, confidence, camera intrinsics, pose, sequence, and wall clock timestamp. It does not send RGB imagery. -Production integration should terminate the WebSocket inside RuView, authenticate the device using the existing sensor identity path, convert each frame into `ruview-hal::Observation`, and attach witness receipts before fusion or persistence. +The development relay requires an ephemeral token and bounds each WebSocket message, but it is not a production trust boundary. Production integration should terminate the WebSocket inside RuView, authenticate the device using the existing sensor identity path, convert each frame into `ruview-hal::Observation`, and attach witness receipts before fusion or persistence. + +## Validation status + +- `[MEASURED]` The committed Node tests cover wire decoding, malformed inputs, relay authentication, static-file restrictions, and live WebSocket forwarding. +- `[MEASURED]` GitHub Actions type-checks the native sources with strict concurrency against the iOS 17 SDK. +- Physical iPhone capture, end-to-end rendering, confidence-map behavior, and the latency target are not yet measured. A simulator or CI compile does not satisfy the hardware acceptance test. ## Acceptance test @@ -68,4 +75,4 @@ Production integration should terminate the WebSocket inside RuView, authenticat 2. Run the native app on a LiDAR capable iPhone. 3. Start LiDAR capture and enable streaming. 4. Move the phone through a room. -5. Verify the browser shows a changing point cloud, sequence increases monotonically, latency stays below 150 ms on a local WiFi network, and no RGB payload is present in captured WebSocket frames. +5. Verify the browser shows a changing point cloud, sequence increases monotonically, latency stays below the `[CLAIMED target]` of 150 ms p95 on a local WiFi network, and no RGB payload is present in captured WebSocket frames. diff --git a/integrations/iphone-lidar/native/RuViewLiDAR/ContentView.swift b/integrations/iphone-lidar/native/RuViewLiDAR/ContentView.swift index 13eb0155..966202da 100644 --- a/integrations/iphone-lidar/native/RuViewLiDAR/ContentView.swift +++ b/integrations/iphone-lidar/native/RuViewLiDAR/ContentView.swift @@ -2,7 +2,7 @@ import SwiftUI struct ContentView: View { @StateObject private var capture = LiDARCaptureManager() - @State private var endpoint = "ws://192.168.1.10:3001/ws/lidar" + @State private var endpoint = "ws://HOST:8787/ws/lidar?token=TOKEN" @State private var streaming = false @State private var status = "Idle" @@ -27,7 +27,7 @@ struct ContentView: View { HStack { Text("Depth") Spacer() - Text("\(frame.depth.width) × \(frame.depth.height)") + Text("\(frame.depth.width) x \(frame.depth.height)") } HStack { Text("Sequence") diff --git a/integrations/iphone-lidar/native/RuViewLiDAR/LiDARCaptureManager.swift b/integrations/iphone-lidar/native/RuViewLiDAR/LiDARCaptureManager.swift index 869e8ac9..25f7d8cf 100644 --- a/integrations/iphone-lidar/native/RuViewLiDAR/LiDARCaptureManager.swift +++ b/integrations/iphone-lidar/native/RuViewLiDAR/LiDARCaptureManager.swift @@ -16,7 +16,7 @@ final class LiDARCaptureManager: NSObject, ObservableObject { @Published private(set) var framesPerSecond: Double = 0 let session = ARSession() - var onFrame: (@Sendable (RuViewLiDARFrame) -> Void)? + var onFrame: (@MainActor @Sendable (RuViewLiDARFrame) -> Void)? private var sequence: UInt64 = 0 private var lastTimestamp: TimeInterval? @@ -53,7 +53,9 @@ final class LiDARCaptureManager: NSObject, ObservableObject { let depthMap = sceneDepth.depthMap let confidenceMap = sceneDepth.confidenceMap - CVPixelBufferLockBaseAddress(depthMap, .readOnly) + guard CVPixelBufferLockBaseAddress(depthMap, .readOnly) == kCVReturnSuccess else { + return nil + } defer { CVPixelBufferUnlockBaseAddress(depthMap, .readOnly) } guard CVPixelBufferGetPixelFormatType(depthMap) == kCVPixelFormatType_DepthFloat32, @@ -77,8 +79,12 @@ final class LiDARCaptureManager: NSObject, ObservableObject { } var confidence = [UInt8](repeating: 0, count: width * height) - if let confidenceMap { - CVPixelBufferLockBaseAddress(confidenceMap, .readOnly) + if let confidenceMap, + CVPixelBufferGetPixelFormatType(confidenceMap) == kCVPixelFormatType_OneComponent8, + CVPixelBufferGetWidth(confidenceMap) == width, + CVPixelBufferGetHeight(confidenceMap) == height, + CVPixelBufferLockBaseAddress(confidenceMap, .readOnly) == kCVReturnSuccess { + defer { CVPixelBufferUnlockBaseAddress(confidenceMap, .readOnly) } if let confidenceBase = CVPixelBufferGetBaseAddress(confidenceMap) { let confidenceStride = CVPixelBufferGetBytesPerRow(confidenceMap) let confidencePointer = confidenceBase.assumingMemoryBound(to: UInt8.self) @@ -89,7 +95,6 @@ final class LiDARCaptureManager: NSObject, ObservableObject { } } } - CVPixelBufferUnlockBaseAddress(confidenceMap, .readOnly) } return RuViewLiDARFrame( @@ -109,26 +114,17 @@ final class LiDARCaptureManager: NSObject, ObservableObject { extension LiDARCaptureManager: ARSessionDelegate { nonisolated func session(_ session: ARSession, didUpdate frame: ARFrame) { guard let base = makeFrame(from: frame) else { return } + let frameTimestamp = frame.timestamp Task { @MainActor in sequence &+= 1 - let corrected = RuViewLiDARFrame( - intrinsics: frame.camera.intrinsics, - imageResolution: frame.camera.imageResolution, - cameraTransform: frame.camera.transform, - depthWidth: base.depth.width, - depthHeight: base.depth.height, - depthMeters: base.depth.meters, - confidence: base.depth.confidence, - sequence: sequence, - timestamp: Date().timeIntervalSince1970 - ) + let corrected = base.assigningSequence(sequence) if let previous = lastTimestamp { - let delta = frame.timestamp - previous + let delta = frameTimestamp - previous if delta > 0 { framesPerSecond = 1.0 / delta } } - lastTimestamp = frame.timestamp + lastTimestamp = frameTimestamp lastFrame = corrected onFrame?(corrected) } diff --git a/integrations/iphone-lidar/native/RuViewLiDAR/RuViewFrame.swift b/integrations/iphone-lidar/native/RuViewLiDAR/RuViewFrame.swift index 8ccd2d53..aab1cd8a 100644 --- a/integrations/iphone-lidar/native/RuViewLiDAR/RuViewFrame.swift +++ b/integrations/iphone-lidar/native/RuViewLiDAR/RuViewFrame.swift @@ -73,6 +73,37 @@ struct RuViewLiDARFrame: Codable, Sendable { schema: "ruview.lidar.depth.v1" ) } + + func assigningSequence(_ sequence: UInt64) -> RuViewLiDARFrame { + RuViewLiDARFrame( + type: type, + intrinsics: intrinsics, + pose: pose, + depth: depth, + provenance: Provenance( + sensor: provenance.sensor, + source: provenance.source, + privacyClass: provenance.privacyClass, + sequence: sequence, + timestampNs: provenance.timestampNs, + schema: provenance.schema + ) + ) + } + + private init( + type: String, + intrinsics: Intrinsics, + pose: Pose, + depth: Depth, + provenance: Provenance + ) { + self.type = type + self.intrinsics = intrinsics + self.pose = pose + self.depth = depth + self.provenance = provenance + } } private extension simd_float4x4 { diff --git a/integrations/iphone-lidar/web/app.mjs b/integrations/iphone-lidar/web/app.mjs index 387fb9c0..ec164b22 100644 --- a/integrations/iphone-lidar/web/app.mjs +++ b/integrations/iphone-lidar/web/app.mjs @@ -15,8 +15,15 @@ let pitch = -0.15; let scale = 120; function connect() { + const token = new URLSearchParams(location.search).get('token'); + if (!token) { + status.textContent = 'TOKEN REQUIRED'; + status.dataset.state = 'warn'; + return; + } + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const socket = new WebSocket(`${protocol}//${location.host}/ws/lidar`); + const socket = new WebSocket(`${protocol}//${location.host}/ws/lidar?token=${encodeURIComponent(token)}`); socket.addEventListener('open', () => { status.textContent = 'LIVE'; diff --git a/integrations/iphone-lidar/web/codec.mjs b/integrations/iphone-lidar/web/codec.mjs index 22afed24..61f45653 100644 --- a/integrations/iphone-lidar/web/codec.mjs +++ b/integrations/iphone-lidar/web/codec.mjs @@ -8,10 +8,22 @@ export function decodeLiDARPacket(packet) { throw new Error('Unsupported depth encoding'); } - const mmBytes = base64ToBytes(depth.millimetersBase64); - const confidence = base64ToBytes(depth.confidenceBase64); + assertPositiveInteger(depth.width, 'depth.width'); + assertPositiveInteger(depth.height, 'depth.height'); + assertIntrinsics(packet.intrinsics); + if (!packet.pose || !Array.isArray(packet.pose.matrix) || packet.pose.matrix.length !== 16 + || packet.pose.matrix.some((value) => !Number.isFinite(value))) { + throw new Error('Invalid camera pose'); + } + + const mmBytes = base64ToBytes(depth.millimetersBase64, 'millimetersBase64'); + const confidence = base64ToBytes(depth.confidenceBase64, 'confidenceBase64'); const expectedPixels = depth.width * depth.height; + if (!Number.isSafeInteger(expectedPixels) || expectedPixels > 1_000_000) { + throw new Error('Depth dimensions exceed the supported pixel limit'); + } + if (mmBytes.byteLength !== expectedPixels * 2) { throw new Error(`Depth payload length mismatch: expected ${expectedPixels * 2}, got ${mmBytes.byteLength}`); } @@ -40,6 +52,16 @@ export function depthToPointCloud(frame, confidenceThreshold = 1) { const { width, height, meters, confidence } = frame.depth; const { fx, fy, cx, cy, imageWidth, imageHeight } = frame.intrinsics; + assertPositiveInteger(width, 'depth.width'); + assertPositiveInteger(height, 'depth.height'); + assertIntrinsics(frame.intrinsics); + if (meters.length !== width * height || confidence.length !== width * height) { + throw new Error('Decoded depth array length mismatch'); + } + if (!Number.isFinite(confidenceThreshold) || confidenceThreshold < 0 || confidenceThreshold > 255) { + throw new Error('Invalid confidence threshold'); + } + const sx = width / imageWidth; const sy = height / imageHeight; const scaledFx = fx * sx; @@ -56,13 +78,36 @@ export function depthToPointCloud(frame, confidenceThreshold = 1) { const x = ((u - scaledCx) / scaledFx) * z; const y = ((v - scaledCy) / scaledFy) * z; - points.push([x, -y, -z]); + points.push([x, y === 0 ? 0 : -y, -z]); } } return points; } -function base64ToBytes(value) { +function assertPositiveInteger(value, name) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer`); + } +} + +function assertIntrinsics(intrinsics) { + if (!intrinsics + || !Number.isFinite(intrinsics.fx) || intrinsics.fx <= 0 + || !Number.isFinite(intrinsics.fy) || intrinsics.fy <= 0 + || !Number.isFinite(intrinsics.cx) + || !Number.isFinite(intrinsics.cy)) { + throw new Error('Invalid camera intrinsics'); + } + assertPositiveInteger(intrinsics.imageWidth, 'intrinsics.imageWidth'); + assertPositiveInteger(intrinsics.imageHeight, 'intrinsics.imageHeight'); +} + +function base64ToBytes(value, name) { + if (typeof value !== 'string' || value.length === 0 || value.length % 4 !== 0 + || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + throw new Error(`${name} must be canonical base64`); + } + if (typeof Buffer !== 'undefined') { return Uint8Array.from(Buffer.from(value, 'base64')); } diff --git a/integrations/iphone-lidar/web/codec.test.mjs b/integrations/iphone-lidar/web/codec.test.mjs index 424d3364..3c3d061a 100644 --- a/integrations/iphone-lidar/web/codec.test.mjs +++ b/integrations/iphone-lidar/web/codec.test.mjs @@ -29,10 +29,35 @@ test('decodes u16 millimeter depth and confidence', () => { test('rejects malformed payload length', () => { assert.throws(() => decodeLiDARPacket({ type: 'ruview.lidar.depth.v1', + intrinsics: { fx: 100, fy: 100, cx: 1, cy: 1, imageWidth: 2, imageHeight: 2 }, + pose: { matrix: Array(16).fill(0) }, depth: { width: 2, height: 2, encoding: 'u16le-mm+u8-confidence', millimetersBase64: b64([1,2]), confidenceBase64: b64([1,1,1,1]) }, }), /length mismatch/); }); +test('rejects invalid dimensions, intrinsics, pose, and base64', () => { + const valid = { + type: 'ruview.lidar.depth.v1', + intrinsics: { fx: 100, fy: 100, cx: 0, cy: 0, imageWidth: 1, imageHeight: 1 }, + pose: { matrix: Array(16).fill(0) }, + depth: { + width: 1, + height: 1, + encoding: 'u16le-mm+u8-confidence', + millimetersBase64: b64([0xe8, 0x03]), + confidenceBase64: b64([2]), + }, + }; + + assert.throws(() => decodeLiDARPacket({ ...valid, depth: { ...valid.depth, width: 0 } }), /positive integer/); + assert.throws(() => decodeLiDARPacket({ ...valid, intrinsics: { ...valid.intrinsics, fx: 0 } }), /intrinsics/); + assert.throws(() => decodeLiDARPacket({ ...valid, pose: { matrix: [1] } }), /pose/); + assert.throws(() => decodeLiDARPacket({ + ...valid, + depth: { ...valid.depth, millimetersBase64: '!!!!' }, + }), /canonical base64/); +}); + test('projects depth into a point cloud and honors confidence', () => { const frame = { intrinsics: { fx: 100, fy: 100, cx: 0, cy: 0, imageWidth: 2, imageHeight: 2 }, diff --git a/integrations/iphone-lidar/web/package-lock.json b/integrations/iphone-lidar/web/package-lock.json new file mode 100644 index 00000000..c4b0c350 --- /dev/null +++ b/integrations/iphone-lidar/web/package-lock.json @@ -0,0 +1,39 @@ +{ + "name": "@ruview/iphone-lidar-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@ruview/iphone-lidar-web", + "version": "0.1.0", + "dependencies": { + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/integrations/iphone-lidar/web/package.json b/integrations/iphone-lidar/web/package.json index cec45ae0..6515a1ad 100644 --- a/integrations/iphone-lidar/web/package.json +++ b/integrations/iphone-lidar/web/package.json @@ -5,7 +5,10 @@ "type": "module", "scripts": { "start": "node relay.mjs", - "test": "node --test codec.test.mjs" + "test": "node --test" + }, + "engines": { + "node": ">=20" }, "dependencies": { "ws": "^8.18.3" diff --git a/integrations/iphone-lidar/web/relay.mjs b/integrations/iphone-lidar/web/relay.mjs index 289c2fba..bbfc6b7f 100644 --- a/integrations/iphone-lidar/web/relay.mjs +++ b/integrations/iphone-lidar/web/relay.mjs @@ -1,61 +1,166 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto'; import http from 'node:http'; import { readFile } from 'node:fs/promises'; import { extname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { WebSocketServer } from 'ws'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { WebSocket, WebSocketServer } from 'ws'; const root = fileURLToPath(new URL('.', import.meta.url)); -const port = Number(process.env.PORT || 8787); -const clients = new Set(); +const staticFiles = new Set(['index.html', 'app.mjs', 'codec.mjs', 'styles.css']); +const maxPayloadBytes = 2_000_000; -const server = http.createServer(async (req, res) => { - const path = req.url === '/' ? '/index.html' : req.url?.split('?')[0] || '/index.html'; - if (path.includes('..')) { - res.writeHead(400).end('bad path'); - return; - } +function constantTimeEqual(left, right) { + const leftBytes = Buffer.from(left, 'utf8'); + const rightBytes = Buffer.from(right, 'utf8'); + return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes); +} - try { - const data = await readFile(join(root, path)); - const contentType = { - '.html': 'text/html; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.mjs': 'text/javascript; charset=utf-8', - '.css': 'text/css; charset=utf-8', - }[extname(path)] || 'application/octet-stream'; - res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-store' }); - res.end(data); - } catch { - res.writeHead(404).end('not found'); - } -}); +function rejectUpgrade(socket, status, message) { + const body = `${message}\n`; + socket.end( + `HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`, + ); +} -const wss = new WebSocketServer({ server, path: '/ws/lidar' }); +export function createLiDARRelay({ token, rootDirectory = root } = {}) { + const accessToken = token || randomBytes(24).toString('hex'); + const clients = new Set(); -wss.on('connection', (socket) => { - clients.add(socket); - socket.on('close', () => clients.delete(socket)); - socket.on('message', (data, isBinary) => { - if (isBinary || data.length > 2_000_000) return; - - let packet; - try { - packet = JSON.parse(data.toString()); - } catch { + const server = http.createServer(async (req, res) => { + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405, { allow: 'GET, HEAD' }).end(); return; } - if (packet?.type !== 'ruview.lidar.depth.v1') return; + let pathname; + try { + pathname = decodeURIComponent(new URL(req.url || '/', 'http://localhost').pathname); + } catch { + res.writeHead(400).end('bad path'); + return; + } - for (const peer of clients) { - if (peer !== socket && peer.readyState === peer.OPEN) { - peer.send(data); - } + const filename = pathname === '/' ? 'index.html' : pathname.slice(1); + if (!staticFiles.has(filename)) { + res.writeHead(404).end('not found'); + return; + } + + try { + const data = await readFile(join(rootDirectory, filename)); + const contentType = { + '.html': 'text/html; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + }[extname(filename)] || 'application/octet-stream'; + res.writeHead(200, { + 'content-type': contentType, + 'cache-control': 'no-store', + 'content-security-policy': "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self'; style-src 'self'; base-uri 'none'; frame-ancestors 'none'", + 'referrer-policy': 'no-referrer', + 'x-content-type-options': 'nosniff', + }); + if (req.method === 'HEAD') res.end(); + else res.end(data); + } catch { + res.writeHead(404).end('not found'); } }); -}); -server.listen(port, '0.0.0.0', () => { - console.log(`RuView iPhone LiDAR relay: http://0.0.0.0:${port}`); - console.log(`Native endpoint: ws://:${port}/ws/lidar`); -}); + const wss = new WebSocketServer({ noServer: true, maxPayload: maxPayloadBytes }); + + server.on('upgrade', (req, socket, head) => { + let url; + try { + url = new URL(req.url || '/', 'http://localhost'); + } catch { + rejectUpgrade(socket, '400 Bad Request', 'bad websocket URL'); + return; + } + + if (url.pathname !== '/ws/lidar') { + rejectUpgrade(socket, '404 Not Found', 'not found'); + return; + } + + const suppliedToken = url.searchParams.get('token') || ''; + if (!constantTimeEqual(suppliedToken, accessToken)) { + rejectUpgrade(socket, '401 Unauthorized', 'valid LiDAR relay token required'); + return; + } + + wss.handleUpgrade(req, socket, head, (websocket) => { + wss.emit('connection', websocket, req); + }); + }); + + wss.on('connection', (socket) => { + clients.add(socket); + socket.on('close', () => clients.delete(socket)); + socket.on('error', () => socket.terminate()); + socket.on('message', (data, isBinary) => { + if (isBinary) return; + + let packet; + try { + packet = JSON.parse(data.toString()); + } catch { + return; + } + + if (packet?.type !== 'ruview.lidar.depth.v1') return; + + for (const peer of clients) { + if (peer !== socket && peer.readyState === WebSocket.OPEN) { + peer.send(data.toString()); + } + } + }); + }); + + return { + accessToken, + server, + async listen(port = 0, host = '127.0.0.1') { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, () => { + server.off('error', reject); + resolve(); + }); + }); + return server.address(); + }, + async close() { + for (const peer of clients) peer.terminate(); + await new Promise((resolve) => wss.close(resolve)); + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }, + }; +} + +function configuredPort(value) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65_535) { + throw new Error(`PORT must be an integer from 1 to 65535; received ${value}`); + } + return parsed; +} + +const directInvocation = process.argv[1] + && pathToFileURL(process.argv[1]).href === import.meta.url; + +if (directInvocation) { + const port = configuredPort(process.env.PORT || '8787'); + const host = process.env.HOST || '0.0.0.0'; + const relay = createLiDARRelay({ token: process.env.RUVIEW_LIDAR_TOKEN }); + await relay.listen(port, host); + const encodedToken = encodeURIComponent(relay.accessToken); + console.log(`RuView iPhone LiDAR relay listening on ${host}:${port}`); + console.log(`Browser: http://:${port}/?token=${encodedToken}`); + console.log(`Native endpoint: ws://:${port}/ws/lidar?token=${encodedToken}`); +} diff --git a/integrations/iphone-lidar/web/relay.test.mjs b/integrations/iphone-lidar/web/relay.test.mjs new file mode 100644 index 00000000..54baee62 --- /dev/null +++ b/integrations/iphone-lidar/web/relay.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import test from 'node:test'; +import { WebSocket } from 'ws'; +import { createLiDARRelay } from './relay.mjs'; + +function connect(url) { + return new Promise((resolve, reject) => { + const socket = new WebSocket(url); + socket.once('open', () => resolve(socket)); + socket.once('error', reject); + }); +} + +function request(url) { + return new Promise((resolve, reject) => { + http.get(url, (response) => { + response.resume(); + response.once('end', () => resolve(response)); + }).once('error', reject); + }); +} + +test('relay requires a token, limits static files, and forwards LiDAR frames', async (t) => { + const token = 'test-token-for-relay'; + const relay = createLiDARRelay({ token }); + const address = await relay.listen(0, '127.0.0.1'); + const httpBase = `http://127.0.0.1:${address.port}`; + const wsBase = `ws://127.0.0.1:${address.port}/ws/lidar`; + const sockets = []; + + t.after(async () => { + for (const socket of sockets) socket.terminate(); + await relay.close(); + }); + + const indexResponse = await request(`${httpBase}/?token=${token}`); + assert.equal(indexResponse.statusCode, 200); + assert.match(indexResponse.headers['content-security-policy'], /frame-ancestors 'none'/); + + const sourceResponse = await request(`${httpBase}/relay.mjs`); + assert.equal(sourceResponse.statusCode, 404); + + await new Promise((resolve, reject) => { + const unauthorized = new WebSocket(wsBase); + unauthorized.once('unexpected-response', (_request, response) => { + assert.equal(response.statusCode, 401); + response.resume(); + resolve(); + }); + unauthorized.once('open', () => reject(new Error('unauthorized websocket opened'))); + unauthorized.once('error', () => {}); + }); + + const sender = await connect(`${wsBase}?token=${encodeURIComponent(token)}`); + const receiver = await connect(`${wsBase}?token=${encodeURIComponent(token)}`); + sockets.push(sender, receiver); + + const received = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timed out waiting for relayed frame')), 2_000); + receiver.once('message', (data, isBinary) => { + clearTimeout(timer); + resolve({ data, isBinary }); + }); + }); + + const frame = { type: 'ruview.lidar.depth.v1', provenance: { sequence: 7 } }; + sender.send(JSON.stringify(frame)); + const message = await received; + + assert.equal(message.isBinary, false); + assert.deepEqual(JSON.parse(message.data.toString()), frame); +});