fix(settings): update account panel and assistant connection state

This commit is contained in:
Haitao Pan 2026-06-17 21:01:56 +08:00
parent ab0ecdd005
commit a353f6866f
7 changed files with 433 additions and 18 deletions

View File

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

View File

@ -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<void> Function() onVerifyMfa;
final Future<void> Function() onCancelMfa;
final Future<void> Function() onSync;
final Future<void> Function() onResetManualBridge;
final Future<void> Function() onLogout;
@override
@ -89,7 +91,11 @@ class _SettingsAccountPanelState extends State<SettingsAccountPanel>
@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<SettingsAccountPanel>
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<void> Function({required bool isManualBridge})
onSaveAccountProfile;
final Future<void> Function() onSync;
final Future<void> Function() onResetManualBridge;
final Future<void> 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;
}

View File

@ -324,6 +324,25 @@ class _SettingsPageState extends State<SettingsPage> {
await _refreshAboutSnapshot();
}
Future<void> _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<void> _refreshAboutSnapshot() async {
if (!mounted) {
return;
@ -514,6 +533,7 @@ class _SettingsPageState extends State<SettingsPage> {
_verifyAccountMfa(widget.controller.settings),
onCancelMfa: _cancelAccountMfa,
onSync: () => _syncAccount(widget.controller.settings),
onResetManualBridge: _resetManualBridge,
onLogout: _logoutAccount,
),
),

View File

@ -596,6 +596,10 @@ String _extractBridgeAuthTokenMetadata(Map<String, dynamic> 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<SettingsSnapshot> 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<SettingsSnapshot> buildSavedAccountProfileSettingsInternal(
effective: nextEffective,
),
);
final trimmedBridgeToken = bridgeToken.trim();
if (isManualBridge && trimmedBridgeToken.isNotEmpty) {
await controller.saveSecretValueByRef(
nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef,
@ -675,6 +693,53 @@ Future<SettingsSnapshot> 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:<port> or http://localhost:<port> for local mode, or https:// for public custom bridge mode',
);
}
int _parseExpiresAtMs(Object? value) {
if (value is int) {
return value;

View File

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

View File

@ -72,7 +72,7 @@ void main() {
AssistantExecutionTarget.gateway,
],
environmentOverride: const <String, String>{
'BRIDGE_AUTH_TOKEN': 'bridge-token',
'AI_WORKSPACE_AUTH_TOKEN': 'bridge-token',
},
);
addTearDown(controller.dispose);

View File

@ -244,6 +244,7 @@ void main() {
},
},
syncPayload: const <String, dynamic>{
'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 {