From a353f6866f1d03864c3f5052d22444f06390849b Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 17 Jun 2026 21:01:56 +0800 Subject: [PATCH] fix(settings): update account panel and assistant connection state --- ...pp_controller_desktop_runtime_helpers.dart | 14 +- .../settings/settings_account_panel.dart | 21 +- lib/features/settings/settings_page_core.dart | 20 ++ ...ime_controllers_settings_account_impl.dart | 69 +++++- .../settings/settings_account_panel_test.dart | 101 +++++++- .../assistant_connection_state_test.dart | 2 +- ...ime_controllers_settings_account_test.dart | 224 +++++++++++++++++- 7 files changed, 433 insertions(+), 18 deletions(-) diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 314eb738..42ddcdc4 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -1303,7 +1303,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController { .isNotEmpty) { return false; } - final envToken = runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN'); + final envToken = _runtimeBridgeAuthEnvTokenInternal(); return envToken != null && envToken.isNotEmpty; } @@ -1421,10 +1421,20 @@ extension AppControllerDesktopRuntimeHelpers on AppController { return bridgeToken?.isNotEmpty == true ? bridgeToken : null; } - final envToken = runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN'); + final envToken = _runtimeBridgeAuthEnvTokenInternal(); return envToken?.isNotEmpty == true ? envToken : null; } + String? _runtimeBridgeAuthEnvTokenInternal() { + final aiWorkspaceToken = runtimeEnvironmentValueInternal( + 'AI_WORKSPACE_AUTH_TOKEN', + ); + if (aiWorkspaceToken != null && aiWorkspaceToken.isNotEmpty) { + return aiWorkspaceToken; + } + return runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN'); + } + int? gatewayProfileIndexMatchingEndpointInternal(Uri endpoint) { final normalizedHost = endpoint.host.trim().toLowerCase(); final normalizedScheme = endpoint.scheme.trim().toLowerCase(); diff --git a/lib/features/settings/settings_account_panel.dart b/lib/features/settings/settings_account_panel.dart index ad4e9f10..f4f94bc7 100644 --- a/lib/features/settings/settings_account_panel.dart +++ b/lib/features/settings/settings_account_panel.dart @@ -24,6 +24,7 @@ class SettingsAccountPanel extends StatefulWidget { required this.onVerifyMfa, required this.onCancelMfa, required this.onSync, + required this.onResetManualBridge, required this.onLogout, }); @@ -46,6 +47,7 @@ class SettingsAccountPanel extends StatefulWidget { final Future Function() onVerifyMfa; final Future Function() onCancelMfa; final Future Function() onSync; + final Future Function() onResetManualBridge; final Future Function() onLogout; @override @@ -89,7 +91,11 @@ class _SettingsAccountPanelState extends State @override Widget build(BuildContext context) { - if (!widget.accountSignedIn && !widget.accountMfaRequired) { + final isManualBridgeConfigured = + widget.settings.acpBridgeServerModeConfig.effective.source == 'bridge'; + if (!widget.accountSignedIn && + !widget.accountMfaRequired && + !isManualBridgeConfigured) { return AnimatedBuilder( animation: _signedOutTabController, builder: (context, _) { @@ -151,6 +157,7 @@ class _SettingsAccountPanelState extends State accountStatus: widget.accountStatus, onSaveAccountProfile: widget.onSaveAccountProfile, onSync: widget.onSync, + onResetManualBridge: widget.onResetManualBridge, onLogout: widget.onLogout, ); } @@ -464,6 +471,7 @@ class _SignedInAccountPanel extends StatelessWidget { required this.accountStatus, required this.onSaveAccountProfile, required this.onSync, + required this.onResetManualBridge, required this.onLogout, }); @@ -475,6 +483,7 @@ class _SignedInAccountPanel extends StatelessWidget { final Future Function({required bool isManualBridge}) onSaveAccountProfile; final Future Function() onSync; + final Future Function() onResetManualBridge; final Future Function() onLogout; @override @@ -525,9 +534,8 @@ class _SignedInAccountPanel extends StatelessWidget { final primaryActionKey = isAccountSyncMode ? 'settings-account-sync-button' : 'settings-account-manual-reset-button'; - final primaryAction = isAccountSyncMode - ? onSync - : () => onSaveAccountProfile(isManualBridge: true); + final primaryAction = isAccountSyncMode ? onSync : onResetManualBridge; + final exitAction = isAccountSyncMode ? onLogout : onResetManualBridge; final mfaEnabled = accountSession?.totpEnabled == true || accountSession?.mfaEnabled == true; @@ -630,7 +638,7 @@ class _SignedInAccountPanel extends StatelessWidget { ), TextButton( key: const ValueKey('settings-account-logout-button'), - onPressed: accountBusy ? null : () => onLogout(), + onPressed: accountBusy ? null : () => exitAction(), child: Text(appText('退出', 'Exit')), ), ], @@ -763,6 +771,9 @@ _SignedInAccountMode _signedInAccountModeFromSettings({ required SettingsSnapshot settings, required AccountSyncState? accountState, }) { + if (settings.acpBridgeServerModeConfig.effective.source == 'bridge') { + return _SignedInAccountMode.manualBridge; + } if (accountState?.profileScope.trim().toLowerCase() == 'bridge') { return _SignedInAccountMode.accountSync; } diff --git a/lib/features/settings/settings_page_core.dart b/lib/features/settings/settings_page_core.dart index 4d01ddc5..df275191 100644 --- a/lib/features/settings/settings_page_core.dart +++ b/lib/features/settings/settings_page_core.dart @@ -324,6 +324,25 @@ class _SettingsPageState extends State { await _refreshAboutSnapshot(); } + Future _resetManualBridge() async { + final current = widget.controller.settings; + final passwordRef = + current.acpBridgeServerModeConfig.selfHosted.passwordRef; + if (passwordRef.trim().isNotEmpty) { + await widget.controller.settingsController.storeInternal + .clearSecretValueByRef(passwordRef); + } + final nextSettings = current.copyWith( + acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults(), + ); + await widget.controller.saveSettings(nextSettings, refreshAfterSave: false); + _bridgeUrlController.clear(); + _bridgeTokenController.clear(); + _lastSavedBridgeUrl = ''; + await widget.controller.settingsController.refreshDerivedState(); + await _refreshAboutSnapshot(); + } + Future _refreshAboutSnapshot() async { if (!mounted) { return; @@ -514,6 +533,7 @@ class _SettingsPageState extends State { _verifyAccountMfa(widget.controller.settings), onCancelMfa: _cancelAccountMfa, onSync: () => _syncAccount(widget.controller.settings), + onResetManualBridge: _resetManualBridge, onLogout: _logoutAccount, ), ), diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart index b06177b6..7251a2da 100644 --- a/lib/runtime/runtime_controllers_settings_account_impl.dart +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -596,6 +596,10 @@ String _extractBridgeAuthTokenMetadata(Map payload) { if (reviewToken.isNotEmpty) { return reviewToken; } + final aiWorkspaceToken = _stringValue(payload['AI_WORKSPACE_AUTH_TOKEN']); + if (aiWorkspaceToken.isNotEmpty) { + return aiWorkspaceToken; + } return _stringValue(payload['BRIDGE_AUTH_TOKEN']); } @@ -644,10 +648,25 @@ Future buildSavedAccountProfileSettingsInternal( required bool isManualBridge, }) async { final bridgeConfig = settings.acpBridgeServerModeConfig; + final trimmedBridgeServerUrl = bridgeServerUrl.trim(); + final trimmedBridgeToken = bridgeToken.trim(); + final existingBridgeToken = isManualBridge + ? ((await controller.storeInternal.loadSecretValueByRef( + bridgeConfig.selfHosted.passwordRef, + ))?.trim() ?? + '') + : ''; + if (isManualBridge) { + _validateManualBridgeProfile( + serverUrl: trimmedBridgeServerUrl, + tokenConfigured: + trimmedBridgeToken.isNotEmpty || existingBridgeToken.isNotEmpty, + ); + } final nextBridgeConfig = bridgeConfig.copyWith( selfHosted: isManualBridge ? bridgeConfig.selfHosted.copyWith( - serverUrl: bridgeServerUrl.trim(), + serverUrl: trimmedBridgeServerUrl, username: 'admin', ) : bridgeConfig.selfHosted, @@ -663,7 +682,6 @@ Future buildSavedAccountProfileSettingsInternal( effective: nextEffective, ), ); - final trimmedBridgeToken = bridgeToken.trim(); if (isManualBridge && trimmedBridgeToken.isNotEmpty) { await controller.saveSecretValueByRef( nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef, @@ -675,6 +693,53 @@ Future buildSavedAccountProfileSettingsInternal( return nextSettings; } +void _validateManualBridgeProfile({ + required String serverUrl, + required bool tokenConfigured, +}) { + if (serverUrl.isEmpty) { + throw ArgumentError.value( + serverUrl, + 'bridgeServerUrl', + 'Bridge URL is required', + ); + } + if (!tokenConfigured) { + throw ArgumentError.value( + '', + 'bridgeToken', + 'Bridge auth token is required', + ); + } + final uri = Uri.tryParse(serverUrl); + if (uri == null || !uri.hasScheme || uri.host.trim().isEmpty) { + throw ArgumentError.value( + serverUrl, + 'bridgeServerUrl', + 'Bridge URL must be a valid URL', + ); + } + final scheme = uri.scheme.toLowerCase(); + final host = uri.host.toLowerCase(); + final isLocalHttp = + scheme == 'http' && + (host == '127.0.0.1' || host == 'localhost') && + uri.hasPort && + uri.port >= 1 && + uri.port <= 65535; + if (isLocalHttp) { + return; + } + if (scheme == 'https') { + return; + } + throw ArgumentError.value( + serverUrl, + 'bridgeServerUrl', + 'Manual Bridge URL must be http://127.0.0.1: or http://localhost: for local mode, or https:// for public custom bridge mode', + ); +} + int _parseExpiresAtMs(Object? value) { if (value is int) { return value; diff --git a/test/features/settings/settings_account_panel_test.dart b/test/features/settings/settings_account_panel_test.dart index 31c7e7ac..6e2895fd 100644 --- a/test/features/settings/settings_account_panel_test.dart +++ b/test/features/settings/settings_account_panel_test.dart @@ -40,6 +40,7 @@ void main() { onVerifyMfa: () async {}, onCancelMfa: () async {}, onSync: () async {}, + onResetManualBridge: () async {}, onLogout: () async {}, ), ), @@ -97,6 +98,7 @@ void main() { onVerifyMfa: () async {}, onCancelMfa: () async {}, onSync: () async {}, + onResetManualBridge: () async {}, onLogout: () async {}, ), ), @@ -151,6 +153,7 @@ void main() { onVerifyMfa: () async {}, onCancelMfa: () async {}, onSync: () async {}, + onResetManualBridge: () async {}, onLogout: () async {}, ), ), @@ -206,6 +209,7 @@ void main() { onVerifyMfa: () async {}, onCancelMfa: () async {}, onSync: () async {}, + onResetManualBridge: () async {}, onLogout: () async {}, ), ), @@ -260,6 +264,7 @@ void main() { onVerifyMfa: () async {}, onCancelMfa: () async {}, onSync: () async {}, + onResetManualBridge: () async {}, onLogout: () async {}, ), ), @@ -321,6 +326,7 @@ void main() { addTearDown(controllers.dispose); var syncCount = 0; + var resetCount = 0; var logoutCount = 0; final settings = SettingsSnapshot.defaults().copyWith( @@ -382,6 +388,9 @@ void main() { onSync: () async { syncCount += 1; }, + onResetManualBridge: () async { + resetCount += 1; + }, onLogout: () async { logoutCount += 1; }, @@ -430,6 +439,7 @@ void main() { await tester.pump(); expect(syncCount, 1); + expect(resetCount, 0); expect(logoutCount, 1); }, ); @@ -476,6 +486,7 @@ void main() { onVerifyMfa: () async {}, onCancelMfa: () async {}, onSync: () async {}, + onResetManualBridge: () async {}, onLogout: () async {}, ), ), @@ -538,8 +549,8 @@ void main() { addTearDown(controllers.dispose); var saveCount = 0; + var resetCount = 0; var logoutCount = 0; - var receivedManualBridge = false; await tester.pumpWidget( _buildTestApp( @@ -574,12 +585,14 @@ void main() { bridgeTokenController: controllers.bridgeToken, onSaveAccountProfile: ({required bool isManualBridge}) async { saveCount += 1; - receivedManualBridge = isManualBridge; }, onLogin: () async {}, onVerifyMfa: () async {}, onCancelMfa: () async {}, onSync: () async {}, + onResetManualBridge: () async { + resetCount += 1; + }, onLogout: () async { logoutCount += 1; }, @@ -607,9 +620,86 @@ void main() { ); await tester.pump(); - expect(saveCount, 1); - expect(receivedManualBridge, isTrue); - expect(logoutCount, 1); + expect(saveCount, 0); + expect(resetCount, 2); + expect(logoutCount, 0); + }, + ); + + testWidgets( + 'shows manual bridge status when saved without account sign-in', + (tester) async { + final controllers = _TestControllers(); + addTearDown(controllers.dispose); + + var saveCount = 0; + var resetCount = 0; + + await tester.pumpWidget( + _buildTestApp( + child: SettingsAccountPanel( + settings: SettingsSnapshot.defaults().copyWith( + acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults() + .copyWith( + effective: const AcpBridgeServerEffectiveConfig( + endpoint: 'http://127.0.0.1:8787', + tokenRef: 'acp_bridge_server_password', + source: 'bridge', + reason: + 'Manual Bridge configuration is present and valid', + ), + selfHosted: AcpBridgeServerModeConfig.defaults() + .selfHosted + .copyWith( + serverUrl: 'http://127.0.0.1:8787', + username: 'admin', + ), + ), + ), + accountSession: null, + accountState: null, + accountBusy: false, + accountSignedIn: false, + accountMfaRequired: false, + accountBaseUrlController: controllers.baseUrl, + accountIdentifierController: controllers.identifier, + accountPasswordController: controllers.password, + accountMfaCodeController: controllers.mfaCode, + bridgeUrlController: controllers.bridgeUrl, + bridgeTokenController: controllers.bridgeToken, + onSaveAccountProfile: ({required bool isManualBridge}) async { + saveCount += 1; + }, + onLogin: () async {}, + onVerifyMfa: () async {}, + onCancelMfa: () async {}, + onSync: () async {}, + onResetManualBridge: () async { + resetCount += 1; + }, + onLogout: () async {}, + ), + ), + ); + + expect(find.text('手动 Bridge'), findsOneWidget); + expect(find.textContaining('保存状态'), findsOneWidget); + expect( + find.byKey(const ValueKey('settings-account-manual-reset-button')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('settings-manual-bridge-save-button')), + findsNothing, + ); + + await tester.tap( + find.byKey(const ValueKey('settings-account-manual-reset-button')), + ); + await tester.pump(); + + expect(saveCount, 0); + expect(resetCount, 1); }, ); @@ -653,6 +743,7 @@ void main() { onVerifyMfa: () async {}, onCancelMfa: () async {}, onSync: () async {}, + onResetManualBridge: () async {}, onLogout: () async {}, ), ), diff --git a/test/runtime/assistant_connection_state_test.dart b/test/runtime/assistant_connection_state_test.dart index 54f2bfa3..95b2fb06 100644 --- a/test/runtime/assistant_connection_state_test.dart +++ b/test/runtime/assistant_connection_state_test.dart @@ -72,7 +72,7 @@ void main() { AssistantExecutionTarget.gateway, ], environmentOverride: const { - 'BRIDGE_AUTH_TOKEN': 'bridge-token', + 'AI_WORKSPACE_AUTH_TOKEN': 'bridge-token', }, ); addTearDown(controller.dispose); diff --git a/test/runtime/runtime_controllers_settings_account_test.dart b/test/runtime/runtime_controllers_settings_account_test.dart index 089b8887..794809fd 100644 --- a/test/runtime/runtime_controllers_settings_account_test.dart +++ b/test/runtime/runtime_controllers_settings_account_test.dart @@ -244,6 +244,7 @@ void main() { }, }, syncPayload: const { + 'AI_WORKSPACE_AUTH_TOKEN': 'ai-workspace-token-from-sync', 'BRIDGE_AUTH_TOKEN': 'bridge-token-from-sync', 'BRIDGE_SERVER_URL': 'https://xworkmate-bridge-alt.svc.plus', }, @@ -284,16 +285,18 @@ void main() { await store.loadAccountManagedSecret( target: kAccountManagedSecretTargetBridgeAuthToken, ), - 'bridge-token-from-sync', + 'ai-workspace-token-from-sync', ); expect( - controller.snapshot.toJsonString().contains('bridge-token-from-sync'), + controller.snapshot.toJsonString().contains( + 'ai-workspace-token-from-sync', + ), isFalse, ); expect( jsonEncode( controller.accountSyncState!.toJson(), - ).contains('bridge-token-from-sync'), + ).contains('ai-workspace-token-from-sync'), isFalse, ); }, @@ -789,6 +792,221 @@ void main() { ); }); + test( + 'manual bridge save accepts local loopback http with explicit token', + () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-manual-bridge-local-validation-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + await storeRoot.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + final controller = SettingsController(store); + addTearDown(controller.dispose); + await controller.initialize(); + + final nextSettings = await controller.buildSavedAccountProfileSettings( + settings: SettingsSnapshot.defaults(), + accountBaseUrl: '', + accountIdentifier: '', + bridgeServerUrl: 'http://127.0.0.1:8787', + bridgeToken: 'local-token', + isManualBridge: true, + ); + + expect( + nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl, + 'http://127.0.0.1:8787', + ); + expect( + nextSettings.acpBridgeServerModeConfig.effective.source, + 'bridge', + ); + expect( + await store.loadSecretValueByRef( + nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef, + ), + 'local-token', + ); + }, + ); + + test( + 'manual bridge save accepts localhost http with explicit token', + () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-manual-bridge-localhost-validation-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + await storeRoot.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + final controller = SettingsController(store); + addTearDown(controller.dispose); + await controller.initialize(); + + final nextSettings = await controller.buildSavedAccountProfileSettings( + settings: SettingsSnapshot.defaults(), + accountBaseUrl: '', + accountIdentifier: '', + bridgeServerUrl: 'http://localhost:8787', + bridgeToken: 'localhost-token', + isManualBridge: true, + ); + + expect( + nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl, + 'http://localhost:8787', + ); + expect( + nextSettings.acpBridgeServerModeConfig.effective.source, + 'bridge', + ); + expect( + await store.loadSecretValueByRef( + nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef, + ), + 'localhost-token', + ); + }, + ); + + test( + 'manual bridge save accepts public custom https bridge with token', + () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-manual-bridge-https-validation-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + await storeRoot.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + final controller = SettingsController(store); + addTearDown(controller.dispose); + await controller.initialize(); + + final nextSettings = await controller.buildSavedAccountProfileSettings( + settings: SettingsSnapshot.defaults(), + accountBaseUrl: '', + accountIdentifier: '', + bridgeServerUrl: 'https://private-bridge.example.com', + bridgeToken: 'public-token', + isManualBridge: true, + ); + + expect( + nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl, + 'https://private-bridge.example.com', + ); + expect( + nextSettings.acpBridgeServerModeConfig.effective.source, + 'bridge', + ); + expect( + await store.loadSecretValueByRef( + nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef, + ), + 'public-token', + ); + }, + ); + + test('manual bridge save rejects non-local http bridge url', () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-manual-bridge-http-reject-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + await storeRoot.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + final controller = SettingsController(store); + addTearDown(controller.dispose); + await controller.initialize(); + + expect( + controller.buildSavedAccountProfileSettings( + settings: SettingsSnapshot.defaults(), + accountBaseUrl: '', + accountIdentifier: '', + bridgeServerUrl: 'http://private-bridge.example.com:8787', + bridgeToken: 'token', + isManualBridge: true, + ), + throwsArgumentError, + ); + }); + + test('manual bridge save requires token authentication', () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-manual-bridge-token-required-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + await storeRoot.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + final controller = SettingsController(store); + addTearDown(controller.dispose); + await controller.initialize(); + + expect( + controller.buildSavedAccountProfileSettings( + settings: SettingsSnapshot.defaults(), + accountBaseUrl: '', + accountIdentifier: '', + bridgeServerUrl: 'http://127.0.0.1:8787', + bridgeToken: '', + isManualBridge: true, + ), + throwsArgumentError, + ); + }); + test( 'syncAccountSettings succeeds when bridge url metadata is missing', () async {