diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09c96d6d9..d13110168 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: push: branches: [ "main" ] pull_request: - branches: [ "main", "dev-*" ] + branches: [ "main", "dev-*", "usp" ] workflow_dispatch: inputs: job: diff --git a/.github/workflows/usp-boundary.yml b/.github/workflows/usp-boundary.yml new file mode 100644 index 000000000..bde91a1b4 --- /dev/null +++ b/.github/workflows/usp-boundary.yml @@ -0,0 +1,37 @@ +name: USP consumer boundary + +on: + pull_request: + branches: [usp, 'dev-*'] + push: + branches: [usp, 'dev-*'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + dart-wasm-boundary: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.13' + - name: Verify local vendored artifact hashes and required exports + run: >- + python3 tools/usp_boundary/verify_artifacts.py + --manifest web/usp-artifacts.json + --root . + - name: Compare discovered Dart bindings with the TypeScript and local JS surfaces + run: >- + python3 tools/usp_boundary/check_boundary.py + --dts web/usp_client.d.ts + --dart-root lib/core/usp/web + --policy tools/usp_boundary/policy.json + --root . + - name: Prove drift fixtures fail closed + run: >- + python3 -m unittest discover + -s tools/usp_boundary + -p 'test_*.py' diff --git a/analysis_options.yaml b/analysis_options.yaml index 04e69e71f..6f1a42c70 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -8,6 +8,10 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. analyzer: + exclude: + # Deliberately invalid boundary fixtures are parsed by the Python + # contract tests and must not be treated as application Dart sources. + - tools/usp_boundary/fixtures/** errors: prefer_const_constructors: ignore include: package:flutter_lints/flutter.yaml diff --git a/doc/usp/vendored-artifacts.md b/doc/usp/vendored-artifacts.md index 7efce7f6b..255a3c13c 100644 --- a/doc/usp/vendored-artifacts.md +++ b/doc/usp/vendored-artifacts.md @@ -9,13 +9,28 @@ Files in this repo that are built or generated from `linksys/usp_framework` and ## Manifest -**Last updated**: 2026-07-07 +**Last updated**: 2026-07-19 + +The web package records reviewed `usp_framework` commit +`b5e65ae9ce3fc5d61abe63adb269d77a5a7a9cbd` and was built with both the +`wasm` and `websocket` features. Machine-verifiable local hashes and the +reviewed upstream paths are recorded in `web/usp-artifacts.json`. Consumer CI +does not authenticate the cross-repository commit reference; the producer +repository owns the source-to-committed-package rebuild gate. + +The generated TypeScript surface includes `UspClient.subscribe()` and +`unsubscribe()`, but PrivacyGUI intentionally does not bind those methods. +Application subscriptions go through the authenticated `UspBridgeClient` and +SSE lifecycle instead. `tools/usp_boundary/policy.json` records that decision +and fails if either the upstream method or the consumer policy disappears +without review. | # | Artifact | Version | Checked-in path | Upstream source | |---|----------|---------|-----------------|-----------------| | 1 | `usp-codegen` (Mach-O arm64) | **0.15.3** | `tools/usp-codegen` | `usp-codegen/bin/usp-codegen` (built from `src/` via `Makefile.standalone`) | | 2 | `usp_client.js` | **0.12.0** | `web/usp_client.js` | `usp-client/pkg/usp_client.js` | | 3 | `usp_client_bg.wasm` | **0.12.0** | `web/usp_client_bg.wasm` | `usp-client/pkg/usp_client_bg.wasm` | +| 4 | `usp_client.d.ts` | **0.12.0** | `web/usp_client.d.ts` | `usp-client/pkg/usp_client.d.ts` | ## Derived (generated locally, not copied) @@ -49,13 +64,21 @@ The YAML definitions are **not vendored** into this repo. 3. **Web client assets** ```bash + cd linksys/usp/usp_framework/usp-client + wasm-pack build --release --target web --out-dir pkg --features wasm,websocket + cd - cp linksys/usp/usp_framework/usp-client/pkg/usp_client.js web/usp_client.js cp linksys/usp/usp_framework/usp-client/pkg/usp_client_bg.wasm web/usp_client_bg.wasm + cp linksys/usp/usp_framework/usp-client/pkg/usp_client.d.ts web/usp_client.d.ts ``` -4. **Update the version table above** in the same commit as the artifact change. +4. **Update `web/usp-artifacts.json` and the version table above** in the same + commit as the artifact change. Use the full upstream commit SHA and hashes + from the producer artifact. 5. **Sanity checks** + - `python3 tools/usp_boundary/verify_artifacts.py --manifest web/usp-artifacts.json --root .` + - `python3 tools/usp_boundary/check_boundary.py --help` - `flutter analyze lib/generated` — no new errors - `flutter analyze lib/page lib/core` — no new errors - Grep for removed APIs when bumping `usp-client` (e.g. `setOrderedWithOptions` was removed in 0.9.0) diff --git a/lib/core/usp/stub/usp_client_stub.dart b/lib/core/usp/stub/usp_client_stub.dart index 2b987093d..52bd2e41d 100644 --- a/lib/core/usp/stub/usp_client_stub.dart +++ b/lib/core/usp/stub/usp_client_stub.dart @@ -12,12 +12,6 @@ class UspClientWeb { String? get sessionToken => null; - Future subscribe(String subscriptionId) => - throw UnsupportedError('USP is only available on Web'); - - Future unsubscribe(String subscriptionId) => - throw UnsupportedError('USP is only available on Web'); - Future login(String password) => throw UnsupportedError('USP is only available on Web'); diff --git a/lib/core/usp/web/usp_client_wasm.dart b/lib/core/usp/web/usp_client_wasm.dart index bc0f27844..dfd5a1164 100644 --- a/lib/core/usp/web/usp_client_wasm.dart +++ b/lib/core/usp/web/usp_client_wasm.dart @@ -65,10 +65,6 @@ extension type UspClientJS._(JSObject _) implements JSObject { @JS('getToken') external String? getToken(); - external JSPromise subscribe(String subscriptionId); - - external JSPromise unsubscribe(String subscriptionId); - external JSPromise login(String password); external JSPromise logout(); @@ -130,14 +126,6 @@ class UspClientWeb { } } - Future subscribe(String subscriptionId) async { - await _client.subscribe(subscriptionId).toDart; - } - - Future unsubscribe(String subscriptionId) async { - await _client.unsubscribe(subscriptionId).toDart; - } - Future login(String password) async { try { await _client.login(password).toDart; diff --git a/lib/core/usp/web/usp_ws_client_wrapper.dart b/lib/core/usp/web/usp_ws_client_wrapper.dart index 9e871ff92..d5f625387 100644 --- a/lib/core/usp/web/usp_ws_client_wrapper.dart +++ b/lib/core/usp/web/usp_ws_client_wrapper.dart @@ -52,7 +52,8 @@ external JSUint8Array buildWebSocketConnectJS(JSString fromId, JSString toId); @JS('decodeRecord') external JSAny decodeRecordJS(JSUint8Array data); -// Native JS helpers that bypass Dart interop issues +// Native JS helpers copy Wasm-backed bytes before a Wasm memory growth can +// detach their ArrayBuffer while Dart hands them back to JavaScript. @JS('sendWebSocketConnectNative') external JSPromise _sendWebSocketConnectNative( UspWsClientJS wsClient, JSString fromId, JSString toId); @@ -61,11 +62,6 @@ external JSPromise _sendWebSocketConnectNative( external JSPromise _sendOperateRecordNative(UspWsClientJS wsClient, JSString command, JSAny inputArgs, JSString fromId, JSString toId); -// Debug helper to test sendRecord from pure JS -@JS('uspWsSendRecord') -external JSPromise _uspWsSendRecord( - UspWsClientJS wsClient, JSUint8Array bytes); - // ----------------------------------------------------------------------------- // Dart Wrapper // ----------------------------------------------------------------------------- @@ -146,27 +142,11 @@ class UspWsClientWrapper { } final wrapper = UspWsClientWrapper._(jsClient as UspWsClientJS); - // _setupCallbacks is called in constructor, state callback now active - - // Wait for state to settle — either 'open' from callback or 'closed' on error - // Most connections settle within 100-300ms; SSL errors close immediately - for (var i = 0; i < 10; i++) { - await Future.delayed(const Duration(milliseconds: 100)); - if (wrapper._currentState == WsConnectionState.open) { - logger.i('$_tag Connected to $url'); - return wrapper; - } - if (wrapper._currentState == WsConnectionState.closed) { - logger.e('$_tag WebSocket closed during connect'); - wrapper.dispose(); - throw StateError( - 'WebSocket connection closed (SSL error or server reject)'); - } - } - - // Still connecting after 1s — assume success (some browsers don't fire open callback) + // The producer's connect Promise is the readiness contract: it resolves + // only after the browser upgrade reaches OPEN and rejects failed + // upgrades. Do not infer readiness from a timer. wrapper._currentState = WsConnectionState.open; - logger.i('$_tag Connected to $url (no open callback, assuming success)'); + logger.i('$_tag Connected to $url'); return wrapper; } catch (e) { logger.e('$_tag Connection failed: $e'); @@ -214,8 +194,7 @@ class UspWsClientWrapper { throw StateError('WebSocket is not open (state: $_currentState)'); } logger.d('$_tag sendRecord: ${bytes.length} bytes'); - // Use debug helper to inspect bytes in pure JS - await _uspWsSendRecord(_jsClient, bytes.toJS).toDart; + await _jsClient.sendRecord_(bytes.toJS).toDart; } /// Send a WebSocketConnect record (required as first frame per TR-369 §6.4.5). diff --git a/lib/page/firmware_update/services/firmware_ws_upload_strategy.dart b/lib/page/firmware_update/services/firmware_ws_upload_strategy.dart index 2292c9821..50c0fb802 100644 --- a/lib/page/firmware_update/services/firmware_ws_upload_strategy.dart +++ b/lib/page/firmware_update/services/firmware_ws_upload_strategy.dart @@ -12,6 +12,8 @@ import 'package:privacy_gui/page/firmware_update/services/firmware_upload_strate const _tag = '[FirmwareUpdate]'; +typedef UspWsConnector = Future Function(String url); + /// WebSocket-based firmware upload strategy (Method 2). /// /// Uses direct WebSocket connection to OBUSPA (`wss://router/usp-ws`), @@ -34,6 +36,8 @@ class FirmwareWsUploadStrategy implements FirmwareUploadStrategy { final String _wsUrl; final String _fromId; final String _toId; + final UspWsConnector _connect; + final Duration _handshakeTimeout; UspWsClientWrapper? _wsClient; StreamSubscription? _messageSubscription; @@ -44,10 +48,14 @@ class FirmwareWsUploadStrategy implements FirmwareUploadStrategy { required String wsUrl, required String fromId, required String toId, + UspWsConnector? connect, + Duration handshakeTimeout = const Duration(seconds: 10), }) : _turboManager = turboManager, _wsUrl = wsUrl, _fromId = fromId, - _toId = toId; + _toId = toId, + _connect = connect ?? ((url) => UspWsClientWrapper.connect(url)), + _handshakeTimeout = handshakeTimeout; @override String get name => 'WebSocket'; @@ -77,7 +85,7 @@ class FirmwareWsUploadStrategy implements FirmwareUploadStrategy { // 2. Connect WebSocket try { - _wsClient = await UspWsClientWrapper.connect(_wsUrl); + _wsClient = await _connect(_wsUrl); } catch (e) { logger.e('$_tag Failed to connect WebSocket: $e'); await _turboManager.release(); @@ -109,13 +117,7 @@ class FirmwareWsUploadStrategy implements FirmwareUploadStrategy { .d('$_tag WebSocketConnect handshake sent, waiting for response...'); // Wait for handshake response - await _responseCompleter!.future.timeout( - const Duration(seconds: 10), - onTimeout: () { - logger - .w('$_tag WebSocketConnect response timeout (continuing anyway)'); - }, - ); + await _responseCompleter!.future.timeout(_handshakeTimeout); _responseCompleter = null; logger.d('$_tag WebSocketConnect handshake complete'); } catch (e) { diff --git a/test/page/firmware_update/services/firmware_local_upload_service_test.dart b/test/page/firmware_update/services/firmware_local_upload_service_test.dart index 4d6dc2ec0..6553332f4 100644 --- a/test/page/firmware_update/services/firmware_local_upload_service_test.dart +++ b/test/page/firmware_update/services/firmware_local_upload_service_test.dart @@ -8,9 +8,13 @@ import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; import 'package:privacy_gui/page/firmware_update/services/firmware_chunker.dart'; import 'package:privacy_gui/page/firmware_update/services/firmware_local_upload_service.dart'; +import 'package:privacy_gui/page/firmware_update/services/firmware_upload_strategy.dart'; class MockUspClient extends Mock implements UspClient {} +class MockFirmwareUploadStrategy extends Mock + implements FirmwareUploadStrategy {} + void main() { late MockUspClient mockUsp; late UspMutationLock lock; @@ -156,6 +160,35 @@ void main() { ); }); + test('falls back to HTTP when WebSocket preparation fails', () async { + final wsStrategy = MockFirmwareUploadStrategy(); + when(() => wsStrategy.name).thenReturn('WebSocket'); + when(() => wsStrategy.isAvailable()).thenAnswer((_) async => true); + when(() => wsStrategy.prepare()) + .thenThrow(const NetworkError(detail: 'handshake failed')); + when(() => wsStrategy.finalize()).thenAnswer((_) async {}); + when(() => mockUsp.operate(any(), args: any(named: 'args'))) + .thenAnswer((_) async => {}); + + service = FirmwareLocalUploadService( + mockUsp, + lock, + chunker: const FirmwareChunker(chunkBytes: 4), + wsStrategyFactory: () async => wsStrategy, + ); + + await service.uploadFile( + bytes: bytesOf(4), + md5: 'abc', + commandKey: '123', + ); + + expect(service.lastUsedMethod, UploadMethod.http); + verify(() => wsStrategy.prepare()).called(1); + verify(() => wsStrategy.finalize()).called(1); + verify(() => mockUsp.operate(any(), args: any(named: 'args'))).called(1); + }); + test('totalFragmentsFor mirrors chunker math', () { expect(service.totalFragmentsFor(0), 0); expect(service.totalFragmentsFor(1), 1); diff --git a/test/page/firmware_update/services/firmware_ws_upload_strategy_test.dart b/test/page/firmware_update/services/firmware_ws_upload_strategy_test.dart new file mode 100644 index 000000000..53515419d --- /dev/null +++ b/test/page/firmware_update/services/firmware_ws_upload_strategy_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/core/usp/models/usp_ws_message.dart'; +import 'package:privacy_gui/core/usp/services/turbo_session_manager.dart'; +import 'package:privacy_gui/core/usp/usp_ws_client.dart'; +import 'package:privacy_gui/page/firmware_update/services/firmware_ws_upload_strategy.dart'; + +class MockTurboSessionManager extends Mock implements TurboSessionManager {} + +class MockUspWsClientWrapper extends Mock implements UspWsClientWrapper {} + +void main() { + late MockTurboSessionManager turboManager; + late MockUspWsClientWrapper wsClient; + + setUp(() { + turboManager = MockTurboSessionManager(); + wsClient = MockUspWsClientWrapper(); + + when(() => turboManager.start()).thenAnswer((_) async => 'test-session'); + when(() => turboManager.release()).thenAnswer((_) async {}); + when(() => wsClient.onMessage) + .thenAnswer((_) => const Stream.empty()); + when( + () => wsClient.sendWebSocketConnect( + fromId: any(named: 'fromId'), + toId: any(named: 'toId'), + ), + ).thenAnswer((_) async {}); + }); + + test('handshake timeout fails preparation and releases all resources', + () async { + String? connectedUrl; + final strategy = FirmwareWsUploadStrategy( + turboManager: turboManager, + wsUrl: 'wss://router.test/usp-ws', + fromId: 'controller::test', + toId: 'agent::test', + connect: (url) async { + connectedUrl = url; + return wsClient; + }, + handshakeTimeout: const Duration(milliseconds: 1), + ); + + await expectLater(strategy.prepare(), throwsA(isA())); + + expect(connectedUrl, 'wss://router.test/usp-ws'); + verify(() => turboManager.start()).called(1); + verify( + () => wsClient.sendWebSocketConnect( + fromId: 'controller::test', + toId: 'agent::test', + ), + ).called(1); + verify(() => wsClient.dispose()).called(1); + verify(() => turboManager.release()).called(1); + }); +} diff --git a/tools/usp_boundary/README.md b/tools/usp_boundary/README.md new file mode 100644 index 000000000..109fbcd29 --- /dev/null +++ b/tools/usp_boundary/README.md @@ -0,0 +1,44 @@ +# USP Dart/Wasm boundary gate + +This gate compares PrivacyGUI's Dart `external` bindings with the vendored +`usp_client.d.ts` contract. It checks class and method existence, method arity, +compatible parameter/return types, local JavaScript shim arity, and an explicit +policy for every intentionally unbound TypeScript method. + +Run it from the repository root: + +```bash +python3 tools/usp_boundary/verify_artifacts.py \ + --manifest web/usp-artifacts.json \ + --root . + +python3 tools/usp_boundary/check_boundary.py \ + --dts web/usp_client.d.ts \ + --dart-root lib/core/usp/web \ + --policy tools/usp_boundary/policy.json \ + --root . + +python3 -m unittest discover -s tools/usp_boundary -p 'test_*.py' +``` + +The checker discovers every Dart `external` declaration below the configured +root, including top-level getters such as `__uspClientReady`. It fails if any +declaration is not recognized, requires the expected JS classes and globals, +and enforces a non-zero decision floor. The +arity-mismatch fixture recreates the former one-argument Dart +`subscribe()` binding against the real three-argument TypeScript shape and +proves the checker fails. Hash verification proves the three vendored files +remain the reviewed artifact set recorded in `web/usp-artifacts.json`. The +manifest records the reviewed upstream commit, but consumer CI does not claim +to authenticate that cross-repository reference. The producer repository owns +the source-to-committed-package rebuild gate. + +The checker compares normalized parameter and return types. TypeScript +parameter optionality is not asserted as equivalent to Dart nullability: +PrivacyGUI can intentionally require a nullable argument even when the +TypeScript caller may omit it. Any optionality-sensitive behavior needs an +explicit wrapper test rather than an inaccurate syntax-only equality claim. + +This is a producer/consumer API gate. It does not claim that a browser loaded +the application against a router, that firmware upload succeeded on a device, +or that TR-369 conformance passed. diff --git a/tools/usp_boundary/check_boundary.py b/tools/usp_boundary/check_boundary.py new file mode 100644 index 000000000..9a91c6c4c --- /dev/null +++ b/tools/usp_boundary/check_boundary.py @@ -0,0 +1,526 @@ +#!/usr/bin/env python3 +"""Fail closed when PrivacyGUI Dart JS interop drifts from usp_client.d.ts.""" + +import argparse +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Parameter: + type_name: str + optional: bool = False + + +@dataclass(frozen=True) +class Signature: + name: str + parameters: tuple[Parameter, ...] + return_type: str + kind: str = "callable" + + +def strip_comments(text): + text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL) + return re.sub(r"//[^\n]*", "", text) + + +def matching_brace(text, opening): + depth = 0 + for index in range(opening, len(text)): + if text[index] == "{": + depth += 1 + elif text[index] == "}": + depth -= 1 + if depth == 0: + return index + raise ValueError("unclosed block") + + +def split_parameters(raw): + if not raw.strip(): + return [] + result = [] + start = 0 + depths = {"<": 0, "(": 0, "{": 0, "[": 0} + pairs = {">": "<", ")": "(", "}": "{", "]": "["} + for index, char in enumerate(raw): + if char in depths: + depths[char] += 1 + elif char in pairs: + depths[pairs[char]] -= 1 + elif char == "," and all(value == 0 for value in depths.values()): + result.append(raw[start:index].strip()) + start = index + 1 + result.append(raw[start:].strip()) + return [item for item in result if item] + + +def ts_parameters(raw): + result = [] + for item in split_parameters(raw): + match = re.match(r"[\w$]+\s*(\?)?\s*:\s*(.+)$", item, re.DOTALL) + if not match: + raise ValueError("cannot parse TypeScript parameter: %s" % item) + result.append(Parameter(match.group(2).strip(), bool(match.group(1)))) + return tuple(result) + + +def dart_parameters(raw): + result = [] + for item in split_parameters(raw): + item = re.sub(r"\b(required|covariant)\s+", "", item.strip()) + match = re.match(r"(.+?)\s+\w+$", item, re.DOTALL) + if not match: + raise ValueError("cannot parse Dart parameter: %s" % item) + result.append(Parameter(match.group(1).strip(), False)) + return tuple(result) + + +def parse_typescript(path): + text = strip_comments(Path(path).read_text(encoding="utf-8")) + classes = {} + class_spans = [] + for match in re.finditer(r"export\s+class\s+(\w+)\s*\{", text): + class_name = match.group(1) + opening = text.index("{", match.start()) + closing = matching_brace(text, opening) + class_spans.append((match.start(), closing + 1)) + body = text[opening + 1:closing] + methods = {} + constructor = re.search( + r"(?ms)^\s*(private\s+)?constructor\s*\(([^;]*?)\)\s*;", + body, + ) + if constructor and not constructor.group(1): + methods["constructor"] = Signature( + "constructor", ts_parameters(constructor.group(2)), class_name + ) + method_pattern = re.compile( + r"(?m)^\s*(?:static\s+)?(\[Symbol\.dispose\]|\w+)" + r"\s*\(([^;]*?)\)\s*:\s*([^;\n]+);", + re.DOTALL, + ) + for method in method_pattern.finditer(body): + name = ( + "Symbol.dispose" + if method.group(1) == "[Symbol.dispose]" + else method.group(1) + ) + methods[name] = Signature( + name, + ts_parameters(method.group(2)), + method.group(3).strip(), + ) + classes[class_name] = methods + + functions = {} + function_pattern = re.compile( + r"export\s+(?:default\s+)?function\s+(\w+)\s*" + r"\((.*?)\)\s*:\s*([^;]+);", + re.DOTALL, + ) + for match in function_pattern.finditer(text): + if any(start <= match.start() < end for start, end in class_spans): + continue + functions[match.group(1)] = Signature( + match.group(1), + ts_parameters(match.group(2)), + match.group(3).strip(), + ) + return classes, functions + + +def parse_dart_extensions(path): + text = strip_comments(Path(path).read_text(encoding="utf-8")) + classes = {} + spans = [] + extension_pattern = re.compile( + r"@JS\('([^']+)'\)" + r"(?:\s*@[A-Za-z_]\w*(?:\([^)]*\))?)*" + r"\s*extension\s+type\s+\w+.*?\{", + re.DOTALL, + ) + for match in extension_pattern.finditer(text): + js_class = match.group(1) + opening = text.index("{", match.start()) + closing = matching_brace(text, opening) + spans.append((match.start(), closing + 1)) + body = text[opening + 1:closing] + methods = {} + + factory_pattern = re.compile( + r"(?ms)^\s*(?:@JS\('([^']+)'\)\s*)?" + r"external\s+factory\s+\w+(?:\._)?\s*\(([^;]*?)\)\s*;", + ) + for factory in factory_pattern.finditer(body): + name = factory.group(1) or "constructor" + methods[name] = Signature( + name, dart_parameters(factory.group(2)), js_class + ) + + method_pattern = re.compile( + r"(?ms)^\s*(?:@JS\('([^']+)'\)\s*)?" + r"external\s+(?!factory\b)(?:static\s+)?(\S+)\s+(\w+)\s*" + r"\(([^;]*?)\)\s*;", + ) + for method in method_pattern.finditer(body): + dart_name = method.group(3) + name = method.group(1) or dart_name.rstrip("_") + methods[name] = Signature( + name, + dart_parameters(method.group(4)), + method.group(2).strip(), + ) + classes[js_class] = methods + return classes, spans, text + + +def parse_dart_globals(path, spans, text): + globals_ = {} + callable_pattern = re.compile( + r"(?ms)^\s*@JS\('([^']+)'\)\s*" + r"external\s+(\S+)\s+\w+\s*\(([^;]*?)\)\s*;", + ) + for match in callable_pattern.finditer(text): + if any(start <= match.start() < end for start, end in spans): + continue + globals_[match.group(1)] = Signature( + match.group(1), + dart_parameters(match.group(3)), + match.group(2).strip(), + ) + getter_pattern = re.compile( + r"(?ms)^\s*@JS\('([^']+)'\)\s*" + r"external\s+(\S+)\s+get\s+\w+\s*;", + ) + for match in getter_pattern.finditer(text): + if any(start <= match.start() < end for start, end in spans): + continue + globals_[match.group(1)] = Signature( + match.group(1), + (), + match.group(2).strip(), + kind="getter", + ) + return globals_ + + +def normalized_type(type_name): + compact = re.sub(r"\s+", "", type_name) + compact = compact.replace("|undefined", "").replace("|null", "") + compact = compact.rstrip("?") + if compact.startswith("Promise<") or compact.startswith("JSPromise<"): + return "promise" + if "Function(" in compact or "=>" in compact: + return "function" + mapping = { + "any": "any", + "JSAny": "any", + "string": "string", + "String": "string", + "JSString": "string", + "boolean": "bool", + "bool": "bool", + "number": "number", + "int": "number", + "num": "number", + "Uint8Array": "bytes", + "JSUint8Array": "bytes", + "Function": "function", + "JSFunction": "function", + "void": "void", + "UspClient": "usp-client", + "UspClientJS": "usp-client", + "UspClientBuilder": "usp-client-builder", + "UspClientBuilderJS": "usp-client-builder", + "UspWsClient": "usp-ws-client", + "UspWsClientJS": "usp-ws-client", + } + return mapping.get(compact, compact) + + +def compare_signature(label, dart, typescript): + errors = [] + if dart.kind != typescript.kind: + errors.append( + "%s declaration mismatch: Dart=%s TypeScript=%s" + % (label, dart.kind, typescript.kind) + ) + if len(dart.parameters) != len(typescript.parameters): + errors.append( + "%s arity mismatch: Dart=%d TypeScript=%d" + % (label, len(dart.parameters), len(typescript.parameters)) + ) + return errors + for index, (dart_param, ts_param) in enumerate( + zip(dart.parameters, typescript.parameters), start=1 + ): + if normalized_type(dart_param.type_name) != normalized_type(ts_param.type_name): + errors.append( + "%s parameter %d type mismatch: Dart=%s TypeScript=%s" + % (label, index, dart_param.type_name, ts_param.type_name) + ) + if normalized_type(dart.return_type) != normalized_type(typescript.return_type): + errors.append( + "%s return type mismatch: Dart=%s TypeScript=%s" + % (label, dart.return_type, typescript.return_type) + ) + return errors + + +def local_js_arity(path, name): + text = Path(path).read_text(encoding="utf-8") + match = re.search( + r"window\." + re.escape(name) + + r"\s*=\s*(?:async\s+)?function\s*\((.*?)\)", + text, + re.DOTALL, + ) + if not match: + return None + return len(split_parameters(match.group(1))) + + +def local_js_value_exists(path, name): + text = Path(path).read_text(encoding="utf-8") + return bool( + re.search( + r"window\." + re.escape(name) + r"\s*=", + text, + ) + ) + + +def discover_dart_paths(roots): + """Discover every Dart file below roots that declares JS externals.""" + discovered = [] + for root in roots: + root = Path(root) + if not root.is_dir(): + raise ValueError("Dart discovery root is not a directory: %s" % root) + for path in sorted(root.rglob("*.dart")): + text = strip_comments(path.read_text(encoding="utf-8")) + if re.search(r"\bexternal\b", text): + discovered.append(path) + return discovered + + +def run_check(dts_path, dart_paths, policy_path, root): + policy = json.loads(Path(policy_path).read_text(encoding="utf-8")) + if policy.get("schema_version") != 1: + return [], ["policy schema_version must be 1"] + + ts_classes, ts_functions = parse_typescript(dts_path) + dart_classes = {} + dart_globals = {} + errors = [] + for dart_path in dart_paths: + classes, spans, text = parse_dart_extensions(dart_path) + globals_ = parse_dart_globals(dart_path, spans, text) + declared = len(re.findall(r"\bexternal\b", text)) + recognized = sum(len(methods) for methods in classes.values()) + len(globals_) + if recognized != declared: + errors.append( + "%s contains %d external declarations but only %d were recognized" + % (dart_path, declared, recognized) + ) + for class_name, methods in classes.items(): + dart_classes.setdefault(class_name, {}).update(methods) + dart_globals.update(globals_) + + reports = [] + unbound = policy.get("intentionally_unbound", {}) + if not isinstance(unbound, dict): + return reports, ["policy intentionally_unbound must be an object"] + unbound_shape_valid = True + for class_name, methods in unbound.items(): + if not isinstance(methods, dict): + errors.append( + "policy intentionally_unbound.%s must be an object" % class_name + ) + unbound_shape_valid = False + continue + for method_name, reason in methods.items(): + if not isinstance(reason, str) or not reason.strip(): + errors.append( + "policy reason for %s.%s must be a non-empty string" + % (class_name, method_name) + ) + if not unbound_shape_valid: + return reports, errors + + for class_name, methods in dart_classes.items(): + if class_name not in ts_classes: + errors.append("Dart binds missing TypeScript class %s" % class_name) + continue + for method_name, dart_signature in methods.items(): + ts_signature = ts_classes[class_name].get(method_name) + label = "%s.%s" % (class_name, method_name) + if not ts_signature: + errors.append("Dart binds missing TypeScript method %s" % label) + continue + errors.extend(compare_signature(label, dart_signature, ts_signature)) + reports.append("BOUND %s" % label) + + for class_name in dart_classes: + if class_name not in ts_classes: + continue + bound = set(dart_classes[class_name]) + for method_name in sorted(set(ts_classes[class_name]) - bound): + reason = unbound.get(class_name, {}).get(method_name) + label = "%s.%s" % (class_name, method_name) + if not reason: + errors.append( + "TypeScript method %s is unbound and has no policy reason" % label + ) + else: + reports.append("INTENTIONALLY UNBOUND %s — %s" % (label, reason)) + + for class_name, methods in unbound.items(): + for method_name in methods: + if class_name not in ts_classes or method_name not in ts_classes[class_name]: + errors.append( + "stale unbound policy entry %s.%s" % (class_name, method_name) + ) + elif method_name in dart_classes.get(class_name, {}): + errors.append( + "stale unbound policy entry for bound method %s.%s" + % (class_name, method_name) + ) + + local_globals = policy.get("local_js_globals", {}) + if not isinstance(local_globals, dict): + return reports, errors + ["policy local_js_globals must be an object"] + for name, dart_signature in dart_globals.items(): + if name in ts_functions: + errors.extend( + compare_signature( + "top-level %s" % name, dart_signature, ts_functions[name] + ) + ) + reports.append("BOUND top-level %s" % name) + continue + local = local_globals.get(name) + if not local: + errors.append( + "Dart binds top-level %s, absent from .d.ts and local shim policy" % name + ) + continue + if not isinstance(local, dict): + errors.append("local JS policy entry %s must be an object" % name) + continue + defined_in = local.get("defined_in") + reason = local.get("reason") + if not isinstance(defined_in, str) or not defined_in: + errors.append("local JS policy entry %s needs defined_in" % name) + continue + if not isinstance(reason, str) or not reason.strip(): + errors.append("local JS policy entry %s needs a reason" % name) + continue + js_path = Path(root) / defined_in + local_kind = local.get("kind", "callable") + if local_kind not in {"callable", "getter"}: + errors.append( + "local JS policy entry %s has unsupported kind %s" + % (name, local_kind) + ) + continue + if local_kind != dart_signature.kind: + errors.append( + "local JS %s declaration mismatch: Dart=%s JavaScript=%s" + % (name, dart_signature.kind, local_kind) + ) + elif local_kind == "getter": + if local_js_value_exists(js_path, name): + reports.append("LOCAL JS VALUE %s — %s" % (name, reason)) + else: + errors.append( + "local JS value %s is not defined in %s" % (name, js_path) + ) + else: + arity = local_js_arity(js_path, name) + if arity is None: + errors.append( + "local JS shim %s is not defined in %s" % (name, js_path) + ) + elif arity != len(dart_signature.parameters): + errors.append( + "local JS shim %s arity mismatch: Dart=%d JavaScript=%d" + % (name, len(dart_signature.parameters), arity) + ) + else: + reports.append("LOCAL JS SHIM %s — %s" % (name, reason)) + + for name in local_globals: + if name not in dart_globals: + errors.append("stale local JS shim policy entry %s" % name) + + required_classes = policy.get("required_dart_classes", []) + if ( + not isinstance(required_classes, list) + or not all(isinstance(name, str) for name in required_classes) + ): + errors.append("policy required_dart_classes must be an array of strings") + required_classes = [] + for class_name in required_classes: + if class_name not in dart_classes: + errors.append("required Dart JS class was not discovered: %s" % class_name) + + required_globals = policy.get("required_dart_globals", []) + if ( + not isinstance(required_globals, list) + or not all(isinstance(name, str) for name in required_globals) + ): + errors.append("policy required_dart_globals must be an array of strings") + required_globals = [] + for name in required_globals: + if name not in dart_globals: + errors.append("required Dart JS global was not discovered: %s" % name) + + minimum = policy.get("minimum_decisions") + if not isinstance(minimum, int) or isinstance(minimum, bool) or minimum < 1: + errors.append("policy minimum_decisions must be a positive integer") + elif len(reports) < minimum: + errors.append( + "boundary decision floor not met: verified=%d required=%d" + % (len(reports), minimum) + ) + return reports, errors + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--dts", required=True) + parser.add_argument("--dart", action="append", default=[]) + parser.add_argument("--dart-root", action="append", default=[]) + parser.add_argument("--policy", required=True) + parser.add_argument("--root", default=".") + args = parser.parse_args(argv) + try: + dart_paths = [Path(path) for path in args.dart] + dart_paths.extend(discover_dart_paths(args.dart_root)) + dart_paths = sorted(set(path.resolve() for path in dart_paths)) + if not dart_paths: + raise ValueError("no Dart external-binding files were discovered") + reports, errors = run_check( + args.dts, dart_paths, args.policy, Path(args.root).resolve() + ) + except (OSError, ValueError, json.JSONDecodeError) as error: + print("FAIL: %s" % error, file=sys.stderr) + return 1 + for report in sorted(reports): + print(report) + if errors: + for error in errors: + print("FAIL: %s" % error, file=sys.stderr) + return 1 + print("PASS: %d boundary decisions verified" % len(reports)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/usp_boundary/fixtures/arity_mismatch/policy.json b/tools/usp_boundary/fixtures/arity_mismatch/policy.json new file mode 100644 index 000000000..c1b05d924 --- /dev/null +++ b/tools/usp_boundary/fixtures/arity_mismatch/policy.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "minimum_decisions": 1, + "intentionally_unbound": {}, + "local_js_globals": {} +} diff --git a/tools/usp_boundary/fixtures/arity_mismatch/usp_client.d.ts b/tools/usp_boundary/fixtures/arity_mismatch/usp_client.d.ts new file mode 100644 index 000000000..46a10adba --- /dev/null +++ b/tools/usp_boundary/fixtures/arity_mismatch/usp_client.d.ts @@ -0,0 +1,3 @@ +export class UspClient { + subscribe(subscription_id: string, path: string, notification_type: number): Promise; +} diff --git a/tools/usp_boundary/fixtures/arity_mismatch/usp_client_wasm.dart b/tools/usp_boundary/fixtures/arity_mismatch/usp_client_wasm.dart new file mode 100644 index 000000000..9747e3b52 --- /dev/null +++ b/tools/usp_boundary/fixtures/arity_mismatch/usp_client_wasm.dart @@ -0,0 +1,4 @@ +@JS('UspClient') +extension type UspClientJS._(JSObject _) implements JSObject { + external JSPromise subscribe(String subscriptionId); +} diff --git a/tools/usp_boundary/policy.json b/tools/usp_boundary/policy.json new file mode 100644 index 000000000..3c964994c --- /dev/null +++ b/tools/usp_boundary/policy.json @@ -0,0 +1,44 @@ +{ + "schema_version": 1, + "minimum_decisions": 40, + "required_dart_classes": [ + "UspClient", + "UspClientBuilder", + "UspWsClient" + ], + "required_dart_globals": [ + "__uspClientReady" + ], + "intentionally_unbound": { + "UspClient": { + "Symbol.dispose": "Generated JavaScript lifecycle alias; Dart calls free() explicitly.", + "baseUrl": "The Dart wrapper owns the configured URL and does not read it back from Wasm.", + "connectNotifications": "PrivacyGUI subscriptions use its authenticated SSE strategy, not the Wasm EventSource helper.", + "notificationsUrl": "PrivacyGUI builds the authenticated SSE endpoint in its SSE strategy.", + "subscribe": "The unused drifted Wasm binding was removed; subscriptions use UspBridgeClient plus SSE.", + "unsubscribe": "The unused Wasm binding was removed; unsubscription uses UspBridgeClient plus SSE." + }, + "UspClientBuilder": { + "Symbol.dispose": "Generated JavaScript lifecycle alias; the built client owns the active lifecycle.", + "free": "The short-lived builder is not retained by the Dart wrapper." + }, + "UspWsClient": { + "Symbol.dispose": "Generated JavaScript lifecycle alias; Dart calls free() explicitly." + } + }, + "local_js_globals": { + "__uspClientReady": { + "kind": "getter", + "defined_in": "web/usp_init.js", + "reason": "PrivacyGUI awaits the Promise installed by its non-module Wasm bootstrap." + }, + "sendWebSocketConnectNative": { + "defined_in": "web/usp_init.js", + "reason": "PrivacyGUI shim copies Wasm bytes before the mandatory first WebSocket frame." + }, + "sendOperateRecordNative": { + "defined_in": "web/usp_init.js", + "reason": "PrivacyGUI shim copies Wasm bytes before sending firmware Operate records." + } + } +} diff --git a/tools/usp_boundary/test_artifacts.py b/tools/usp_boundary/test_artifacts.py new file mode 100644 index 000000000..a95443073 --- /dev/null +++ b/tools/usp_boundary/test_artifacts.py @@ -0,0 +1,83 @@ +import hashlib +import json +import tempfile +import unittest +from pathlib import Path + +from verify_artifacts import verify_manifest + + +EXPORTS = b"\n".join( + ( + b"export class UspClient", + b"export class UspWsClient", + b"export function buildGetRecord", + b"export function buildOperateRecord", + b"export function buildWebSocketConnect", + b"export function decodeRecord", + ) +) + + +class ArtifactTests(unittest.TestCase): + def make_tree(self, root): + files = { + "web/usp_client.js": EXPORTS, + "web/usp_client_bg.wasm": b"\x00asmfixture", + "web/usp_client.d.ts": EXPORTS, + } + artifacts = [] + for relative, data in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + artifacts.append( + { + "path": relative, + "upstream_path": relative.replace("web/", "usp-client/pkg/"), + "sha256": hashlib.sha256(data).hexdigest(), + } + ) + manifest = { + "schema_version": 1, + "upstream": { + "repository": "https://github.com/linksys/usp_framework", + "commit": "0123456789abcdef0123456789abcdef01234567", + }, + "build": {"features": ["wasm", "websocket"]}, + "artifacts": artifacts, + } + manifest_path = root / "manifest.json" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + return manifest_path, files + + def test_valid_manifest_and_upstream_match(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + manifest, files = self.make_tree(root) + + def fetcher(url): + for relative, data in files.items(): + upstream = relative.replace("web/", "usp-client/pkg/") + if url.endswith(upstream): + return data + raise AssertionError(url) + + _, errors = verify_manifest( + manifest, root, verify_upstream=True, fetcher=fetcher + ) + self.assertEqual(errors, []) + + def test_changed_artifact_fails_hash(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + manifest, _ = self.make_tree(root) + (root / "web/usp_client.d.ts").write_text( + "changed", encoding="utf-8" + ) + _, errors = verify_manifest(manifest, root) + self.assertTrue(any("hash mismatch" in error for error in errors)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/usp_boundary/test_boundary.py b/tools/usp_boundary/test_boundary.py new file mode 100644 index 000000000..47d19cb59 --- /dev/null +++ b/tools/usp_boundary/test_boundary.py @@ -0,0 +1,286 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from check_boundary import discover_dart_paths, run_check + + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent + + +class BoundaryTests(unittest.TestCase): + def test_deliberate_subscribe_arity_mismatch_fails(self): + fixture = HERE / "fixtures" / "arity_mismatch" + _, errors = run_check( + fixture / "usp_client.d.ts", + [fixture / "usp_client_wasm.dart"], + fixture / "policy.json", + fixture, + ) + self.assertTrue(any("arity mismatch" in error for error in errors), errors) + + def test_missing_local_shim_fails(self): + fixture = HERE / "fixtures" / "arity_mismatch" + with tempfile.TemporaryDirectory() as temp: + temp = Path(temp) + (temp / "binding.dart").write_text( + "@JS('missingShim')\n" + "external JSPromise missingShim(JSAny value);\n", + encoding="utf-8", + ) + (temp / "shim.js").write_text("", encoding="utf-8") + policy = { + "schema_version": 1, + "minimum_decisions": 1, + "intentionally_unbound": {}, + "local_js_globals": { + "missingShim": { + "defined_in": "shim.js", + "reason": "negative fixture" + } + } + } + (temp / "policy.json").write_text( + json.dumps(policy), encoding="utf-8" + ) + _, errors = run_check( + fixture / "usp_client.d.ts", + [temp / "binding.dart"], + temp / "policy.json", + temp, + ) + self.assertTrue(any("not defined" in error for error in errors), errors) + + def test_zero_decisions_fails_the_floor(self): + fixture = HERE / "fixtures" / "arity_mismatch" + with tempfile.TemporaryDirectory() as temp: + temp = Path(temp) + dart = temp / "empty.dart" + dart.write_text("", encoding="utf-8") + policy = temp / "policy.json" + policy.write_text( + json.dumps( + { + "schema_version": 1, + "minimum_decisions": 1, + "intentionally_unbound": {}, + "local_js_globals": {}, + } + ), + encoding="utf-8", + ) + _, errors = run_check( + fixture / "usp_client.d.ts", [dart], policy, temp + ) + self.assertTrue(any("decision floor" in error for error in errors), errors) + + def test_static_interop_annotation_does_not_hide_extension(self): + with tempfile.TemporaryDirectory() as temp: + temp = Path(temp) + dts = temp / "usp_client.d.ts" + dts.write_text( + "export class UspClient { constructor(base_url: string); }\n", + encoding="utf-8", + ) + dart = temp / "binding.dart" + dart.write_text( + "@JS('UspClient')\n" + "@staticInterop\n" + "extension type UspClientJS._(JSObject _) implements JSObject {\n" + " external factory UspClientJS(String baseUrl);\n" + "}\n", + encoding="utf-8", + ) + policy = temp / "policy.json" + policy.write_text( + json.dumps( + { + "schema_version": 1, + "minimum_decisions": 1, + "required_dart_classes": ["UspClient"], + "intentionally_unbound": {}, + "local_js_globals": {}, + } + ), + encoding="utf-8", + ) + reports, errors = run_check(dts, [dart], policy, temp) + self.assertEqual(errors, []) + self.assertIn("BOUND UspClient.constructor", reports) + + def test_unannotated_top_level_external_is_not_silently_ignored(self): + with tempfile.TemporaryDirectory() as temp: + temp = Path(temp) + dts = temp / "usp_client.d.ts" + dts.write_text( + "export function subscribe(value: string): void;\n", + encoding="utf-8", + ) + dart = temp / "binding.dart" + dart.write_text( + "external void subscribe(String value);\n", encoding="utf-8" + ) + policy = temp / "policy.json" + policy.write_text( + json.dumps( + { + "schema_version": 1, + "minimum_decisions": 1, + "intentionally_unbound": {}, + "local_js_globals": {}, + } + ), + encoding="utf-8", + ) + _, errors = run_check(dts, [dart], policy, temp) + self.assertTrue(any("only 0 were recognized" in e for e in errors), errors) + + def test_function_typed_parameter_is_parsed(self): + with tempfile.TemporaryDirectory() as temp: + temp = Path(temp) + dts = temp / "usp_client.d.ts" + dts.write_text( + "export class UspClient { onRecord(callback: Function): void; }\n", + encoding="utf-8", + ) + dart = temp / "binding.dart" + dart.write_text( + "@JS('UspClient')\n" + "extension type UspClientJS._(JSObject _) implements JSObject {\n" + " external void onRecord(void Function(JSAny) callback);\n" + "}\n", + encoding="utf-8", + ) + policy = temp / "policy.json" + policy.write_text( + json.dumps( + { + "schema_version": 1, + "minimum_decisions": 1, + "intentionally_unbound": {}, + "local_js_globals": {}, + } + ), + encoding="utf-8", + ) + reports, errors = run_check(dts, [dart], policy, temp) + self.assertEqual(errors, []) + self.assertIn("BOUND UspClient.onRecord", reports) + + def test_discovery_includes_new_external_binding_files(self): + with tempfile.TemporaryDirectory() as temp: + temp = Path(temp) + binding = temp / "new_binding.dart" + binding.write_text( + "@JS('newBinding')\nexternal void newBinding(String value);\n", + encoding="utf-8", + ) + (temp / "ordinary.dart").write_text( + "void ordinary() {}\n", encoding="utf-8" + ) + self.assertEqual(discover_dart_paths([temp]), [binding]) + + def test_local_js_getter_is_discovered_and_verified(self): + with tempfile.TemporaryDirectory() as temp: + temp = Path(temp) + dts = temp / "usp_client.d.ts" + dts.write_text("", encoding="utf-8") + binding = temp / "wasm_init.dart" + binding.write_text( + "@JS('__uspClientReady')\n" + "external JSPromise? get ready;\n", + encoding="utf-8", + ) + (temp / "usp_init.js").write_text( + "window.__uspClientReady = Promise.resolve(true);\n", + encoding="utf-8", + ) + policy = temp / "policy.json" + policy.write_text( + json.dumps( + { + "schema_version": 1, + "minimum_decisions": 1, + "required_dart_globals": ["__uspClientReady"], + "intentionally_unbound": {}, + "local_js_globals": { + "__uspClientReady": { + "kind": "getter", + "defined_in": "usp_init.js", + "reason": "negative fixture", + } + }, + } + ), + encoding="utf-8", + ) + + self.assertEqual(discover_dart_paths([temp]), [binding]) + reports, errors = run_check(dts, [binding], policy, temp) + self.assertEqual(errors, []) + self.assertTrue( + any("LOCAL JS VALUE __uspClientReady" in item for item in reports) + ) + + def test_missing_required_class_fails(self): + with tempfile.TemporaryDirectory() as temp: + temp = Path(temp) + dts = temp / "usp_client.d.ts" + dts.write_text( + "export class UspClient { constructor(base_url: string); }\n", + encoding="utf-8", + ) + dart = temp / "empty.dart" + dart.write_text("", encoding="utf-8") + policy = temp / "policy.json" + policy.write_text( + json.dumps( + { + "schema_version": 1, + "minimum_decisions": 1, + "required_dart_classes": ["UspClient"], + "intentionally_unbound": {}, + "local_js_globals": {}, + } + ), + encoding="utf-8", + ) + + _, errors = run_check(dts, [dart], policy, temp) + self.assertTrue( + any("required Dart JS class" in error for error in errors), + errors, + ) + + def test_invalid_unbound_policy_fails_without_a_traceback(self): + fixture = HERE / "fixtures" / "arity_mismatch" + with tempfile.TemporaryDirectory() as temp: + temp = Path(temp) + policy = temp / "policy.json" + policy.write_text( + json.dumps( + { + "schema_version": 1, + "minimum_decisions": 1, + "intentionally_unbound": [], + "local_js_globals": {}, + } + ), + encoding="utf-8", + ) + _, errors = run_check( + fixture / "usp_client.d.ts", + [fixture / "usp_client_wasm.dart"], + policy, + temp, + ) + self.assertIn( + "policy intentionally_unbound must be an object", + errors, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/usp_boundary/verify_artifacts.py b/tools/usp_boundary/verify_artifacts.py new file mode 100644 index 000000000..8ebc21953 --- /dev/null +++ b/tools/usp_boundary/verify_artifacts.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Verify vendored USP artifacts, recorded hashes, and production surface.""" + +import argparse +import hashlib +import json +import re +import sys +import urllib.request +from pathlib import Path + + +REQUIRED_PATHS = { + "web/usp_client.js", + "web/usp_client_bg.wasm", + "web/usp_client.d.ts", +} +REQUIRED_EXPORTS = ( + "export class UspClient", + "export class UspWsClient", + "export function buildGetRecord", + "export function buildOperateRecord", + "export function buildWebSocketConnect", + "export function decodeRecord", +) + + +def sha256(data): + return hashlib.sha256(data).hexdigest() + + +def fetch_url(url): + with urllib.request.urlopen(url, timeout=30) as response: + return response.read() + + +def raw_base(repository, commit): + match = re.fullmatch(r"https://github\.com/([^/]+)/([^/]+?)(?:\.git)?", repository) + if not match: + raise ValueError("unsupported upstream repository URL: %s" % repository) + return "https://raw.githubusercontent.com/%s/%s/%s" % ( + match.group(1), + match.group(2), + commit, + ) + + +def verify_manifest(manifest_path, root, verify_upstream=False, fetcher=fetch_url): + manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8")) + errors = [] + reports = [] + if manifest.get("schema_version") != 1: + errors.append("manifest schema_version must be 1") + upstream = manifest.get("upstream", {}) + commit = upstream.get("commit", "") + if not re.fullmatch(r"[0-9a-f]{40}", commit): + errors.append("upstream commit must be a full 40-character SHA") + features = set(manifest.get("build", {}).get("features", [])) + if not {"wasm", "websocket"} <= features: + errors.append("build features must include wasm and websocket") + + artifacts = manifest.get("artifacts", []) + paths = {artifact.get("path") for artifact in artifacts} + if paths != REQUIRED_PATHS: + errors.append( + "artifact paths must be exactly: %s" + % ", ".join(sorted(REQUIRED_PATHS)) + ) + + base = None + if verify_upstream and not errors: + try: + base = raw_base(upstream.get("repository", ""), commit) + except ValueError as error: + errors.append(str(error)) + + contents = {} + for artifact in artifacts: + relative = artifact.get("path", "") + path = Path(root) / relative + try: + data = path.read_bytes() + except OSError as error: + errors.append("%s: %s" % (relative, error)) + continue + contents[relative] = data + actual_hash = sha256(data) + if artifact.get("sha256") != actual_hash: + errors.append( + "%s hash mismatch: manifest=%s actual=%s" + % (relative, artifact.get("sha256"), actual_hash) + ) + else: + reports.append("HASH %s %s" % (relative, actual_hash)) + + if base: + upstream_path = artifact.get("upstream_path", "") + if not upstream_path: + errors.append("%s has no upstream_path" % relative) + continue + try: + source = fetcher("%s/%s" % (base, upstream_path)) + except Exception as error: # URL failures must become gate failures. + errors.append("%s upstream fetch failed: %s" % (relative, error)) + continue + if source != data: + errors.append( + "%s differs from %s@%s:%s" + % ( + relative, + upstream.get("repository"), + commit, + upstream_path, + ) + ) + else: + reports.append("UPSTREAM MATCH %s" % relative) + + wasm = contents.get("web/usp_client_bg.wasm", b"") + if wasm and not wasm.startswith(b"\x00asm"): + errors.append("web/usp_client_bg.wasm has invalid WebAssembly magic") + for relative in ("web/usp_client.js", "web/usp_client.d.ts"): + if relative not in contents: + continue + surface = contents[relative].decode("utf-8", errors="replace") + missing = [export for export in REQUIRED_EXPORTS if export not in surface] + if missing: + errors.append( + "%s is missing production exports: %s" + % (relative, ", ".join(missing)) + ) + return reports, errors + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--manifest", required=True) + parser.add_argument("--root", default=".") + parser.add_argument("--verify-upstream", action="store_true") + args = parser.parse_args(argv) + try: + reports, errors = verify_manifest( + args.manifest, + Path(args.root).resolve(), + verify_upstream=args.verify_upstream, + ) + except (OSError, ValueError, json.JSONDecodeError) as error: + print("FAIL: %s" % error, file=sys.stderr) + return 1 + for report in reports: + print(report) + if errors: + for error in errors: + print("FAIL: %s" % error, file=sys.stderr) + return 1 + if args.verify_upstream: + print("PASS: vendored USP artifacts match hashes and upstream source") + else: + print("PASS: vendored USP artifacts match the recorded artifact manifest") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/web/usp-artifacts.json b/web/usp-artifacts.json new file mode 100644 index 000000000..91e603646 --- /dev/null +++ b/web/usp-artifacts.json @@ -0,0 +1,36 @@ +{ + "schema_version": 1, + "upstream": { + "repository": "https://github.com/linksys/usp_framework", + "commit": "b5e65ae9ce3fc5d61abe63adb269d77a5a7a9cbd" + }, + "package": { + "name": "usp-client", + "version": "0.12.0" + }, + "build": { + "target": "web", + "profile": "release", + "features": [ + "wasm", + "websocket" + ] + }, + "artifacts": [ + { + "path": "web/usp_client.js", + "upstream_path": "usp-client/pkg/usp_client.js", + "sha256": "7ff8eaa93b926d2793ef8565ad9ee40c3f8e91989ffaebfbaf10b5fc19d87da5" + }, + { + "path": "web/usp_client_bg.wasm", + "upstream_path": "usp-client/pkg/usp_client_bg.wasm", + "sha256": "767ae88aa39c8d83ad3a611dc5ce0104a98fa5eb3f89b9661b3203a4f9a8721c" + }, + { + "path": "web/usp_client.d.ts", + "upstream_path": "usp-client/pkg/usp_client.d.ts", + "sha256": "f85f8b6fc30e336907d72d6144485fde0614f5fef8433cc5d6baccfe8c0704a6" + } + ] +} diff --git a/web/usp_client.d.ts b/web/usp_client.d.ts new file mode 100644 index 000000000..5de45ab76 --- /dev/null +++ b/web/usp_client.d.ts @@ -0,0 +1,613 @@ +/* tslint:disable */ +/* eslint-disable */ + +/** + * WASM-compatible USP client for web browsers + * + * # Example (JavaScript) + * ```javascript + * import init, { UspClient } from './usp_client.js'; + * + * await init(); + * const client = new UspClient("https://router.local"); + * await client.login("password123"); + * + * const value = await client.get("Device.DeviceInfo.Manufacturer"); + * console.log("Manufacturer:", value); + * + * await client.set("Device.WiFi.SSID.1.SSID", "MyNetwork"); + * await client.logout(); + * ``` + */ +export class UspClient { + free(): void; + [Symbol.dispose](): void; + /** + * Performs an Add operation for single or multiple object instances (unified API) + * + * # Arguments + * * `items` - Single object {path, params} or array of objects [{path, params}, ...] + * * `options` - Optional object with {allowPartial: boolean} (defaults to {allowPartial: false}) + * + * # Returns + * * Promise that resolves to structured result with detailed operation status + * + * # Examples + * ```javascript + * // Single instance + * await client.add({path: "Device.NAT.PortMapping.", params: {"ExternalPort": "8080"}}); + * + * // Multiple instances with partial mode + * await client.add([ + * {path: "Device.NAT.PortMapping.", params: {"ExternalPort": "8080"}}, + * {path: "Device.NAT.PortMapping.", params: {"ExternalPort": "8443"}} + * ], {allowPartial: true}); + * ``` + */ + add(items: any, options?: any | null): Promise; + /** + * Gets the base URL of the client + * + * # Returns + * * Base URL string + */ + baseUrl(): string; + /** + * Creates an EventSource connection to the bridge's SSE notifications endpoint. + * + * Returns the browser `EventSource` object directly — the caller is responsible + * for attaching event listeners and managing the connection lifecycle. + * + * Uses `withCredentials: true` so the browser sends the session cookie + * automatically. For Bearer-token auth, use `getToken()` and construct + * the EventSource manually with a query parameter. + * + * # Returns + * * `EventSource` object on success + * * Throws JavaScript exception on error + * + * # Example (JavaScript) + * ```javascript + * const es = client.connectNotifications(); + * es.onmessage = (event) => console.log("SSE:", event.data); + * es.onerror = (err) => console.error("SSE error:", err); + * // To close: es.close(); + * ``` + */ + connectNotifications(): EventSource; + /** + * Performs a Delete operation for single or multiple object instances (unified API) + * + * # Arguments + * * `paths` - Single path (string) or array of paths to delete + * * `options` - Optional object with {allowPartial: boolean} (defaults to {allowPartial: false}) + * + * # Returns + * * Promise that resolves to structured result with detailed operation status + * + * # Examples + * ```javascript + * // Single path + * await client.delete("Device.NAT.PortMapping.3."); + * + * // Multiple paths with partial mode + * await client.delete([ + * "Device.NAT.PortMapping.3.", + * "Device.NAT.PortMapping.4." + * ], {allowPartial: true}); + * ``` + */ + delete(paths: any, options?: any | null): Promise; + /** + * Performs a Get operation for single or multiple parameters (unified API) + * + * # Arguments + * * `paths` - Single parameter path (string) or array of parameter paths + * + * # Returns + * * Promise that resolves to JavaScript object with path-value pairs + * + * # Examples + * ```javascript + * // Single path + * await client.get("Device.DeviceInfo.Manufacturer"); + * + * // Multiple paths + * await client.get(["Device.DeviceInfo.Manufacturer", "Device.WiFi.SSID.1.SSID"]); + * ``` + */ + get(paths: any): Promise; + /** + * Returns the current session token string, if authenticated. + * + * Use this to make direct API calls to the bridge (e.g., SSE notifications) + * that require JWT authentication. + * + * # Returns + * * Token string if authenticated, `undefined` if not + * + * # Example (JavaScript) + * ```javascript + * const token = client.getToken(); + * if (token) { + * const source = new EventSource(`/api/v1/notifications?token=${token}`); + * } + * ``` + */ + getToken(): string | undefined; + /** + * Checks if the client is authenticated + * + * # Returns + * * `true` if authenticated, `false` otherwise + */ + isAuthenticated(): boolean; + /** + * Lists all active subscriptions for the current session. + * + * # Returns + * * Promise that resolves with an array of subscription objects + * + * # Example (JavaScript) + * ```javascript + * const subs = await client.listSubscriptions(); + * // [{ subscription_id: "wifi-status", path: "Device.WiFi.", active: true }, ...] + * ``` + */ + listSubscriptions(): Promise; + /** + * Authenticates with the router using password + * + * # Arguments + * * `password` - Router admin password + * + * # Returns + * * Promise that resolves on success, rejects on error + */ + login(password: string): Promise; + /** + * Logs out and invalidates the session + * + * # Returns + * * Promise that resolves on success, rejects on error + */ + logout(): Promise; + /** + * Creates a new USP client instance + * + * # Arguments + * * `base_url` - Base URL of the USP controller (e.g., "https://router.local") + * + * # Returns + * * `UspClient` instance on success + * * Throws JavaScript exception on error + * + * # Example (JavaScript) + * ```javascript + * const client = new UspClient("https://192.168.1.1"); + * ``` + */ + constructor(base_url: string); + /** + * Returns the full notifications SSE endpoint URL. + * + * Useful for constructing a custom EventSource with Bearer token auth. + * + * # Returns + * * Full URL string (e.g., "https://192.168.1.1/api/v1/notifications") + * + * # Example (JavaScript) + * ```javascript + * const url = client.notificationsUrl(); + * const token = client.getToken(); + * const es = new EventSource(`${url}?token=${token}`); + * ``` + */ + notificationsUrl(): string; + /** + * Performs an Operate command on the USP agent + * + * # Arguments + * * `command` - Command path (e.g., "Device.Reboot()" or "Device.IP.Diagnostics.Ping()") + * * `args` - JavaScript object with input argument name-value pairs (optional, pass {} for no args) + * + * # Returns + * * Promise that resolves to `{ commandKey: string, outputArgs: Record | undefined }` + * - `commandKey` — UUID for correlating with OperationComplete notifications + * - `outputArgs` — output arguments from the command (undefined if none) + * + * # Example (JavaScript) + * ```javascript + * // Simple command with no arguments + * const { commandKey } = await client.operate("Device.Reboot()", {}); + * console.log("Track async result with:", commandKey); + * + * // Command with input arguments + * const result = await client.operate("Device.IP.Diagnostics.Ping()", { + * "Host": "8.8.8.8", + * "NumberOfRepetitions": "4" + * }); + * console.log(result.commandKey); // "a1b2c3d4-..." + * console.log(result.outputArgs); // { "SuccessCount": "4", "AverageResponseTime": "12" } + * ``` + */ + operate(command: string, args: any): Promise; + /** + * Refreshes the authentication token, optionally restoring from an external token. + * + * This method can be used to: + * 1. Refresh the current session token (when `token` is `undefined`) + * 2. Restore and validate a token from external storage (when `token` is provided) + * + * When restoring from external storage (e.g., localStorage), the provided token + * is sent to the server to request a fresh token. This validates that the token + * is still valid and refreshable. + * + * # Arguments + * * `token` - Optional token to restore. If `undefined`, uses the current session token. + * + * # Returns + * * Promise that resolves on success, rejects on error (token expired, invalid, etc.) + * + * # Example (JavaScript) + * ```javascript + * // Normal refresh after login + * await client.refreshToken(); + * + * // Restore token from localStorage on page load + * const savedToken = localStorage.getItem('usp_token'); + * if (savedToken) { + * try { + * await client.refreshToken(savedToken); + * console.log('Token restored and validated'); + * } catch (e) { + * console.log('Token expired, need to re-login'); + * localStorage.removeItem('usp_token'); + * } + * } + * ``` + */ + refreshToken(token?: string | null): Promise; + /** + * Performs a Set operation for single or multiple parameters (unified API) + * + * # Arguments + * * `parameters` - JavaScript object with path-value pairs + * * `options` - Optional object with {allowPartial: boolean} (defaults to {allowPartial: false}) + * + * # Returns + * * Promise that resolves to structured result with detailed operation status + * + * # Examples + * ```javascript + * // Single parameter + * await client.set({"Device.WiFi.SSID.1.SSID": "NewNetwork"}); + * + * // Multiple parameters with partial mode + * await client.set({ + * "Device.WiFi.SSID.1.SSID": "NewNetwork", + * "Device.WiFi.Radio.1.Channel": "6" + * }, {allowPartial: true}); + * ``` + */ + set(parameters: any, options?: any | null): Promise; + /** + * Performs a grouped ordered Set operation preserving priority-based parameter sequence + * + * Parameters are organized into priority groups. Within each group, parameters + * sharing the same object path are merged into a single USP UpdateObject. + * Groups are sent in order, preserving the priority-based execution sequence. + * + * # Arguments + * * `groups_array` - JavaScript array of arrays: `[[{path, value}, ...], ...]` + * * `allow_partial` - If true, allows partial success + * + * # Returns + * * Promise that resolves to structured result with detailed operation status + * + * # Example (JavaScript) + * ```javascript + * const result = await client.setOrdered([ + * [ + * { path: "Device.WiFi.Radio.1.Enable", value: "false" } + * ], + * [ + * { path: "Device.WiFi.SSID.1.SSID", value: "NewNetwork" }, + * { path: "Device.WiFi.SSID.1.Enable", value: "true" } + * ], + * [ + * { path: "Device.WiFi.Radio.1.Enable", value: "true" } + * ] + * ], true); + * ``` + */ + setOrdered(groups_array: any, allow_partial: boolean): Promise; + /** + * Registers a notification subscription with the bridge. + * + * # Arguments + * * `subscription_id` - Unique subscription identifier + * * `path` - USP object path to monitor (e.g., "Device.Hosts.Host.") + * * `notification_type` - USP notification type: 1=ValueChange, 2=ObjectCreation, 3=ObjectDeletion + * + * # Returns + * * Promise that resolves on success, rejects on error + * + * # Example (JavaScript) + * ```javascript + * await client.subscribe("host-changes", "Device.Hosts.Host.", 1); + * ``` + */ + subscribe(subscription_id: string, path: string, notification_type: number): Promise; + /** + * Removes a notification subscription from the bridge. + * + * # Arguments + * * `subscription_id` - Subscription identifier to remove + * + * # Returns + * * Promise that resolves on success, rejects on error + * + * # Example (JavaScript) + * ```javascript + * await client.unsubscribe("wifi-status"); + * ``` + */ + unsubscribe(subscription_id: string): Promise; +} + +/** + * Builder for creating a UspClient with custom configuration. + * + * # Example (JavaScript) + * ```javascript + * import init, { UspClientBuilder } from './usp_client.js'; + * + * await init(); + * + * // Create client for remote USP endpoint + * const client = new UspClientBuilder("https://api.example.com") + * .endpoint("/v1/usp") + * .authToken("my-token") + * .extraHeader("X-Custom-Header", "value") + * .build(); + * + * const result = await client.get("Device.DeviceInfo."); + * ``` + */ +export class UspClientBuilder { + free(): void; + [Symbol.dispose](): void; + /** + * Sets the authentication token (skips the login flow) + * + * # Arguments + * * `token` - Authentication token (e.g., Bearer token) + */ + authToken(token: string): UspClientBuilder; + /** + * Builds the UspClient + * + * # Returns + * * `UspClient` instance on success + * * Throws JavaScript exception on error + */ + build(): UspClient; + /** + * Sets the USP endpoint path + * + * # Arguments + * * `endpoint` - Endpoint path (e.g., "/v1/usp") + */ + endpoint(endpoint: string): UspClientBuilder; + /** + * Adds an extra HTTP header to include with every request + * + * # Arguments + * * `name` - Header name + * * `value` - Header value + */ + extraHeader(name: string, value: string): UspClientBuilder; + /** + * Creates a new builder with the specified base URL + * + * # Arguments + * * `base_url` - Base URL of the USP controller (e.g., "https://api.example.com") + */ + constructor(base_url: string); +} + +/** + * JS-facing WebSocket client. Single-consumer; not cloneable. + * + * # Example (JavaScript) + * ```javascript + * const ws = await UspWsClient.connect("wss://192.168.1.1/usp-ws"); + * ws.on_record((bytes) => console.log("record:", bytes)); + * await ws.send_record(recordBytes); + * ws.close(); + * ``` + */ +export class UspWsClient { + private constructor(); + free(): void; + [Symbol.dispose](): void; + /** + * Close the socket. Subsequent `sendRecord` calls reject. + */ + close(): void; + /** + * Open a connection. Resolves to a `UspWsClient` once the socket + * is established. + * + * `subprotocol` declares the `Sec-WebSocket-Protocol` to send during + * the upgrade handshake. USP requires `"v1.usp"` (TR-369 §6.4.4); + * OBUSPA destroys connections that omit it. Pass `None` only when + * connecting to a non-USP echo server. + */ + static connect(url: string, subprotocol?: string | null): Promise; + /** + * Register the per-record callback. Replaces any previous one. + * `cb` is called as `cb(Uint8Array)`. + */ + onRecord(cb: Function): void; + /** + * Register the connection-state callback. + * `cb` is called as `cb(state: string)` with `"open"` or `"closed"`. + */ + onStateChange(cb: Function): void; + /** + * Queue a binary USP Record for delivery. Resolves once enqueued. + */ + sendRecord(bytes: Uint8Array): Promise; +} + +/** + * Build a USP Record carrying a `Get` for a single parameter path. + * + * The Record is needed for WebSocket-MTP traffic (the HTTP path used + * by `UspClient` sends bare Msg bytes; the Linksys bridge wraps for HTTP). + * + * # JavaScript + * ```javascript + * const bytes = buildGetRecord( + * "Device.DeviceInfo.SoftwareVersion", + * "controller::localui-turbo", + * "os::router-001122334455" + * ); + * await ws.sendRecord(bytes); + * ``` + */ +export function buildGetRecord(path: string, from_id: string, to_id: string): Uint8Array; + +/** + * Build a USP Record carrying an `Operate` command. + * + * # JavaScript + * ```javascript + * const bytes = buildOperateRecord( + * "Device.LocalAgent.X_LINKSYS_Download()", + * { "Data": base64chunk, "Offset": "0", "Size": "65535" }, + * "controller::localui-turbo", + * "os::router-001122334455" + * ); + * await ws.sendRecord(bytes); + * ``` + */ +export function buildOperateRecord(command: string, input_args: any, from_id: string, to_id: string): Uint8Array; + +/** + * Build the mandatory `WebSocketConnectRecord` first frame for USP WS MTP + * (TR-369 §6.4.5). OBUSPA destroys the connection if this is not the + * first binary frame after upgrade. + * + * # JavaScript + * ```javascript + * const handshake = buildWebSocketConnect( + * "controller::localui-turbo", + * "os::router-001122334455" + * ); + * await ws.sendRecord(handshake); + * ``` + */ +export function buildWebSocketConnect(from_id: string, to_id: string): Uint8Array; + +/** + * Decode a USP Record received over WebSocket and return a JS object + * with the parsed response. + * + * # JavaScript + * ```javascript + * const result = decodeRecord(responseBytes); + * // result = { from_id, to_id, version, msg_type, msg_id, command?, output_args?, error? } + * ``` + */ +export function decodeRecord(data: Uint8Array): any; + +/** + * Initialize the WASM module + * + * # Example (JavaScript) + * ```javascript + * import init from './usp_client.js'; + * await init(); + * ``` + */ +export function init(): void; + +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly __wbg_uspclient_free: (a: number, b: number) => void; + readonly __wbg_uspclientbuilder_free: (a: number, b: number) => void; + readonly __wbg_uspwsclient_free: (a: number, b: number) => void; + readonly buildGetRecord: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; + readonly buildOperateRecord: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => void; + readonly buildWebSocketConnect: (a: number, b: number, c: number, d: number, e: number) => void; + readonly decodeRecord: (a: number, b: number, c: number) => void; + readonly init: () => void; + readonly uspclient_add: (a: number, b: number, c: number) => number; + readonly uspclient_baseUrl: (a: number, b: number) => void; + readonly uspclient_connectNotifications: (a: number, b: number) => void; + readonly uspclient_delete: (a: number, b: number, c: number) => number; + readonly uspclient_get: (a: number, b: number) => number; + readonly uspclient_getToken: (a: number, b: number) => void; + readonly uspclient_isAuthenticated: (a: number) => number; + readonly uspclient_listSubscriptions: (a: number) => number; + readonly uspclient_login: (a: number, b: number, c: number) => number; + readonly uspclient_logout: (a: number) => number; + readonly uspclient_new: (a: number, b: number, c: number) => void; + readonly uspclient_notificationsUrl: (a: number, b: number) => void; + readonly uspclient_operate: (a: number, b: number, c: number, d: number) => number; + readonly uspclient_refreshToken: (a: number, b: number, c: number) => number; + readonly uspclient_set: (a: number, b: number, c: number) => number; + readonly uspclient_setOrdered: (a: number, b: number, c: number) => number; + readonly uspclient_subscribe: (a: number, b: number, c: number, d: number, e: number, f: number) => number; + readonly uspclient_unsubscribe: (a: number, b: number, c: number) => number; + readonly uspclientbuilder_authToken: (a: number, b: number, c: number) => number; + readonly uspclientbuilder_build: (a: number, b: number) => void; + readonly uspclientbuilder_endpoint: (a: number, b: number, c: number) => number; + readonly uspclientbuilder_extraHeader: (a: number, b: number, c: number, d: number, e: number) => number; + readonly uspclientbuilder_new: (a: number, b: number) => number; + readonly uspwsclient_close: (a: number) => void; + readonly uspwsclient_connect: (a: number, b: number, c: number, d: number) => number; + readonly uspwsclient_onRecord: (a: number, b: number) => void; + readonly uspwsclient_onStateChange: (a: number, b: number) => void; + readonly uspwsclient_sendRecord: (a: number, b: number, c: number) => number; + readonly __wasm_bindgen_func_elem_3020: (a: number, b: number, c: number, d: number) => void; + readonly __wasm_bindgen_func_elem_3022: (a: number, b: number, c: number, d: number) => void; + readonly __wasm_bindgen_func_elem_2344: (a: number, b: number, c: number) => void; + readonly __wasm_bindgen_func_elem_2344_2: (a: number, b: number, c: number) => void; + readonly __wasm_bindgen_func_elem_2344_3: (a: number, b: number, c: number) => void; + readonly __wasm_bindgen_func_elem_2343: (a: number, b: number) => void; + readonly __wbindgen_export: (a: number, b: number) => number; + readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number; + readonly __wbindgen_export3: (a: number) => void; + readonly __wbindgen_export4: (a: number, b: number, c: number) => void; + readonly __wbindgen_export5: (a: number, b: number) => void; + readonly __wbindgen_add_to_stack_pointer: (a: number) => number; + readonly __wbindgen_start: () => void; +} + +export type SyncInitInput = BufferSource | WebAssembly.Module; + +/** + * Instantiates the given `module`, which can either be bytes or + * a precompiled `WebAssembly.Module`. + * + * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. + * + * @returns {InitOutput} + */ +export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; + +/** + * If `module_or_path` is {RequestInfo} or {URL}, makes a request and + * for everything else, calls `WebAssembly.instantiate` directly. + * + * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. + * + * @returns {Promise} + */ +export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; diff --git a/web/usp_client.js b/web/usp_client.js index cdcca2928..98ac51d28 100644 --- a/web/usp_client.js +++ b/web/usp_client.js @@ -660,6 +660,249 @@ export class UspClientBuilder { } if (Symbol.dispose) UspClientBuilder.prototype[Symbol.dispose] = UspClientBuilder.prototype.free; +/** + * JS-facing WebSocket client. Single-consumer; not cloneable. + * + * # Example (JavaScript) + * ```javascript + * const ws = await UspWsClient.connect("wss://192.168.1.1/usp-ws"); + * ws.on_record((bytes) => console.log("record:", bytes)); + * await ws.send_record(recordBytes); + * ws.close(); + * ``` + */ +export class UspWsClient { + static __wrap(ptr) { + const obj = Object.create(UspWsClient.prototype); + obj.__wbg_ptr = ptr; + UspWsClientFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + UspWsClientFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_uspwsclient_free(ptr, 0); + } + /** + * Close the socket. Subsequent `sendRecord` calls reject. + */ + close() { + wasm.uspwsclient_close(this.__wbg_ptr); + } + /** + * Open a connection. Resolves to a `UspWsClient` once the socket + * is established. + * + * `subprotocol` declares the `Sec-WebSocket-Protocol` to send during + * the upgrade handshake. USP requires `"v1.usp"` (TR-369 §6.4.4); + * OBUSPA destroys connections that omit it. Pass `None` only when + * connecting to a non-USP echo server. + * @param {string} url + * @param {string | null} [subprotocol] + * @returns {Promise} + */ + static connect(url, subprotocol) { + const ptr0 = passStringToWasm0(url, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + var ptr1 = isLikeNone(subprotocol) ? 0 : passStringToWasm0(subprotocol, wasm.__wbindgen_export, wasm.__wbindgen_export2); + var len1 = WASM_VECTOR_LEN; + const ret = wasm.uspwsclient_connect(ptr0, len0, ptr1, len1); + return takeObject(ret); + } + /** + * Register the per-record callback. Replaces any previous one. + * `cb` is called as `cb(Uint8Array)`. + * @param {Function} cb + */ + onRecord(cb) { + wasm.uspwsclient_onRecord(this.__wbg_ptr, addHeapObject(cb)); + } + /** + * Register the connection-state callback. + * `cb` is called as `cb(state: string)` with `"open"` or `"closed"`. + * @param {Function} cb + */ + onStateChange(cb) { + wasm.uspwsclient_onStateChange(this.__wbg_ptr, addHeapObject(cb)); + } + /** + * Queue a binary USP Record for delivery. Resolves once enqueued. + * @param {Uint8Array} bytes + * @returns {Promise} + */ + sendRecord(bytes) { + const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_export); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.uspwsclient_sendRecord(this.__wbg_ptr, ptr0, len0); + return takeObject(ret); + } +} +if (Symbol.dispose) UspWsClient.prototype[Symbol.dispose] = UspWsClient.prototype.free; + +/** + * Build a USP Record carrying a `Get` for a single parameter path. + * + * The Record is needed for WebSocket-MTP traffic (the HTTP path used + * by `UspClient` sends bare Msg bytes; the Linksys bridge wraps for HTTP). + * + * # JavaScript + * ```javascript + * const bytes = buildGetRecord( + * "Device.DeviceInfo.SoftwareVersion", + * "controller::localui-turbo", + * "os::router-001122334455" + * ); + * await ws.sendRecord(bytes); + * ``` + * @param {string} path + * @param {string} from_id + * @param {string} to_id + * @returns {Uint8Array} + */ +export function buildGetRecord(path, from_id, to_id) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passStringToWasm0(path, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(from_id, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + const ptr2 = passStringToWasm0(to_id, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len2 = WASM_VECTOR_LEN; + wasm.buildGetRecord(retptr, ptr0, len0, ptr1, len1, ptr2, len2); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); + var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true); + if (r3) { + throw takeObject(r2); + } + var v4 = getArrayU8FromWasm0(r0, r1).slice(); + wasm.__wbindgen_export4(r0, r1 * 1, 1); + return v4; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } +} + +/** + * Build a USP Record carrying an `Operate` command. + * + * # JavaScript + * ```javascript + * const bytes = buildOperateRecord( + * "Device.LocalAgent.X_LINKSYS_Download()", + * { "Data": base64chunk, "Offset": "0", "Size": "65535" }, + * "controller::localui-turbo", + * "os::router-001122334455" + * ); + * await ws.sendRecord(bytes); + * ``` + * @param {string} command + * @param {any} input_args + * @param {string} from_id + * @param {string} to_id + * @returns {Uint8Array} + */ +export function buildOperateRecord(command, input_args, from_id, to_id) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passStringToWasm0(command, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(from_id, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + const ptr2 = passStringToWasm0(to_id, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len2 = WASM_VECTOR_LEN; + wasm.buildOperateRecord(retptr, ptr0, len0, addHeapObject(input_args), ptr1, len1, ptr2, len2); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); + var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true); + if (r3) { + throw takeObject(r2); + } + var v4 = getArrayU8FromWasm0(r0, r1).slice(); + wasm.__wbindgen_export4(r0, r1 * 1, 1); + return v4; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } +} + +/** + * Build the mandatory `WebSocketConnectRecord` first frame for USP WS MTP + * (TR-369 §6.4.5). OBUSPA destroys the connection if this is not the + * first binary frame after upgrade. + * + * # JavaScript + * ```javascript + * const handshake = buildWebSocketConnect( + * "controller::localui-turbo", + * "os::router-001122334455" + * ); + * await ws.sendRecord(handshake); + * ``` + * @param {string} from_id + * @param {string} to_id + * @returns {Uint8Array} + */ +export function buildWebSocketConnect(from_id, to_id) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passStringToWasm0(from_id, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(to_id, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + wasm.buildWebSocketConnect(retptr, ptr0, len0, ptr1, len1); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); + var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true); + if (r3) { + throw takeObject(r2); + } + var v3 = getArrayU8FromWasm0(r0, r1).slice(); + wasm.__wbindgen_export4(r0, r1 * 1, 1); + return v3; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } +} + +/** + * Decode a USP Record received over WebSocket and return a JS object + * with the parsed response. + * + * # JavaScript + * ```javascript + * const result = decodeRecord(responseBytes); + * // result = { from_id, to_id, version, msg_type, msg_id, command?, output_args?, error? } + * ``` + * @param {Uint8Array} data + * @returns {any} + */ +export function decodeRecord(data) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_export); + const len0 = WASM_VECTOR_LEN; + wasm.decodeRecord(retptr, ptr0, len0); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); + if (r2) { + throw takeObject(r1); + } + return takeObject(r0); + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } +} + /** * Initialize the WASM module * @@ -725,6 +968,12 @@ function __wbg_get_imports() { __wbg_abort_b363e6285472a358: function(arg0) { getObject(arg0).abort(); }, + __wbg_addEventListener_737cdb55f09bc146: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + getObject(arg0).addEventListener(getStringFromWasm0(arg1, arg2), getObject(arg3), getObject(arg4)); + }, arguments); }, + __wbg_addEventListener_aedacff123afaebd: function() { return handleError(function (arg0, arg1, arg2, arg3) { + getObject(arg0).addEventListener(getStringFromWasm0(arg1, arg2), getObject(arg3)); + }, arguments); }, __wbg_append_263958599fd198c1: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { getObject(arg0).append(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); }, arguments); }, @@ -740,10 +989,29 @@ function __wbg_get_imports() { const ret = getObject(arg0).call(getObject(arg1), getObject(arg2)); return addHeapObject(ret); }, arguments); }, + __wbg_close_e323e9eee669c291: function() { return handleError(function (arg0) { + getObject(arg0).close(); + }, arguments); }, + __wbg_code_98ceeaa5ff83fb0b: function(arg0) { + const ret = getObject(arg0).code; + return ret; + }, + __wbg_data_5fc79a19e47d1531: function(arg0) { + const ret = getObject(arg0).data; + return addHeapObject(ret); + }, + __wbg_dispatchEvent_29c919cea8d37995: function() { return handleError(function (arg0, arg1) { + const ret = getObject(arg0).dispatchEvent(getObject(arg1)); + return ret; + }, arguments); }, __wbg_done_54b8da57023b7ed2: function(arg0) { const ret = getObject(arg0).done; return ret; }, + __wbg_entries_564a7e8b1e54ede5: function(arg0) { + const ret = Object.entries(getObject(arg0)); + return addHeapObject(ret); + }, __wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) { let deferred0_0; let deferred0_1; @@ -790,6 +1058,26 @@ function __wbg_get_imports() { const ret = getObject(arg0).headers; return addHeapObject(ret); }, + __wbg_instanceof_ArrayBuffer_53db37b06f6b9afe: function(arg0) { + let result; + try { + result = getObject(arg0) instanceof ArrayBuffer; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Error_b3f7e146d654031a: function(arg0) { + let result; + try { + result = getObject(arg0) instanceof Error; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, __wbg_instanceof_Object_03924e0dbda74bd8: function(arg0) { let result; try { @@ -830,6 +1118,14 @@ function __wbg_get_imports() { const ret = getObject(arg0).length; return ret; }, + __wbg_message_324ac511aeaf710e: function(arg0) { + const ret = getObject(arg0).message; + return addHeapObject(ret); + }, + __wbg_name_d09e9b472d8320d3: function(arg0) { + const ret = getObject(arg0).name; + return addHeapObject(ret); + }, __wbg_new_02d162bc6cf02f60: function() { const ret = new Object(); return addHeapObject(ret); @@ -850,6 +1146,10 @@ function __wbg_get_imports() { const ret = new AbortController(); return addHeapObject(ret); }, arguments); }, + __wbg_new_b1280f836646084c: function() { return handleError(function (arg0, arg1) { + const ret = new WebSocket(getStringFromWasm0(arg0, arg1)); + return addHeapObject(ret); + }, arguments); }, __wbg_new_ee0be486d8f01282: function() { return handleError(function () { const ret = new Headers(); return addHeapObject(ret); @@ -865,7 +1165,7 @@ function __wbg_get_imports() { const a = state0.a; state0.a = 0; try { - return __wasm_bindgen_func_elem_2271(a, state0.b, arg0, arg1); + return __wasm_bindgen_func_elem_3022(a, state0.b, arg0, arg1); } finally { state0.a = a; } @@ -876,14 +1176,26 @@ function __wbg_get_imports() { state0.a = 0; } }, + __wbg_new_with_event_init_dict_6e3c4558031bcc74: function() { return handleError(function (arg0, arg1, arg2) { + const ret = new CloseEvent(getStringFromWasm0(arg0, arg1), getObject(arg2)); + return addHeapObject(ret); + }, arguments); }, __wbg_new_with_event_source_init_dict_b9ea67d613b2ba60: function() { return handleError(function (arg0, arg1, arg2) { const ret = new EventSource(getStringFromWasm0(arg0, arg1), getObject(arg2)); return addHeapObject(ret); }, arguments); }, + __wbg_new_with_str_138892eed4310532: function() { return handleError(function (arg0, arg1, arg2, arg3) { + const ret = new WebSocket(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3)); + return addHeapObject(ret); + }, arguments); }, __wbg_new_with_str_and_init_ffe9977c986ea039: function() { return handleError(function (arg0, arg1, arg2) { const ret = new Request(getStringFromWasm0(arg0, arg1), getObject(arg2)); return addHeapObject(ret); }, arguments); }, + __wbg_new_with_u8_array_sequence_13bd79c99f2fc3b0: function() { return handleError(function (arg0) { + const ret = new Blob(getObject(arg0)); + return addHeapObject(ret); + }, arguments); }, __wbg_next_2a4e19f4f5083b0f: function(arg0) { const ret = getObject(arg0).next; return addHeapObject(ret); @@ -896,6 +1208,10 @@ function __wbg_get_imports() { const ret = Date.now(); return ret; }, + __wbg_of_d694dacacb7afa7f: function(arg0) { + const ret = Array.of(getObject(arg0)); + return addHeapObject(ret); + }, __wbg_prototypesetcall_5f9bdc8d75e07276: function(arg0, arg1, arg2) { Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), getObject(arg2)); }, @@ -910,17 +1226,43 @@ function __wbg_get_imports() { __wbg_queueMicrotask_b39ea83c7f01971a: function(arg0) { queueMicrotask(getObject(arg0)); }, + __wbg_readyState_a1a00cc8898812ac: function(arg0) { + const ret = getObject(arg0).readyState; + return ret; + }, + __wbg_reason_48e6f2ed86d09534: function(arg0, arg1) { + const ret = getObject(arg1).reason; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_removeEventListener_3d948197bcd2a229: function() { return handleError(function (arg0, arg1, arg2, arg3) { + getObject(arg0).removeEventListener(getStringFromWasm0(arg1, arg2), getObject(arg3)); + }, arguments); }, __wbg_resolve_d17db9352f5a220e: function(arg0) { const ret = Promise.resolve(getObject(arg0)); return addHeapObject(ret); }, + __wbg_send_c79e8d29e09cc7b4: function() { return handleError(function (arg0, arg1) { + getObject(arg0).send(getObject(arg1)); + }, arguments); }, + __wbg_send_e1d2f71ce4473d1e: function() { return handleError(function (arg0, arg1, arg2) { + getObject(arg0).send(getStringFromWasm0(arg1, arg2)); + }, arguments); }, __wbg_set_a0e911be3da02782: function() { return handleError(function (arg0, arg1, arg2) { const ret = Reflect.set(getObject(arg0), getObject(arg1), getObject(arg2)); return ret; }, arguments); }, + __wbg_set_binaryType_5c0002dfcf194934: function(arg0, arg1) { + getObject(arg0).binaryType = __wbindgen_enum_BinaryType[arg1]; + }, __wbg_set_body_7f56457720e81672: function(arg0, arg1) { getObject(arg0).body = getObject(arg1); }, + __wbg_set_code_a4411690b706ca41: function(arg0, arg1) { + getObject(arg0).code = arg1; + }, __wbg_set_credentials_55b92faec8dcc6a4: function(arg0, arg1) { getObject(arg0).credentials = __wbindgen_enum_RequestCredentials[arg1]; }, @@ -933,6 +1275,12 @@ function __wbg_get_imports() { __wbg_set_mode_dfc59bbbe25b1d14: function(arg0, arg1) { getObject(arg0).mode = __wbindgen_enum_RequestMode[arg1]; }, + __wbg_set_once_1f7d97545d570128: function(arg0, arg1) { + getObject(arg0).once = arg1 !== 0; + }, + __wbg_set_reason_5270e60cd15986eb: function(arg0, arg1, arg2) { + getObject(arg0).reason = getStringFromWasm0(arg1, arg2); + }, __wbg_set_signal_2a5bd3615938edbc: function(arg0, arg1) { getObject(arg0).signal = getObject(arg1); }, @@ -986,6 +1334,10 @@ function __wbg_get_imports() { const ret = getObject(arg0).then(getObject(arg1), getObject(arg2)); return addHeapObject(ret); }, + __wbg_toString_a5ee42947b978082: function(arg0) { + const ret = getObject(arg0).toString(); + return addHeapObject(ret); + }, __wbg_url_1a5ea6a8a7f22ff8: function(arg0, arg1) { const ret = getObject(arg1).url; const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); @@ -993,21 +1345,49 @@ function __wbg_get_imports() { getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); }, + __wbg_uspwsclient_new: function(arg0) { + const ret = UspWsClient.__wrap(arg0); + return addHeapObject(ret); + }, __wbg_value_9cc0518af87a489c: function(arg0) { const ret = getObject(arg0).value; return addHeapObject(ret); }, + __wbg_wasClean_aa6a78fa841a6301: function(arg0) { + const ret = getObject(arg0).wasClean; + return ret; + }, __wbindgen_cast_0000000000000001: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 206, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2260); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 269, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_3020); return addHeapObject(ret); }, - __wbindgen_cast_0000000000000002: function(arg0) { + __wbindgen_cast_0000000000000002: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("CloseEvent")], shim_idx: 248, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2344); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000003: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 248, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2344_2); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000004: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 248, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2344_3); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000005: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 247, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2343); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000006: function(arg0) { // Cast intrinsic for `F64 -> Externref`. const ret = arg0; return addHeapObject(ret); }, - __wbindgen_cast_0000000000000003: function(arg0, arg1) { + __wbindgen_cast_0000000000000007: function(arg0, arg1) { // Cast intrinsic for `Ref(String) -> Externref`. const ret = getStringFromWasm0(arg0, arg1); return addHeapObject(ret); @@ -1026,10 +1406,26 @@ function __wbg_get_imports() { }; } -function __wasm_bindgen_func_elem_2260(arg0, arg1, arg2) { +function __wasm_bindgen_func_elem_2343(arg0, arg1) { + wasm.__wasm_bindgen_func_elem_2343(arg0, arg1); +} + +function __wasm_bindgen_func_elem_2344(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_2344(arg0, arg1, addHeapObject(arg2)); +} + +function __wasm_bindgen_func_elem_2344_2(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_2344_2(arg0, arg1, addHeapObject(arg2)); +} + +function __wasm_bindgen_func_elem_2344_3(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_2344_3(arg0, arg1, addHeapObject(arg2)); +} + +function __wasm_bindgen_func_elem_3020(arg0, arg1, arg2) { try { const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - wasm.__wasm_bindgen_func_elem_2260(retptr, arg0, arg1, addHeapObject(arg2)); + wasm.__wasm_bindgen_func_elem_3020(retptr, arg0, arg1, addHeapObject(arg2)); var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); if (r1) { @@ -1040,11 +1436,14 @@ function __wasm_bindgen_func_elem_2260(arg0, arg1, arg2) { } } -function __wasm_bindgen_func_elem_2271(arg0, arg1, arg2, arg3) { - wasm.__wasm_bindgen_func_elem_2271(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3)); +function __wasm_bindgen_func_elem_3022(arg0, arg1, arg2, arg3) { + wasm.__wasm_bindgen_func_elem_3022(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3)); } +const __wbindgen_enum_BinaryType = ["blob", "arraybuffer"]; + + const __wbindgen_enum_RequestCredentials = ["omit", "same-origin", "include"]; @@ -1055,6 +1454,9 @@ const UspClientFinalization = (typeof FinalizationRegistry === 'undefined') const UspClientBuilderFinalization = (typeof FinalizationRegistry === 'undefined') ? { register: () => {}, unregister: () => {} } : new FinalizationRegistry(ptr => wasm.__wbg_uspclientbuilder_free(ptr, 1)); +const UspWsClientFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_uspwsclient_free(ptr, 1)); function addHeapObject(obj) { if (heap_next === heap.length) heap.push(heap.length + 1); @@ -1212,6 +1614,13 @@ function makeMutClosure(arg0, arg1, f) { return real; } +function passArray8ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 1, 1) >>> 0; + getUint8ArrayMemory0().set(arg, ptr / 1); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + function passStringToWasm0(arg, malloc, realloc) { if (realloc === undefined) { const buf = cachedTextEncoder.encode(arg); diff --git a/web/usp_client_bg.wasm b/web/usp_client_bg.wasm index 2d5597e58..50b682561 100644 Binary files a/web/usp_client_bg.wasm and b/web/usp_client_bg.wasm differ