Support bridge bootstrap QR and codes
This commit is contained in:
parent
700b639011
commit
b94f0cb849
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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>{}
|
||||
|
||||
@ -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';
|
||||
|
||||
43
lib/runtime/gateway_runtime_bootstrap.dart
Normal file
43
lib/runtime/gateway_runtime_bootstrap.dart
Normal 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() ?? '';
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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});
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
216
test/runtime/app_controller_bridge_bootstrap_suite.dart
Normal file
216
test/runtime/app_controller_bridge_bootstrap_suite.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user