feat(settings): add manual bridge configuration tab to account settings

This commit is contained in:
Haitao Pan 2026-04-17 16:28:27 +08:00
parent f5b3d85a89
commit 8cc662e7d4
5 changed files with 266 additions and 15 deletions

View File

@ -637,10 +637,17 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
Uri? resolveBridgeAcpEndpointInternal() {
final explicitBridgeServerUrl =
runtimeEnvironmentValueInternal('BRIDGE_SERVER_URL')?.trim() ?? '';
final candidate = isSupportedExternalAcpEndpoint(explicitBridgeServerUrl)
? explicitBridgeServerUrl
if (isSupportedExternalAcpEndpoint(explicitBridgeServerUrl)) {
final uri = Uri.tryParse(explicitBridgeServerUrl);
if (uri != null) {
return uri.replace(query: null, fragment: null);
}
}
final modeConfig = settings.acpBridgeServerModeConfig;
final candidate = modeConfig.mode == AcpBridgeServerMode.manual
? modeConfig.selfHosted.serverUrl.trim()
: kManagedBridgeServerUrl;
final uri = Uri.tryParse(candidate);
final uri = Uri.tryParse(candidate.isEmpty ? kManagedBridgeServerUrl : candidate);
final scheme = uri?.scheme.trim().toLowerCase() ?? '';
if (uri == null || !kSupportedExternalAcpEndpointSchemes.contains(scheme)) {
return null;
@ -688,6 +695,14 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
if (normalizedToken.isNotEmpty) {
return normalizedToken;
}
final modeConfig = settings.acpBridgeServerModeConfig;
if (modeConfig.mode == AcpBridgeServerMode.manual) {
final manualToken = await settingsControllerInternal
.loadSecretValueByRef(modeConfig.selfHosted.passwordRef);
if (manualToken.trim().isNotEmpty) {
return manualToken.trim();
}
}
}
final matchingGatewayProfileIndex =
gatewayProfileIndexMatchingEndpointInternal(endpoint);

View File

@ -17,6 +17,8 @@ class SettingsAccountPanel extends StatelessWidget {
required this.accountIdentifierController,
required this.accountPasswordController,
required this.accountMfaCodeController,
required this.bridgeUrlController,
required this.bridgeTokenController,
required this.onSaveAccountProfile,
required this.onLogin,
required this.onVerifyMfa,
@ -36,6 +38,8 @@ class SettingsAccountPanel extends StatelessWidget {
final TextEditingController accountIdentifierController;
final TextEditingController accountPasswordController;
final TextEditingController accountMfaCodeController;
final TextEditingController bridgeUrlController;
final TextEditingController bridgeTokenController;
final Future<void> Function() onSaveAccountProfile;
final Future<void> Function() onLogin;
final Future<void> Function() onVerifyMfa;
@ -46,13 +50,54 @@ class SettingsAccountPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (!accountSignedIn && !accountMfaRequired) {
return _SignedOutAccountPanel(
accountBusy: accountBusy,
accountBaseUrlController: accountBaseUrlController,
accountIdentifierController: accountIdentifierController,
accountPasswordController: accountPasswordController,
onSaveAccountProfile: onSaveAccountProfile,
onLogin: onLogin,
return DefaultTabController(
length: 2,
initialIndex: settings.acpBridgeServerModeConfig.mode ==
AcpBridgeServerMode.manual
? 1
: 0,
child: Column(
children: [
TabBar(
tabs: [
Tab(text: appText('svc.plus 云端同步', 'svc.plus Cloud Sync')),
Tab(text: appText('手动 Bridge 配置', 'Manual Bridge Config')),
],
onTap: (index) {
final mode = index == 1
? AcpBridgeServerMode.manual
: AcpBridgeServerMode.cloudSynced;
if (settings.acpBridgeServerModeConfig.mode != mode) {
onSaveAccountProfile(); // This should trigger a save with the new mode
}
},
),
const SizedBox(height: 24),
SizedBox(
height: 480,
child: TabBarView(
physics: const NeverScrollableScrollPhysics(),
children: [
_SignedOutAccountPanel(
accountBusy: accountBusy,
accountBaseUrlController: accountBaseUrlController,
accountIdentifierController: accountIdentifierController,
accountPasswordController: accountPasswordController,
onSaveAccountProfile: onSaveAccountProfile,
onLogin: onLogin,
),
_ManualBridgePanel(
settings: settings,
accountBusy: accountBusy,
bridgeUrlController: bridgeUrlController,
bridgeTokenController: bridgeTokenController,
onSaveAccountProfile: onSaveAccountProfile,
),
],
),
),
],
),
);
}
if (accountMfaRequired) {
@ -77,6 +122,93 @@ class SettingsAccountPanel extends StatelessWidget {
}
}
class _ManualBridgePanel extends StatelessWidget {
const _ManualBridgePanel({
required this.settings,
required this.accountBusy,
required this.bridgeUrlController,
required this.bridgeTokenController,
required this.onSaveAccountProfile,
});
final SettingsSnapshot settings;
final bool accountBusy;
final TextEditingController bridgeUrlController;
final TextEditingController bridgeTokenController;
final Future<void> Function() onSaveAccountProfile;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Icons.link_outlined,
size: 72,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
appText('手动 Bridge 配置', 'Manual Bridge Config'),
style: theme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Text(
appText(
'直接配置本地或私有 xworkmate-bridge 地址与令牌。',
'Configure local or private xworkmate-bridge address and token directly.',
),
style: theme.textTheme.titleMedium?.copyWith(
color: theme.textTheme.bodyMedium?.color?.withValues(
alpha: 0.8,
),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 28),
TextFormField(
key: const ValueKey('settings-manual-bridge-url-field'),
controller: bridgeUrlController,
decoration: InputDecoration(
labelText: appText('Bridge 地址', 'Bridge URL'),
prefixIcon: const Icon(Icons.dns_outlined),
hintText: 'https://xworkmate-bridge.svc.plus',
),
onFieldSubmitted: (_) => onSaveAccountProfile(),
),
const SizedBox(height: 16),
TextFormField(
key: const ValueKey('settings-manual-bridge-token-field'),
controller: bridgeTokenController,
obscureText: true,
decoration: InputDecoration(
labelText: appText('鉴权令牌 (TOKEN)', 'Auth Token'),
prefixIcon: const Icon(Icons.key_outlined),
),
onFieldSubmitted: (_) => onSaveAccountProfile(),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
key: const ValueKey('settings-manual-bridge-save-button'),
onPressed: accountBusy ? null : () => onSaveAccountProfile(),
child: Text(appText('保存配置', 'Save Configuration')),
),
),
],
),
),
);
}
}
class _SignedOutAccountPanel extends StatelessWidget {
const _SignedOutAccountPanel({
required this.accountBusy,

View File

@ -40,10 +40,13 @@ class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController _accountIdentifierController;
late final TextEditingController _accountPasswordController;
late final TextEditingController _accountMfaCodeController;
late final TextEditingController _bridgeUrlController;
late final TextEditingController _bridgeTokenController;
SettingsAboutSnapshot _aboutSnapshot = const SettingsAboutSnapshot.defaults();
bool _aboutBusy = false;
String _lastSavedAccountBaseUrl = '';
String _lastSavedAccountIdentifier = '';
String _lastSavedBridgeUrl = '';
@override
void initState() {
@ -51,6 +54,7 @@ class _SettingsPageState extends State<SettingsPage> {
final settings = widget.controller.settings;
_lastSavedAccountBaseUrl = settings.accountBaseUrl;
_lastSavedAccountIdentifier = settings.accountUsername;
_lastSavedBridgeUrl = settings.acpBridgeServerModeConfig.selfHosted.serverUrl;
_accountBaseUrlController = TextEditingController(
text: _lastSavedAccountBaseUrl,
);
@ -59,7 +63,10 @@ class _SettingsPageState extends State<SettingsPage> {
);
_accountPasswordController = TextEditingController();
_accountMfaCodeController = TextEditingController();
_bridgeUrlController = TextEditingController(text: _lastSavedBridgeUrl);
_bridgeTokenController = TextEditingController();
unawaited(_refreshAboutSnapshot());
unawaited(_loadBridgeToken());
}
@override
@ -69,9 +76,19 @@ class _SettingsPageState extends State<SettingsPage> {
_accountIdentifierController.dispose();
_accountPasswordController.dispose();
_accountMfaCodeController.dispose();
_bridgeUrlController.dispose();
_bridgeTokenController.dispose();
super.dispose();
}
Future<void> _loadBridgeToken() async {
final token = await widget.controller.settingsController
.loadSecretValueByRef(widget.controller.settings.acpBridgeServerModeConfig.selfHosted.passwordRef);
if (mounted) {
_bridgeTokenController.text = token;
}
}
void _syncAccountControllers(SettingsSnapshot settings) {
if (_accountBaseUrlController.text == _lastSavedAccountBaseUrl &&
settings.accountBaseUrl != _lastSavedAccountBaseUrl) {
@ -83,16 +100,41 @@ class _SettingsPageState extends State<SettingsPage> {
}
_lastSavedAccountBaseUrl = settings.accountBaseUrl;
_lastSavedAccountIdentifier = settings.accountUsername;
final bridgeConfig = settings.acpBridgeServerModeConfig;
if (_bridgeUrlController.text == _lastSavedBridgeUrl &&
bridgeConfig.selfHosted.serverUrl != _lastSavedBridgeUrl) {
_bridgeUrlController.text = bridgeConfig.selfHosted.serverUrl;
}
_lastSavedBridgeUrl = bridgeConfig.selfHosted.serverUrl;
}
Future<void> _saveAccountProfile(SettingsSnapshot settings) async {
final bridgeConfig = settings.acpBridgeServerModeConfig;
final isManual = DefaultTabController.of(context).index == 1;
final nextSettings = settings.copyWith(
accountBaseUrl: _accountBaseUrlController.text.trim(),
accountUsername: _accountIdentifierController.text.trim(),
acpBridgeServerModeConfig: bridgeConfig.copyWith(
mode: isManual ? AcpBridgeServerMode.manual : AcpBridgeServerMode.cloudSynced,
selfHosted: bridgeConfig.selfHosted.copyWith(
serverUrl: _bridgeUrlController.text.trim(),
),
),
);
await widget.controller.settingsController.saveSnapshot(nextSettings);
if (isManual && _bridgeTokenController.text.isNotEmpty) {
await widget.controller.settingsController.saveSecretValueByRef(
nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef,
_bridgeTokenController.text,
provider: 'Bridge',
module: 'Manual',
);
}
_lastSavedAccountBaseUrl = nextSettings.accountBaseUrl;
_lastSavedAccountIdentifier = nextSettings.accountUsername;
_lastSavedBridgeUrl = nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl;
}
Future<void> _loginAccount(SettingsSnapshot settings) async {
@ -300,6 +342,8 @@ class _SettingsPageState extends State<SettingsPage> {
accountIdentifierController: _accountIdentifierController,
accountPasswordController: _accountPasswordController,
accountMfaCodeController: _accountMfaCodeController,
bridgeUrlController: _bridgeUrlController,
bridgeTokenController: _bridgeTokenController,
onSaveAccountProfile: () =>
_saveAccountProfile(currentSettings),
onLogin: () => _loginAccount(currentSettings),

View File

@ -264,7 +264,16 @@ class AccountRemoteProfile {
}
}
enum AcpBridgeServerMode { cloudSynced }
enum AcpBridgeServerMode { cloudSynced, manual }
extension AcpBridgeServerModeCopy on AcpBridgeServerMode {
static AcpBridgeServerMode fromJsonValue(String? value) {
return AcpBridgeServerMode.values.firstWhere(
(item) => item.name == value,
orElse: () => AcpBridgeServerMode.cloudSynced,
);
}
}
class AcpBridgeServerRemoteServerSummary {
const AcpBridgeServerRemoteServerSummary({
@ -542,11 +551,11 @@ class AcpBridgeServerModeConfig {
);
}
bool get usesSelfHostedBase => false;
bool get usesSelfHostedBase => mode == AcpBridgeServerMode.manual;
bool get usesCloudSyncBase => true;
bool get usesCloudSyncBase => mode == AcpBridgeServerMode.cloudSynced;
String get sourceTag => 'cloudSynced';
String get sourceTag => mode.name;
Map<String, dynamic> toJson() {
return <String, dynamic>{
@ -559,7 +568,7 @@ class AcpBridgeServerModeConfig {
factory AcpBridgeServerModeConfig.fromJson(Map<String, dynamic> json) {
return AcpBridgeServerModeConfig(
mode: AcpBridgeServerMode.cloudSynced,
mode: AcpBridgeServerModeCopy.fromJsonValue(json['mode'] as String?),
cloudSynced: AcpBridgeServerCloudSyncConfig.fromJson(
(json['cloudSynced'] as Map?)?.cast<String, dynamic>() ?? const {},
),

View File

@ -247,6 +247,57 @@ void main() {
expect(header, 'gateway-token');
},
);
test(
'desktop bridge auth resolver resolves manual bridge token when configured',
() async {
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-acp-auth-bridge-manual-',
);
addTearDown(() async {
if (await storeRoot.exists()) {
try {
await storeRoot.delete(recursive: true);
} on FileSystemException {
// Temp cleanup is best effort here.
}
}
});
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 settings = SettingsSnapshot.defaults().copyWith(
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults().copyWith(
mode: AcpBridgeServerMode.manual,
selfHosted: AcpBridgeServerSelfHostedConfig.defaults().copyWith(
serverUrl: 'https://manual-bridge.example.com',
),
),
);
await store.saveSettingsSnapshot(settings);
await store.saveSecretValueByRef(
settings.acpBridgeServerModeConfig.selfHosted.passwordRef,
'manual-token',
);
final controller = AppController(store: store);
addTearDown(controller.dispose);
await controller.settingsControllerInternal.initialize();
final header = await controller
.resolveGatewayAcpAuthorizationHeaderInternal(
Uri.parse('https://manual-bridge.example.com/acp/rpc'),
);
expect(header, 'manual-token');
},
);
});
}