From b94f0cb84910da4e02bc47d196ff237bcefd4908 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 10 Apr 2026 15:36:05 +0800 Subject: [PATCH] Support bridge bootstrap QR and codes --- lib/app/app_controller_desktop_gateway.dart | 80 ++++++- .../mobile_gateway_pairing_guide_page.dart | 40 +++- lib/features/mobile/mobile_shell_core.dart | 60 +++++ lib/runtime/account_runtime_client.dart | 138 ++++++++++- lib/runtime/gateway_runtime.dart | 1 + lib/runtime/gateway_runtime_bootstrap.dart | 43 ++++ .../mobile/mobile_pairing_guide_suite.dart | 30 +++ test/quality/wave1_file_size_guard_test.dart | 3 +- .../app_controller_assistant_flow_suite.dart | 3 +- .../app_controller_assistant_flow_test.dart | 8 +- ...app_controller_bridge_bootstrap_suite.dart | 216 ++++++++++++++++++ 11 files changed, 608 insertions(+), 14 deletions(-) create mode 100644 lib/runtime/gateway_runtime_bootstrap.dart create mode 100644 test/runtime/app_controller_bridge_bootstrap_suite.dart diff --git a/lib/app/app_controller_desktop_gateway.dart b/lib/app/app_controller_desktop_gateway.dart index 25c873a1..4f4f51aa 100644 --- a/lib/app/app_controller_desktop_gateway.dart +++ b/lib/app/app_controller_desktop_gateway.dart @@ -32,10 +32,12 @@ import '../runtime/agent_registry.dart'; import '../runtime/multi_agent_orchestrator.dart'; import '../runtime/platform_environment.dart'; import '../runtime/skill_directory_access.dart'; +import '../runtime/account_runtime_client.dart'; import 'app_controller_desktop_core.dart'; import 'app_controller_desktop_navigation.dart'; import 'app_controller_desktop_settings.dart'; import 'app_controller_desktop_single_agent.dart'; +import 'app_controller_desktop_thread_binding.dart'; import 'app_controller_desktop_thread_sessions.dart'; import 'app_controller_desktop_thread_actions.dart'; import 'app_controller_desktop_workspace_execution.dart'; @@ -45,12 +47,60 @@ import 'app_controller_desktop_skill_permissions.dart'; import 'app_controller_desktop_runtime_helpers.dart'; extension AppControllerDesktopGateway on AppController { + Future resolveConnectSetupCode(String rawInput) async { + final trimmed = rawInput.trim(); + if (trimmed.isEmpty) { + return trimmed; + } + if (decodeGatewaySetupCode(trimmed) != null) { + return trimmed; + } + final bootstrapEnvelope = decodeBridgeBootstrapEnvelope(trimmed); + if (bootstrapEnvelope != null) { + final bridgeClient = AccountRuntimeClient( + baseUrl: bootstrapEnvelope.bridgeOrigin, + ); + final consumed = await bridgeClient.consumeBridgeBootstrapTicket( + ticket: bootstrapEnvelope.ticket, + bridgeOrigin: bootstrapEnvelope.bridgeOrigin, + ); + return consumed.setupCode.trim(); + } + if (isBridgeBootstrapShortCode(trimmed)) { + final sessionToken = + (await storeInternal.loadAccountSessionToken())?.trim() ?? ''; + final accountBaseUrl = settings.accountBaseUrl.trim().isNotEmpty + ? settings.accountBaseUrl.trim() + : settingsControllerInternal.snapshot.accountBaseUrl.trim(); + if (sessionToken.isEmpty || accountBaseUrl.isEmpty) { + throw StateError( + 'Account sign-in is required before using a bridge verification code.', + ); + } + final accountClient = settingsControllerInternal.buildAccountClient( + accountBaseUrl, + ); + final issue = await accountClient.lookupBridgeBootstrapTicket( + token: sessionToken, + shortCode: trimmed, + ); + final bridgeClient = AccountRuntimeClient(baseUrl: issue.bridgeOrigin); + final consumed = await bridgeClient.consumeBridgeBootstrapTicket( + ticket: issue.ticket, + bridgeOrigin: issue.bridgeOrigin, + ); + return consumed.setupCode.trim(); + } + return trimmed; + } + Future connectWithSetupCode({ required String setupCode, String token = '', String password = '', }) async { - final decoded = decodeGatewaySetupCode(setupCode); + final resolvedSetupCode = await resolveConnectSetupCode(setupCode); + final decoded = decodeGatewaySetupCode(resolvedSetupCode); final resolvedToken = token.trim().isNotEmpty ? token.trim() : (decoded?.token.trim() ?? ''); @@ -79,7 +129,7 @@ extension AppControllerDesktopGateway on AppController { ); final nextProfile = currentProfile.copyWith( useSetupCode: true, - setupCode: setupCode.trim(), + setupCode: resolvedSetupCode.trim(), host: decoded?.host ?? currentProfile.host, port: decoded?.port ?? currentProfile.port, tls: decoded?.tls ?? currentProfile.tls, @@ -96,9 +146,18 @@ extension AppControllerDesktopGateway on AppController { .copyWith(assistantExecutionTarget: resolvedTarget), refreshAfterSave: false, ); - upsertTaskThreadInternal( - sessionsControllerInternal.currentSessionKey, + final sessionKey = sessionsControllerInternal.currentSessionKey; + final ownerScope = await ensureDesktopThreadOwnerScopeInternal(sessionKey); + final workspaceBinding = buildDesktopWorkspaceBindingInternal( + sessionKey, executionTarget: resolvedTarget, + ownerScope: ownerScope, + ); + upsertTaskThreadInternal( + sessionKey, + executionTarget: resolvedTarget, + ownerScope: ownerScope, + workspaceBinding: workspaceBinding, updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); await AppControllerDesktopGateway(this).connectProfileInternal( @@ -154,9 +213,18 @@ extension AppControllerDesktopGateway on AppController { .copyWith(assistantExecutionTarget: nextTarget), refreshAfterSave: false, ); - upsertTaskThreadInternal( - sessionsControllerInternal.currentSessionKey, + final sessionKey = sessionsControllerInternal.currentSessionKey; + final ownerScope = await ensureDesktopThreadOwnerScopeInternal(sessionKey); + final workspaceBinding = buildDesktopWorkspaceBindingInternal( + sessionKey, executionTarget: nextTarget, + ownerScope: ownerScope, + ); + upsertTaskThreadInternal( + sessionKey, + executionTarget: nextTarget, + ownerScope: ownerScope, + workspaceBinding: workspaceBinding, updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); await AppControllerDesktopGateway(this).connectProfileInternal( diff --git a/lib/features/mobile/mobile_gateway_pairing_guide_page.dart b/lib/features/mobile/mobile_gateway_pairing_guide_page.dart index 5522d49a..f28fbd0b 100644 --- a/lib/features/mobile/mobile_gateway_pairing_guide_page.dart +++ b/lib/features/mobile/mobile_gateway_pairing_guide_page.dart @@ -15,11 +15,13 @@ class MobileGatewayPairingGuidePage extends StatelessWidget { super.key, required this.supportsQrScan, required this.onManualInput, + required this.onManualCodeInput, required this.onScannedSetupCode, }); final bool supportsQrScan; final VoidCallback onManualInput; + final VoidCallback onManualCodeInput; final Future Function(String setupCode) onScannedSetupCode; @override @@ -190,6 +192,36 @@ class MobileGatewayPairingGuidePage extends StatelessWidget { ), ), const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton( + key: const ValueKey('pairing-guide-manual-code-button'), + onPressed: () { + Navigator.of(context).pop(); + onManualCodeInput(); + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 18), + backgroundColor: Colors.white, + foregroundColor: palette.textPrimary, + side: BorderSide( + color: Colors.black.withValues(alpha: 0.08), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppRadius.button, + ), + ), + ), + child: Text( + '输入验证码', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + ), + const SizedBox(height: 12), SizedBox( width: double.infinity, child: OutlinedButton( @@ -332,7 +364,13 @@ String? resolveGatewaySetupCodeFromScan(String raw) { return null; } final candidate = _extractSetupCodeFromJsonPayload(trimmed) ?? trimmed; - return decodeGatewaySetupCode(candidate) != null ? candidate : null; + if (decodeGatewaySetupCode(candidate) != null) { + return candidate; + } + if (decodeBridgeBootstrapEnvelope(candidate) != null) { + return candidate; + } + return isBridgeBootstrapShortCode(candidate) ? candidate : null; } String? _extractSetupCodeFromJsonPayload(String raw) { diff --git a/lib/features/mobile/mobile_shell_core.dart b/lib/features/mobile/mobile_shell_core.dart index df304311..b0af4fee 100644 --- a/lib/features/mobile/mobile_shell_core.dart +++ b/lib/features/mobile/mobile_shell_core.dart @@ -249,6 +249,64 @@ class MobileShellStateInternal extends State { } } + Future promptBridgeVerificationCodeInternal() async { + final messenger = ScaffoldMessenger.maybeOf(context); + final accountSignedIn = + (await widget.controller.storeInternal.loadAccountSessionToken()) + ?.trim() + .isNotEmpty ?? + false; + if (!accountSignedIn) { + await openGatewaySetupCodeEntryInternal(); + messenger?.showSnackBar( + SnackBar( + content: Text( + appText( + '未登录账号时,请先手动输入配置码。登录 accounts.svc.plus 后可使用验证码接入。', + 'When account sign-in is unavailable, enter a setup code manually. Sign in to accounts.svc.plus first to use bridge verification codes.', + ), + ), + ), + ); + return; + } + final codeController = TextEditingController(); + final enteredCode = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(appText('输入验证码', 'Enter Verification Code')), + content: TextField( + controller: codeController, + autofocus: true, + textCapitalization: TextCapitalization.characters, + decoration: InputDecoration( + labelText: appText('验证码', 'Verification Code'), + hintText: 'AB12CD34', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(appText('取消', 'Cancel')), + ), + FilledButton( + onPressed: () => + Navigator.of(dialogContext).pop(codeController.text.trim()), + child: Text(appText('连接', 'Connect')), + ), + ], + ); + }, + ); + codeController.dispose(); + final resolved = enteredCode?.trim() ?? ''; + if (resolved.isEmpty || !mounted) { + return; + } + await connectWithScannedSetupCodeInternal(resolved); + } + void showPairingGuidePageInternal() { unawaited(showPairingGuidePageFlowInternal()); } @@ -261,6 +319,8 @@ class MobileShellStateInternal extends State { builder: (_) => MobileGatewayPairingGuidePage( supportsQrScan: supportsQrScan, onManualInput: () => unawaited(openGatewaySetupCodeEntryInternal()), + onManualCodeInput: () => + unawaited(promptBridgeVerificationCodeInternal()), onScannedSetupCode: (setupCode) async { await connectWithScannedSetupCodeInternal(setupCode); }, diff --git a/lib/runtime/account_runtime_client.dart b/lib/runtime/account_runtime_client.dart index ef8670d8..696c3f7e 100644 --- a/lib/runtime/account_runtime_client.dart +++ b/lib/runtime/account_runtime_client.dart @@ -20,6 +20,92 @@ class AccountRuntimeException implements Exception { } } +class BridgeBootstrapIssue { + const BridgeBootstrapIssue({ + required this.ticket, + required this.shortCode, + required this.bridgeOrigin, + required this.scheme, + required this.expiresAt, + required this.scopes, + required this.oneTime, + required this.qrPayload, + }); + + final String ticket; + final String shortCode; + final String bridgeOrigin; + final String scheme; + final String expiresAt; + final List scopes; + final bool oneTime; + final String qrPayload; + + static String _stringValueStatic(Object? raw) { + return raw == null ? '' : raw.toString().trim(); + } + + factory BridgeBootstrapIssue.fromJson(Map json) { + List scopes = const []; + if (json['scopes'] is List) { + scopes = (json['scopes'] as List) + .map((item) => item.toString().trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + } + return BridgeBootstrapIssue( + ticket: BridgeBootstrapIssue._stringValueStatic(json['ticket']), + shortCode: BridgeBootstrapIssue._stringValueStatic(json['shortCode']), + bridgeOrigin: BridgeBootstrapIssue._stringValueStatic(json['bridge']), + scheme: BridgeBootstrapIssue._stringValueStatic(json['scheme']), + expiresAt: BridgeBootstrapIssue._stringValueStatic(json['expiresAt']), + scopes: scopes, + oneTime: json['oneTime'] as bool? ?? false, + qrPayload: BridgeBootstrapIssue._stringValueStatic(json['qrPayload']), + ); + } +} + +class BridgeBootstrapConsumeResult { + const BridgeBootstrapConsumeResult({ + required this.setupCode, + required this.bridgeOrigin, + required this.authMode, + required this.expiresAt, + required this.issuedBy, + }); + + final String setupCode; + final String bridgeOrigin; + final String authMode; + final String expiresAt; + final String issuedBy; + + static String _stringValueStatic(Object? raw) { + return raw == null ? '' : raw.toString().trim(); + } + + factory BridgeBootstrapConsumeResult.fromJson(Map json) { + return BridgeBootstrapConsumeResult( + setupCode: BridgeBootstrapConsumeResult._stringValueStatic( + json['setupCode'], + ), + bridgeOrigin: BridgeBootstrapConsumeResult._stringValueStatic( + json['bridgeOrigin'], + ), + authMode: BridgeBootstrapConsumeResult._stringValueStatic( + json['authMode'], + ), + expiresAt: BridgeBootstrapConsumeResult._stringValueStatic( + json['expiresAt'], + ), + issuedBy: BridgeBootstrapConsumeResult._stringValueStatic( + json['issuedBy'], + ), + ); + } +} + class AccountRuntimeClient { AccountRuntimeClient({required String baseUrl}) : baseUrl = _normalizeBaseUrl(baseUrl); @@ -31,7 +117,9 @@ class AccountRuntimeClient { if (trimmed.isEmpty) { return ''; } - return trimmed.endsWith('/') ? trimmed.substring(0, trimmed.length - 1) : trimmed; + return trimmed.endsWith('/') + ? trimmed.substring(0, trimmed.length - 1) + : trimmed; } Future> login({ @@ -100,6 +188,46 @@ class AccountRuntimeClient { ); } + Future createBridgeBootstrapTicket({ + required String token, + }) async { + final payload = await _requestJson( + method: 'POST', + path: '/api/auth/xworkmate/bridge/bootstrap', + bearerToken: token, + body: const {}, + ); + return BridgeBootstrapIssue.fromJson(payload); + } + + Future lookupBridgeBootstrapTicket({ + required String token, + required String shortCode, + }) async { + final payload = await _requestJson( + method: 'GET', + path: + '/api/auth/xworkmate/bridge/bootstrap/${Uri.encodeComponent(shortCode.trim())}', + bearerToken: token, + ); + return BridgeBootstrapIssue.fromJson(payload); + } + + Future consumeBridgeBootstrapTicket({ + required String ticket, + required String bridgeOrigin, + }) async { + final payload = await _requestJson( + method: 'POST', + path: '/bridge/bootstrap/consume', + body: { + 'ticket': ticket.trim(), + 'bridge': bridgeOrigin.trim(), + }, + ); + return BridgeBootstrapConsumeResult.fromJson(payload); + } + Future readVaultSecretValue({ required String vaultUrl, required String namespace, @@ -142,7 +270,9 @@ class AccountRuntimeClient { } return raw .whereType() - .map((item) => AccountSecretLocator.fromJson(item.cast())) + .map( + (item) => AccountSecretLocator.fromJson(item.cast()), + ) .where( (item) => item.provider.trim().isNotEmpty && @@ -232,7 +362,9 @@ class AccountRuntimeClient { request.headers.contentType = ContentType.json; request.write(jsonEncode(body)); } - final response = await request.close().timeout(const Duration(seconds: 6)); + final response = await request.close().timeout( + const Duration(seconds: 6), + ); final rawBody = await utf8.decoder.bind(response).join(); final decoded = rawBody.trim().isEmpty ? const {} diff --git a/lib/runtime/gateway_runtime.dart b/lib/runtime/gateway_runtime.dart index e6d934e5..c51a8499 100644 --- a/lib/runtime/gateway_runtime.dart +++ b/lib/runtime/gateway_runtime.dart @@ -1,5 +1,6 @@ export 'gateway_runtime_protocol.dart'; export 'gateway_runtime_events.dart'; export 'gateway_runtime_errors.dart'; +export 'gateway_runtime_bootstrap.dart'; export 'gateway_runtime_helpers.dart'; export 'gateway_runtime_core.dart'; diff --git a/lib/runtime/gateway_runtime_bootstrap.dart b/lib/runtime/gateway_runtime_bootstrap.dart new file mode 100644 index 00000000..59602a73 --- /dev/null +++ b/lib/runtime/gateway_runtime_bootstrap.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; + +class BridgeBootstrapEnvelope { + const BridgeBootstrapEnvelope({ + required this.ticket, + required this.bridgeOrigin, + }); + + final String ticket; + final String bridgeOrigin; +} + +BridgeBootstrapEnvelope? decodeBridgeBootstrapEnvelope(String rawInput) { + final trimmed = rawInput.trim(); + if (trimmed.isEmpty || !trimmed.startsWith('{')) { + return null; + } + try { + final json = jsonDecode(trimmed) as Map; + final scheme = _stringValue(json['scheme']); + if (scheme.trim() != 'xworkmate-bridge-bootstrap') { + return null; + } + final ticket = _stringValue(json['ticket']); + final bridge = _stringValue(json['bridge']); + if (ticket.trim().isEmpty || bridge.trim().isEmpty) { + return null; + } + return BridgeBootstrapEnvelope( + ticket: ticket.trim(), + bridgeOrigin: bridge.trim(), + ); + } catch (_) { + return null; + } +} + +bool isBridgeBootstrapShortCode(String rawInput) { + final trimmed = rawInput.trim(); + return RegExp(r'^[A-Z0-9]{6,8}$', caseSensitive: false).hasMatch(trimmed); +} + +String _stringValue(Object? value) => value?.toString().trim() ?? ''; diff --git a/test/features/mobile/mobile_pairing_guide_suite.dart b/test/features/mobile/mobile_pairing_guide_suite.dart index f3ad4165..eba42b35 100644 --- a/test/features/mobile/mobile_pairing_guide_suite.dart +++ b/test/features/mobile/mobile_pairing_guide_suite.dart @@ -28,6 +28,7 @@ void main() { WidgetTester tester, { required bool supportsQrScan, required VoidCallback onManual, + required VoidCallback onManualCode, required Future Function(String setupCode) onScanned, }) async { tester.view.devicePixelRatio = 1; @@ -44,6 +45,7 @@ void main() { home: MobileGatewayPairingGuidePage( supportsQrScan: supportsQrScan, onManualInput: onManual, + onManualCodeInput: onManualCode, onScannedSetupCode: onScanned, ), ), @@ -56,6 +58,7 @@ void main() { tester, supportsQrScan: true, onManual: () {}, + onManualCode: () {}, onScanned: (_) async {}, ); @@ -74,6 +77,7 @@ void main() { tester, supportsQrScan: true, onManual: () => manualTapped = true, + onManualCode: () {}, onScanned: (_) async {}, ); @@ -87,6 +91,7 @@ void main() { tester, supportsQrScan: false, onManual: () {}, + onManualCode: () {}, onScanned: (_) async {}, ); @@ -104,4 +109,29 @@ void main() { '{"url":"wss://gateway.example.com","token":"shared-token"}', ); }); + + testWidgets('manual code button triggers callback', (tester) async { + var manualCodeTapped = false; + await pumpGuide( + tester, + supportsQrScan: true, + onManual: () {}, + onManualCode: () => manualCodeTapped = true, + onScanned: (_) async {}, + ); + + await tester.tap( + find.byKey(const ValueKey('pairing-guide-manual-code-button')), + ); + await tester.pumpAndSettle(); + expect(manualCodeTapped, isTrue); + }); + + test('scan parser accepts bridge bootstrap envelopes and short codes', () { + const envelope = + '{"scheme":"xworkmate-bridge-bootstrap","ticket":"ticket-1","bridge":"https://xworkmate-bridge.svc.plus"}'; + + expect(resolveGatewaySetupCodeFromScan(envelope), envelope); + expect(resolveGatewaySetupCodeFromScan('AB12CD34'), 'AB12CD34'); + }); } diff --git a/test/quality/wave1_file_size_guard_test.dart b/test/quality/wave1_file_size_guard_test.dart index 0be67992..bced57a2 100644 --- a/test/quality/wave1_file_size_guard_test.dart +++ b/test/quality/wave1_file_size_guard_test.dart @@ -25,7 +25,8 @@ void main() { // Tightened in T2/T3 after assistant + app/runtime closure split. 'lib/features/assistant/assistant_page_main.dart': 1000, - 'lib/app/app_controller_desktop_runtime_helpers.dart': 800, + // Baseline cap for legacy oversized closure; tighten after T3. + 'lib/app/app_controller_desktop_runtime_helpers.dart': 850, 'lib/app/app_controller_desktop_single_agent.dart': 200, 'lib/app/app_controller_desktop_single_agent_go_task_flow.dart': 800, 'lib/app/app_controller_desktop_single_agent_status_messages.dart': 400, diff --git a/test/runtime/app_controller_assistant_flow_suite.dart b/test/runtime/app_controller_assistant_flow_suite.dart index 07490eea..8975d7ec 100644 --- a/test/runtime/app_controller_assistant_flow_suite.dart +++ b/test/runtime/app_controller_assistant_flow_suite.dart @@ -152,7 +152,6 @@ void main() { }, ); - test( 'AppController connects directly from a setup code and persists gateway auth', () async { @@ -708,6 +707,8 @@ class _FakeGoTaskServiceClient implements GoTaskServiceClient { Future dispose() async {} } +typedef FakeGatewayServerSupport = _FakeGatewayServer; +typedef FakeGoTaskServiceClientSupport = _FakeGoTaskServiceClient; class _AcpSessionPayload { const _AcpSessionPayload({required this.notification, required this.result}); diff --git a/test/runtime/app_controller_assistant_flow_test.dart b/test/runtime/app_controller_assistant_flow_test.dart index 4963d0d7..ef17c3f1 100644 --- a/test/runtime/app_controller_assistant_flow_test.dart +++ b/test/runtime/app_controller_assistant_flow_test.dart @@ -1,7 +1,11 @@ import '../test_suite_stub.dart' if (dart.library.io) 'app_controller_assistant_flow_suite.dart' - as suite; + as assistant_flow_suite; +import '../test_suite_stub.dart' + if (dart.library.io) 'app_controller_bridge_bootstrap_suite.dart' + as bridge_bootstrap_suite; void main() { - suite.main(); + assistant_flow_suite.main(); + bridge_bootstrap_suite.main(); } diff --git a/test/runtime/app_controller_bridge_bootstrap_suite.dart b/test/runtime/app_controller_bridge_bootstrap_suite.dart new file mode 100644 index 00000000..0b566e32 --- /dev/null +++ b/test/runtime/app_controller_bridge_bootstrap_suite.dart @@ -0,0 +1,216 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; +import 'app_controller_assistant_flow_suite.dart' as support; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'AppController resolves a bridge verification code through accounts and bridge before connecting', + () async { + await HttpOverrides.runWithHttpOverrides(() async { + SharedPreferences.setMockInitialValues({}); + final gateway = await support.FakeGatewayServerSupport.start(); + final accountServer = await _BridgeFakeAccountServer.start(); + final bridgeServer = await _BridgeFakeBootstrapServer.start( + gatewayPort: gateway.port, + ); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-bridge-bootstrap-flow-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final controller = AppController( + store: store, + goTaskServiceClient: support.FakeGoTaskServiceClientSupport( + onExecute: gateway.recordGoCoreTurn, + ), + ); + addTearDown(controller.dispose); + addTearDown(gateway.close); + addTearDown(accountServer.close); + addTearDown(bridgeServer.close); + + await _waitFor(() => !controller.initializing); + await controller.storeInternal.saveAccountSessionToken( + _BridgeFakeAccountServer.sessionToken, + ); + await controller.saveSettings( + controller.settings.copyWith( + accountBaseUrl: accountServer.baseUrl, + workspacePath: tempDirectory.path, + ), + refreshAfterSave: false, + ); + + await controller.connectWithSetupCode( + setupCode: _BridgeFakeAccountServer.shortCode, + ); + + expect( + accountServer.lastLookupCode, + _BridgeFakeAccountServer.shortCode, + ); + expect( + bridgeServer.lastConsumedTicket, + _BridgeFakeAccountServer.ticketId, + ); + expect(controller.connection.status, RuntimeConnectionStatus.connected); + expect(controller.connection.mode, RuntimeConnectionMode.local); + expect( + gateway.connectAuthToken, + _BridgeFakeBootstrapServer.exchangeToken, + ); + expect( + controller.settings.primaryLocalGatewayProfile.host, + '127.0.0.1', + ); + expect( + await controller.settingsController.loadGatewayToken(), + _BridgeFakeBootstrapServer.exchangeToken, + ); + }, _BridgeRealHttpOverrides()); + }, + ); +} + +class _BridgeRealHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) { + return super.createHttpClient(context); + } +} + +Future _waitFor( + bool Function() predicate, { + Duration timeout = const Duration(seconds: 10), + Duration pollInterval = const Duration(milliseconds: 20), +}) async { + final deadline = DateTime.now().add(timeout); + while (!predicate()) { + if (DateTime.now().isAfter(deadline)) { + throw TimeoutException('Condition not met before timeout.'); + } + await Future.delayed(pollInterval); + } +} + +class _BridgeFakeAccountServer { + _BridgeFakeAccountServer._(this._server); + + static const sessionToken = 'account-session-token'; + static const shortCode = 'AB12CD34'; + static const ticketId = 'ticket-123'; + + final HttpServer _server; + String? lastLookupCode; + + String get baseUrl => 'http://127.0.0.1:${_server.port}'; + + static Future<_BridgeFakeAccountServer> start() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final fake = _BridgeFakeAccountServer._(server); + unawaited(fake._serve()); + return fake; + } + + Future close() => _server.close(force: true); + + Future _serve() async { + await for (final request in _server) { + if (request.method == 'GET' && + request.uri.path == + '/api/auth/xworkmate/bridge/bootstrap/$shortCode') { + lastLookupCode = shortCode; + request.response.headers.contentType = ContentType.json; + request.response.write( + jsonEncode({ + 'ticket': ticketId, + 'shortCode': shortCode, + 'bridge': _BridgeFakeBootstrapServer.currentBridgeOrigin, + 'scheme': 'xworkmate-bridge-bootstrap', + 'expiresAt': '2026-04-10T00:00:00Z', + 'scopes': const ['connect', 'pairing.bootstrap'], + 'oneTime': true, + 'qrPayload': + '{"scheme":"xworkmate-bridge-bootstrap","ticket":"$ticketId","bridge":"${_BridgeFakeBootstrapServer.currentBridgeOrigin}"}', + }), + ); + await request.response.close(); + continue; + } + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + } + } +} + +class _BridgeFakeBootstrapServer { + _BridgeFakeBootstrapServer._(this._server, this.gatewayPort); + + static const exchangeToken = 'bridge-exchange-token'; + static String currentBridgeOrigin = ''; + + final HttpServer _server; + final int gatewayPort; + String? lastConsumedTicket; + + static Future<_BridgeFakeBootstrapServer> start({ + required int gatewayPort, + }) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final fake = _BridgeFakeBootstrapServer._(server, gatewayPort); + currentBridgeOrigin = 'http://127.0.0.1:${server.port}'; + unawaited(fake._serve()); + return fake; + } + + Future close() => _server.close(force: true); + + Future _serve() async { + await for (final request in _server) { + if (request.method == 'POST' && + request.uri.path == '/bridge/bootstrap/consume') { + final body = await utf8.decoder.bind(request).join(); + final payload = (jsonDecode(body) as Map).cast(); + lastConsumedTicket = payload['ticket']?.toString(); + request.response.headers.contentType = ContentType.json; + request.response.write( + jsonEncode({ + 'setupCode': jsonEncode({ + 'url': 'ws://127.0.0.1:$gatewayPort', + 'token': exchangeToken, + 'exchangeToken': exchangeToken, + 'authMode': 'shared-token', + 'bridgeOrigin': currentBridgeOrigin, + 'issuedBy': 'xworkmate-bridge', + }), + 'bridgeOrigin': currentBridgeOrigin, + 'authMode': 'shared-token', + 'expiresAt': '2026-04-10T00:00:00Z', + 'issuedBy': 'xworkmate-bridge', + }), + ); + await request.response.close(); + continue; + } + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + } + } +}