Fix manual bridge save runtime config

This commit is contained in:
Haitao Pan 2026-06-01 13:11:30 +08:00
parent 22a0376b00
commit c2128fe5da
9 changed files with 512 additions and 60 deletions

View File

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

View File

@ -104,38 +104,27 @@ class _MobileSettingsPageState extends State<MobileSettingsPage> {
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<void> loginAccount(SettingsSnapshot settings) async {
@ -245,16 +234,25 @@ class _MobileSettingsPageState extends State<MobileSettingsPage> {
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<MobileSettingsPage> {
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) ...[

View File

@ -101,9 +101,6 @@ class _SettingsAccountPanelState extends State<SettingsAccountPanel>
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(

View File

@ -220,34 +220,20 @@ class _SettingsPageState extends State<SettingsPage> {
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 =

View File

@ -120,6 +120,23 @@ extension SettingsControllerAccountExtension on SettingsController {
required AcpBridgeServerModeConfig config,
}) => resolveAcpBridgeServerEffectiveConfigInternal(this, config: config);
Future<SettingsSnapshot> 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<SecretReferenceEntry> buildSecretReferences() {
final entries = <SecretReferenceEntry>[
...secureRefsInternal.entries.map(

View File

@ -612,6 +612,47 @@ AcpBridgeServerEffectiveConfig resolveAcpBridgeServerEffectiveConfigInternal(
);
}
Future<SettingsSnapshot> 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;

View File

@ -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<FilledButton>(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 <String, String>{}, store: store);
Future<void> refreshAcpCapabilitiesInternal({
bool forceRefresh = false,
bool persistMountTargets = false,
}) async {}
Future<void> refreshSingleAgentCapabilitiesInternal({
bool forceRefresh = false,
}) async {}
}
class _MemorySecureConfigStore extends SecureConfigStore {
_MemorySecureConfigStore() : super(enableSecureStorage: false);
SettingsSnapshot _settings = SettingsSnapshot.defaults();
final Map<String, String> _secrets = <String, String>{};
@override
Future<void> initialize() async {}
@override
Future<SettingsSnapshot> loadSettingsSnapshot() async => _settings;
@override
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) async {
_settings = snapshot;
}
@override
Future<Map<String, String>> loadSecureRefs() async => _secrets;
@override
Future<List<SecretAuditEntry>> loadAuditTrail() async =>
const <SecretAuditEntry>[];
@override
Future<void> appendAudit(SecretAuditEntry entry) async {}
@override
Future<String?> loadSecretValueByRef(String refName) async =>
_secrets[refName];
@override
Future<void> saveSecretValueByRef(String refName, String value) async {
_secrets[refName] = value;
}
@override
Future<String?> loadAccountSessionToken() async => null;
@override
Future<AccountSessionSummary?> loadAccountSessionSummary() async => null;
@override
Future<AccountSyncState?> loadAccountSyncState() async => null;
}

View File

@ -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 <String, String>{}, store: store);
Future<void> refreshAcpCapabilitiesInternal({
bool forceRefresh = false,
bool persistMountTargets = false,
}) async {}
Future<void> refreshSingleAgentCapabilitiesInternal({
bool forceRefresh = false,
}) async {}
}
class _MemorySecureConfigStore extends SecureConfigStore {
_MemorySecureConfigStore() : super(enableSecureStorage: false);
SettingsSnapshot _settings = SettingsSnapshot.defaults();
final Map<String, String> _secrets = <String, String>{};
@override
Future<void> initialize() async {}
@override
Future<SettingsSnapshot> loadSettingsSnapshot() async => _settings;
@override
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) async {
_settings = snapshot;
}
@override
Future<Map<String, String>> loadSecureRefs() async => _secrets;
@override
Future<List<SecretAuditEntry>> loadAuditTrail() async =>
const <SecretAuditEntry>[];
@override
Future<void> appendAudit(SecretAuditEntry entry) async {}
@override
Future<String?> loadSecretValueByRef(String refName) async =>
_secrets[refName];
@override
Future<void> saveSecretValueByRef(String refName, String value) async {
_secrets[refName] = value;
}
@override
Future<String?> loadAccountSessionToken() async => null;
@override
Future<AccountSessionSummary?> loadAccountSessionSummary() async => null;
@override
Future<AccountSyncState?> loadAccountSyncState() async => null;
}

View File

@ -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 <String, String>{},
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',
() {