diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 46364a6f..a912061d 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -1117,6 +1117,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController { return null; } + final manualBridgeToken = await _resolveManualBridgeAuthTokenInternal(); + if (manualBridgeToken != null && manualBridgeToken.isNotEmpty) { + return _normalizeAuthorizationHeaderInternal(manualBridgeToken); + } final bridgeToken = await _resolveManagedBridgeAuthTokenInternal(); if (bridgeToken != null && bridgeToken.isNotEmpty) { return _normalizeAuthorizationHeaderInternal(bridgeToken); diff --git a/lib/features/mobile/mobile_settings_page.dart b/lib/features/mobile/mobile_settings_page.dart index f898ba60..ce87270b 100644 --- a/lib/features/mobile/mobile_settings_page.dart +++ b/lib/features/mobile/mobile_settings_page.dart @@ -104,38 +104,27 @@ class _MobileSettingsPageState extends State { required bool isManualBridge, bool refreshAfterSave = true, }) async { - final bridgeConfig = settings.acpBridgeServerModeConfig; - final nextBridgeConfig = bridgeConfig.copyWith( - selfHosted: bridgeConfig.selfHosted.copyWith( - serverUrl: bridgeUrlController.text.trim(), - username: isManualBridge ? 'admin' : bridgeConfig.selfHosted.username, - ), - ); - final nextEffective = widget.controller.settingsController - .resolveAcpBridgeServerEffectiveConfig(config: nextBridgeConfig); - final nextSettings = settings.copyWith( - accountBaseUrl: accountBaseUrlController.text.trim(), - accountUsername: accountIdentifierController.text.trim(), - acpBridgeServerModeConfig: nextBridgeConfig.copyWith( - effective: nextEffective, - ), - ); - if (isManualBridge && bridgeTokenController.text.isNotEmpty) { - await widget.controller.settingsController.saveSecretValueByRef( - nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef, - bridgeTokenController.text, - provider: 'Bridge', - module: 'Manual', - ); - } + final nextSettings = await widget.controller.settingsController + .buildSavedAccountProfileSettings( + settings: settings, + accountBaseUrl: accountBaseUrlController.text, + accountIdentifier: accountIdentifierController.text, + bridgeServerUrl: bridgeUrlController.text, + bridgeToken: bridgeTokenController.text, + isManualBridge: isManualBridge, + ); await widget.controller.saveSettings( nextSettings, - refreshAfterSave: refreshAfterSave, + refreshAfterSave: isManualBridge ? false : refreshAfterSave, ); lastSavedAccountBaseUrl = nextSettings.accountBaseUrl; lastSavedAccountIdentifier = nextSettings.accountUsername; lastSavedBridgeUrl = nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl; + if (isManualBridge && + nextSettings.acpBridgeServerModeConfig.selfHosted.isConfigured) { + unawaited(refreshBridgeCapabilities()); + } } Future loginAccount(SettingsSnapshot settings) async { @@ -245,16 +234,25 @@ class _MobileSettingsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( - onTap: () => controller.navigateTo(WorkspaceDestination.assistant), + onTap: () => controller.navigateTo( + WorkspaceDestination.assistant, + ), behavior: HitTestBehavior.opaque, child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.arrow_back_ios_new_rounded, size: 16, color: palette.textSecondary), + Icon( + Icons.arrow_back_ios_new_rounded, + size: 16, + color: palette.textSecondary, + ), const SizedBox(width: 6), Text( appText('返回对话主页', 'Back to Chat'), - style: TextStyle(color: palette.textSecondary, fontSize: 16), + style: TextStyle( + color: palette.textSecondary, + fontSize: 16, + ), ), ], ), @@ -262,9 +260,8 @@ class _MobileSettingsPageState extends State { const SizedBox(height: 24), Text( appText('设置', 'Settings'), - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 12), if (availableTabs.length > 1) ...[ diff --git a/lib/features/settings/settings_account_panel.dart b/lib/features/settings/settings_account_panel.dart index 9b78ab95..d413ca7a 100644 --- a/lib/features/settings/settings_account_panel.dart +++ b/lib/features/settings/settings_account_panel.dart @@ -101,9 +101,6 @@ class _SettingsAccountPanelState extends State Tab(text: appText('svc.plus 云端同步', 'svc.plus Cloud Sync')), Tab(text: appText('手动 Bridge 配置', 'Manual Bridge Config')), ], - onTap: (index) { - widget.onSaveAccountProfile(isManualBridge: index == 1); - }, ), const SizedBox(height: 24), SizedBox( diff --git a/lib/features/settings/settings_page_core.dart b/lib/features/settings/settings_page_core.dart index 4282e586..64a641bf 100644 --- a/lib/features/settings/settings_page_core.dart +++ b/lib/features/settings/settings_page_core.dart @@ -220,34 +220,20 @@ class _SettingsPageState extends State { SettingsSnapshot settings, { required bool isManualBridge, }) async { - final bridgeConfig = settings.acpBridgeServerModeConfig; - var nextBridgeConfig = bridgeConfig.copyWith( - selfHosted: bridgeConfig.selfHosted.copyWith( - serverUrl: _bridgeUrlController.text.trim(), - username: isManualBridge ? 'admin' : bridgeConfig.selfHosted.username, - ), + final nextSettings = await widget.controller.settingsController + .buildSavedAccountProfileSettings( + settings: settings, + accountBaseUrl: _accountBaseUrlController.text, + accountIdentifier: _accountIdentifierController.text, + bridgeServerUrl: _bridgeUrlController.text, + bridgeToken: _bridgeTokenController.text, + isManualBridge: isManualBridge, + ); + await widget.controller.saveSettings( + nextSettings, + refreshAfterSave: !isManualBridge, ); - final nextEffective = widget.controller.settingsController - .resolveAcpBridgeServerEffectiveConfig(config: nextBridgeConfig); - - final nextSettings = settings.copyWith( - accountBaseUrl: _accountBaseUrlController.text.trim(), - accountUsername: _accountIdentifierController.text.trim(), - acpBridgeServerModeConfig: nextBridgeConfig.copyWith( - effective: nextEffective, - ), - ); - if (isManualBridge && _bridgeTokenController.text.isNotEmpty) { - await widget.controller.settingsController.saveSecretValueByRef( - nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef, - _bridgeTokenController.text, - provider: 'Bridge', - module: 'Manual', - ); - } - await widget.controller.saveSettings(nextSettings); - _lastSavedAccountBaseUrl = nextSettings.accountBaseUrl; _lastSavedAccountIdentifier = nextSettings.accountUsername; _lastSavedBridgeUrl = diff --git a/lib/runtime/runtime_controllers_settings_account.dart b/lib/runtime/runtime_controllers_settings_account.dart index 9cb76f28..8aaee25b 100644 --- a/lib/runtime/runtime_controllers_settings_account.dart +++ b/lib/runtime/runtime_controllers_settings_account.dart @@ -120,6 +120,23 @@ extension SettingsControllerAccountExtension on SettingsController { required AcpBridgeServerModeConfig config, }) => resolveAcpBridgeServerEffectiveConfigInternal(this, config: config); + Future buildSavedAccountProfileSettings({ + required SettingsSnapshot settings, + required String accountBaseUrl, + required String accountIdentifier, + required String bridgeServerUrl, + required String bridgeToken, + required bool isManualBridge, + }) => buildSavedAccountProfileSettingsInternal( + this, + settings: settings, + accountBaseUrl: accountBaseUrl, + accountIdentifier: accountIdentifier, + bridgeServerUrl: bridgeServerUrl, + bridgeToken: bridgeToken, + isManualBridge: isManualBridge, + ); + List buildSecretReferences() { final entries = [ ...secureRefsInternal.entries.map( diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart index ed6b1d0a..04314cc3 100644 --- a/lib/runtime/runtime_controllers_settings_account_impl.dart +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -612,6 +612,47 @@ AcpBridgeServerEffectiveConfig resolveAcpBridgeServerEffectiveConfigInternal( ); } +Future buildSavedAccountProfileSettingsInternal( + SettingsController controller, { + required SettingsSnapshot settings, + required String accountBaseUrl, + required String accountIdentifier, + required String bridgeServerUrl, + required String bridgeToken, + required bool isManualBridge, +}) async { + final bridgeConfig = settings.acpBridgeServerModeConfig; + final nextBridgeConfig = bridgeConfig.copyWith( + selfHosted: isManualBridge + ? bridgeConfig.selfHosted.copyWith( + serverUrl: bridgeServerUrl.trim(), + username: 'admin', + ) + : bridgeConfig.selfHosted, + ); + final nextEffective = resolveAcpBridgeServerEffectiveConfigInternal( + controller, + config: nextBridgeConfig, + ); + final nextSettings = settings.copyWith( + accountBaseUrl: accountBaseUrl.trim(), + accountUsername: accountIdentifier.trim(), + acpBridgeServerModeConfig: nextBridgeConfig.copyWith( + effective: nextEffective, + ), + ); + final trimmedBridgeToken = bridgeToken.trim(); + if (isManualBridge && trimmedBridgeToken.isNotEmpty) { + await controller.saveSecretValueByRef( + nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef, + trimmedBridgeToken, + provider: 'Bridge', + module: 'Manual', + ); + } + return nextSettings; +} + int _parseExpiresAtMs(Object? value) { if (value is int) { return value; diff --git a/test/features/mobile/mobile_settings_page_test.dart b/test/features/mobile/mobile_settings_page_test.dart index 615880e8..30bdfae1 100644 --- a/test/features/mobile/mobile_settings_page_test.dart +++ b/test/features/mobile/mobile_settings_page_test.dart @@ -5,6 +5,8 @@ import 'package:xworkmate/app/app_shell_desktop.dart'; import 'package:xworkmate/features/mobile/mobile_settings_page.dart'; import 'package:xworkmate/runtime/account_runtime_client.dart'; import 'package:xworkmate/runtime/runtime_controllers.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; import 'package:xworkmate/theme/app_theme.dart'; void main() { @@ -239,6 +241,78 @@ void main() { ); expect(find.text('mobile@svc.plus'), findsOneWidget); }); + + testWidgets('manual bridge save updates mobile runtime configuration', ( + tester, + ) async { + final store = _MemorySecureConfigStore(); + final controller = _NoopRefreshAppController(store: store); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light().copyWith(platform: TargetPlatform.iOS), + home: MediaQuery( + data: const MediaQueryData(size: Size(390, 844)), + child: Scaffold(body: MobileSettingsPage(controller: controller)), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 250)); + + final urlField = find.byKey( + const Key('mobile-settings-manual-bridge-url-field'), + ); + await tester.ensureVisible(urlField); + await tester.enterText( + find.descendant(of: urlField, matching: find.byType(TextFormField)), + 'http://127.0.0.1:1', + ); + final tokenField = find.byKey( + const Key('mobile-settings-manual-bridge-token-field'), + ); + await tester.enterText( + find.descendant(of: tokenField, matching: find.byType(TextFormField)), + 'mobile-manual-token', + ); + final saveButton = find.byKey( + const Key('mobile-settings-manual-bridge-save-button'), + ); + await tester.ensureVisible(saveButton); + tester.widget(saveButton).onPressed!(); + + for ( + var attempt = 0; + attempt < 20 && + controller + .settings + .acpBridgeServerModeConfig + .selfHosted + .serverUrl != + 'http://127.0.0.1:1'; + attempt += 1 + ) { + await tester.pump(const Duration(milliseconds: 50)); + } + + final bridgeConfig = controller.settings.acpBridgeServerModeConfig; + expect(bridgeConfig.selfHosted.serverUrl, 'http://127.0.0.1:1'); + expect(bridgeConfig.effective.source, 'bridge'); + expect( + await store.loadSecretValueByRef(bridgeConfig.selfHosted.passwordRef), + 'mobile-manual-token', + ); + expect( + controller.resolveGatewayAcpEndpointInternal()?.toString(), + 'http://127.0.0.1:1', + ); + expect( + await controller.resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('http://127.0.0.1:1/acp/rpc'), + ), + 'mobile-manual-token', + ); + }); }); } @@ -278,3 +352,63 @@ class _MobileFakeAccountRuntimeClient extends AccountRuntimeClient { return syncPayload; } } + +class _NoopRefreshAppController extends AppController { + _NoopRefreshAppController({required SecureConfigStore store}) + : super(environmentOverride: const {}, store: store); + + Future refreshAcpCapabilitiesInternal({ + bool forceRefresh = false, + bool persistMountTargets = false, + }) async {} + + Future refreshSingleAgentCapabilitiesInternal({ + bool forceRefresh = false, + }) async {} +} + +class _MemorySecureConfigStore extends SecureConfigStore { + _MemorySecureConfigStore() : super(enableSecureStorage: false); + + SettingsSnapshot _settings = SettingsSnapshot.defaults(); + final Map _secrets = {}; + + @override + Future initialize() async {} + + @override + Future loadSettingsSnapshot() async => _settings; + + @override + Future saveSettingsSnapshot(SettingsSnapshot snapshot) async { + _settings = snapshot; + } + + @override + Future> loadSecureRefs() async => _secrets; + + @override + Future> loadAuditTrail() async => + const []; + + @override + Future appendAudit(SecretAuditEntry entry) async {} + + @override + Future loadSecretValueByRef(String refName) async => + _secrets[refName]; + + @override + Future saveSecretValueByRef(String refName, String value) async { + _secrets[refName] = value; + } + + @override + Future loadAccountSessionToken() async => null; + + @override + Future loadAccountSessionSummary() async => null; + + @override + Future loadAccountSyncState() async => null; +} diff --git a/test/features/settings/settings_account_panel_test.dart b/test/features/settings/settings_account_panel_test.dart index 251f1a94..bfca15a0 100644 --- a/test/features/settings/settings_account_panel_test.dart +++ b/test/features/settings/settings_account_panel_test.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/features/settings/settings_account_panel.dart'; +import 'package:xworkmate/runtime/runtime_controllers.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; import 'package:xworkmate/theme/app_theme.dart'; import 'package:xworkmate/widgets/surface_card.dart'; @@ -173,6 +176,144 @@ void main() { expect(savedBridgeToken, 'typed-manual-token'); }); + testWidgets('switching to manual bridge tab does not save draft values', ( + tester, + ) async { + final controllers = _TestControllers(); + addTearDown(controllers.dispose); + + var saveCount = 0; + + await tester.pumpWidget( + _buildTestApp( + child: SettingsAccountPanel( + settings: SettingsSnapshot.defaults(), + 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 {}, + onLogout: () async {}, + ), + ), + ); + + await tester.tap(find.text('手动 Bridge 配置')); + await tester.pump(); + + expect(saveCount, 0); + }); + + testWidgets('desktop manual bridge save updates runtime configuration', ( + tester, + ) async { + final controllers = _TestControllers(); + addTearDown(controllers.dispose); + final store = _MemorySecureConfigStore(); + final controller = _NoopRefreshAppController(store: store); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _buildTestApp( + child: SettingsAccountPanel( + settings: controller.settings, + 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 { + final nextSettings = await controller.settingsController + .buildSavedAccountProfileSettings( + settings: controller.settings, + accountBaseUrl: controllers.baseUrl.text, + accountIdentifier: controllers.identifier.text, + bridgeServerUrl: controllers.bridgeUrl.text, + bridgeToken: controllers.bridgeToken.text, + isManualBridge: isManualBridge, + ); + await controller.saveSettings( + nextSettings, + refreshAfterSave: false, + ); + }, + onLogin: () async {}, + onVerifyMfa: () async {}, + onCancelMfa: () async {}, + onSync: () async {}, + onLogout: () async {}, + ), + ), + ); + + await tester.tap(find.text('手动 Bridge 配置')); + await tester.pump(); + await tester.enterText( + find.byKey(const ValueKey('settings-manual-bridge-url-field')), + 'http://127.0.0.1:1', + ); + await tester.enterText( + find.byKey(const ValueKey('settings-manual-bridge-token-field')), + 'typed-manual-token', + ); + await tester.tap( + find.byKey(const ValueKey('settings-manual-bridge-save-button')), + ); + + for ( + var attempt = 0; + attempt < 20 && + controller + .settings + .acpBridgeServerModeConfig + .selfHosted + .serverUrl != + 'http://127.0.0.1:1'; + attempt += 1 + ) { + await tester.pump(const Duration(milliseconds: 50)); + } + + final bridgeConfig = controller.settings.acpBridgeServerModeConfig; + expect(bridgeConfig.selfHosted.serverUrl, 'http://127.0.0.1:1'); + expect(bridgeConfig.selfHosted.username, 'admin'); + expect(bridgeConfig.effective.source, 'bridge'); + expect(bridgeConfig.effective.endpoint, 'http://127.0.0.1:1'); + expect( + await store.loadSecretValueByRef(bridgeConfig.selfHosted.passwordRef), + 'typed-manual-token', + ); + expect( + controller.resolveGatewayAcpEndpointInternal()?.toString(), + 'http://127.0.0.1:1', + ); + expect( + await controller.resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('http://127.0.0.1:1/acp/rpc'), + ), + 'typed-manual-token', + ); + }); + testWidgets( 'shows account sync status, resync, and exit in signed-in mode', (tester) async { @@ -562,3 +703,63 @@ class _TestControllers { bridgeToken.dispose(); } } + +class _NoopRefreshAppController extends AppController { + _NoopRefreshAppController({required SecureConfigStore store}) + : super(environmentOverride: const {}, store: store); + + Future refreshAcpCapabilitiesInternal({ + bool forceRefresh = false, + bool persistMountTargets = false, + }) async {} + + Future refreshSingleAgentCapabilitiesInternal({ + bool forceRefresh = false, + }) async {} +} + +class _MemorySecureConfigStore extends SecureConfigStore { + _MemorySecureConfigStore() : super(enableSecureStorage: false); + + SettingsSnapshot _settings = SettingsSnapshot.defaults(); + final Map _secrets = {}; + + @override + Future initialize() async {} + + @override + Future loadSettingsSnapshot() async => _settings; + + @override + Future saveSettingsSnapshot(SettingsSnapshot snapshot) async { + _settings = snapshot; + } + + @override + Future> loadSecureRefs() async => _secrets; + + @override + Future> loadAuditTrail() async => + const []; + + @override + Future appendAudit(SecretAuditEntry entry) async {} + + @override + Future loadSecretValueByRef(String refName) async => + _secrets[refName]; + + @override + Future saveSecretValueByRef(String refName, String value) async { + _secrets[refName] = value; + } + + @override + Future loadAccountSessionToken() async => null; + + @override + Future loadAccountSessionSummary() async => null; + + @override + Future loadAccountSyncState() async => null; +} diff --git a/test/runtime/bridge_runtime_cleanup_test.dart b/test/runtime/bridge_runtime_cleanup_test.dart index 62799322..4562c9e6 100644 --- a/test/runtime/bridge_runtime_cleanup_test.dart +++ b/test/runtime/bridge_runtime_cleanup_test.dart @@ -168,6 +168,81 @@ void main() { }, ); + test( + 'manual bridge token authorizes runtime and artifact requests only for manual endpoint', + () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-manual-bridge-artifact-auth-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + try { + await storeRoot.delete(recursive: true); + } on FileSystemException { + // Temp cleanup is best effort here. The controller may still be + // releasing files when teardown starts. + } + } + }); + + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults() + .copyWith( + selfHosted: AcpBridgeServerModeConfig.defaults().selfHosted + .copyWith( + serverUrl: 'https://private-bridge.svc.plus', + username: 'admin', + ), + ), + ), + ); + await store.saveSecretValueByRef( + AcpBridgeServerSelfHostedConfig.defaults().passwordRef, + 'manual-bridge-token', + ); + + final controller = AppController( + environmentOverride: const {}, + store: store, + ); + addTearDown(controller.dispose); + await controller.settingsControllerInternal.initialize(); + + expect( + await controller.resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('https://private-bridge.svc.plus/acp/rpc'), + ), + 'manual-bridge-token', + ); + expect( + await controller.resolveBridgeArtifactAuthorizationHeaderInternal( + Uri.parse('https://private-bridge.svc.plus/artifacts/file.pdf'), + ), + 'Bearer manual-bridge-token', + ); + expect( + await controller.resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('$kManagedBridgeServerUrl/acp/rpc'), + ), + isNull, + ); + expect( + await controller.resolveBridgeArtifactAuthorizationHeaderInternal( + Uri.parse('$kManagedBridgeServerUrl/artifacts/file.pdf'), + ), + isNull, + ); + }, + ); + test( 'runtime coordinator only exposes remote and offline gateway modes', () {