feat(settings): add manual bridge configuration tab to account settings
This commit is contained in:
parent
f5b3d85a89
commit
8cc662e7d4
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 {},
|
||||
),
|
||||
|
||||
@ -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');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user