Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main", "dev-*" ]
branches: [ "main", "dev-*", "usp" ]
workflow_dispatch:
inputs:
job:
Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/usp-boundary.yml
Original file line number Diff line number Diff line change
@@ -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'
4 changes: 4 additions & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 25 additions & 2 deletions doc/usp/vendored-artifacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
6 changes: 0 additions & 6 deletions lib/core/usp/stub/usp_client_stub.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,6 @@ class UspClientWeb {

String? get sessionToken => null;

Future<void> subscribe(String subscriptionId) =>
throw UnsupportedError('USP is only available on Web');

Future<void> unsubscribe(String subscriptionId) =>
throw UnsupportedError('USP is only available on Web');

Future<void> login(String password) =>
throw UnsupportedError('USP is only available on Web');

Expand Down
12 changes: 0 additions & 12 deletions lib/core/usp/web/usp_client_wasm.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,6 @@ extension type UspClientJS._(JSObject _) implements JSObject {
@JS('getToken')
external String? getToken();

external JSPromise<JSAny?> subscribe(String subscriptionId);

external JSPromise<JSAny?> unsubscribe(String subscriptionId);

external JSPromise<JSAny?> login(String password);

external JSPromise<JSAny?> logout();
Expand Down Expand Up @@ -130,14 +126,6 @@ class UspClientWeb {
}
}

Future<void> subscribe(String subscriptionId) async {
await _client.subscribe(subscriptionId).toDart;
}

Future<void> unsubscribe(String subscriptionId) async {
await _client.unsubscribe(subscriptionId).toDart;
}

Future<void> login(String password) async {
try {
await _client.login(password).toDart;
Expand Down
35 changes: 7 additions & 28 deletions lib/core/usp/web/usp_ws_client_wrapper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<JSAny?> _sendWebSocketConnectNative(
UspWsClientJS wsClient, JSString fromId, JSString toId);
Expand All @@ -61,11 +62,6 @@ external JSPromise<JSAny?> _sendWebSocketConnectNative(
external JSPromise<JSAny?> _sendOperateRecordNative(UspWsClientJS wsClient,
JSString command, JSAny inputArgs, JSString fromId, JSString toId);

// Debug helper to test sendRecord from pure JS
@JS('uspWsSendRecord')
external JSPromise<JSAny?> _uspWsSendRecord(
UspWsClientJS wsClient, JSUint8Array bytes);

// -----------------------------------------------------------------------------
// Dart Wrapper
// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -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<void>.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');
Expand Down Expand Up @@ -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).
Expand Down
20 changes: 11 additions & 9 deletions lib/page/firmware_update/services/firmware_ws_upload_strategy.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import 'package:privacy_gui/page/firmware_update/services/firmware_upload_strate

const _tag = '[FirmwareUpdate]';

typedef UspWsConnector = Future<UspWsClientWrapper> Function(String url);

/// WebSocket-based firmware upload strategy (Method 2).
///
/// Uses direct WebSocket connection to OBUSPA (`wss://router/usp-ws`),
Expand All @@ -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;
Expand All @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 => <String, dynamic>{});

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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UspWsMessage>.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<NetworkError>()));

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);
});
}
Loading
Loading