Support bridge bootstrap QR and codes

This commit is contained in:
Haitao Pan 2026-04-10 15:36:05 +08:00
parent 700b639011
commit b94f0cb849
11 changed files with 608 additions and 14 deletions

View File

@ -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<String> 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<void> 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(

View File

@ -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<void> 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) {

View File

@ -249,6 +249,64 @@ class MobileShellStateInternal extends State<MobileShell> {
}
}
Future<void> 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<String>(
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<MobileShell> {
builder: (_) => MobileGatewayPairingGuidePage(
supportsQrScan: supportsQrScan,
onManualInput: () => unawaited(openGatewaySetupCodeEntryInternal()),
onManualCodeInput: () =>
unawaited(promptBridgeVerificationCodeInternal()),
onScannedSetupCode: (setupCode) async {
await connectWithScannedSetupCodeInternal(setupCode);
},

View File

@ -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<String> scopes;
final bool oneTime;
final String qrPayload;
static String _stringValueStatic(Object? raw) {
return raw == null ? '' : raw.toString().trim();
}
factory BridgeBootstrapIssue.fromJson(Map<String, dynamic> json) {
List<String> scopes = const <String>[];
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<String, dynamic> 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<Map<String, dynamic>> login({
@ -100,6 +188,46 @@ class AccountRuntimeClient {
);
}
Future<BridgeBootstrapIssue> createBridgeBootstrapTicket({
required String token,
}) async {
final payload = await _requestJson(
method: 'POST',
path: '/api/auth/xworkmate/bridge/bootstrap',
bearerToken: token,
body: const <String, Object?>{},
);
return BridgeBootstrapIssue.fromJson(payload);
}
Future<BridgeBootstrapIssue> 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<BridgeBootstrapConsumeResult> consumeBridgeBootstrapTicket({
required String ticket,
required String bridgeOrigin,
}) async {
final payload = await _requestJson(
method: 'POST',
path: '/bridge/bootstrap/consume',
body: <String, Object?>{
'ticket': ticket.trim(),
'bridge': bridgeOrigin.trim(),
},
);
return BridgeBootstrapConsumeResult.fromJson(payload);
}
Future<String> readVaultSecretValue({
required String vaultUrl,
required String namespace,
@ -142,7 +270,9 @@ class AccountRuntimeClient {
}
return raw
.whereType<Map>()
.map((item) => AccountSecretLocator.fromJson(item.cast<String, dynamic>()))
.map(
(item) => AccountSecretLocator.fromJson(item.cast<String, dynamic>()),
)
.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 <String, dynamic>{}

View File

@ -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';

View File

@ -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<String, dynamic>;
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() ?? '';

View File

@ -28,6 +28,7 @@ void main() {
WidgetTester tester, {
required bool supportsQrScan,
required VoidCallback onManual,
required VoidCallback onManualCode,
required Future<void> 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');
});
}

View File

@ -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,

View File

@ -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<void> dispose() async {}
}
typedef FakeGatewayServerSupport = _FakeGatewayServer;
typedef FakeGoTaskServiceClientSupport = _FakeGoTaskServiceClient;
class _AcpSessionPayload {
const _AcpSessionPayload({required this.notification, required this.result});

View File

@ -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();
}

View File

@ -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(<String, Object>{});
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<void> _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<void>.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<void> close() => _server.close(force: true);
Future<void> _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(<String, Object?>{
'ticket': ticketId,
'shortCode': shortCode,
'bridge': _BridgeFakeBootstrapServer.currentBridgeOrigin,
'scheme': 'xworkmate-bridge-bootstrap',
'expiresAt': '2026-04-10T00:00:00Z',
'scopes': const <String>['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<void> close() => _server.close(force: true);
Future<void> _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<String, dynamic>();
lastConsumedTicket = payload['ticket']?.toString();
request.response.headers.contentType = ContentType.json;
request.response.write(
jsonEncode(<String, Object?>{
'setupCode': jsonEncode(<String, Object?>{
'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();
}
}
}