fix: unify bridge auth token for desktop connect

This commit is contained in:
Haitao Pan 2026-06-06 19:24:48 +08:00
parent b7a842fce3
commit 20257f392e
5 changed files with 343 additions and 203 deletions

View File

@ -5,6 +5,7 @@ import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'desktop_client.dart';
import 'desktop_input_handler.dart';
import '../../app/app_controller.dart';
import '../../runtime/gateway_acp_client.dart';
import '../../widgets/surface_card.dart';
import '../../i18n/app_language.dart';
@ -131,7 +132,7 @@ class _DesktopViewState extends State<DesktopView> {
final display = _displayController.text.trim();
int width = int.tryParse(_widthController.text) ?? 1280;
int height = int.tryParse(_heightController.text) ?? 720;
if (_adaptiveResolution) {
final viewportSize = _getViewportSize();
if (viewportSize.width > 0 && viewportSize.height > 0) {
@ -141,7 +142,7 @@ class _DesktopViewState extends State<DesktopView> {
_heightController.text = height.toString();
}
}
final fps = int.tryParse(_fpsController.text) ?? 30;
final bitrate = int.tryParse(_bitrateController.text) ?? 2000;
_remoteDesktopSize = Size(width.toDouble(), height.toDouble());
@ -155,6 +156,21 @@ class _DesktopViewState extends State<DesktopView> {
bitrate: bitrate,
useGpu: _useGpu,
);
} on GatewayAcpException catch (error) {
if (mounted) {
final message = _desktopConnectionErrorMessage(error);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
appText(
'连接AI工作空间失败: $message',
'Failed to connect AI Workspace: $message',
),
),
backgroundColor: Colors.redAccent,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@ -170,6 +186,17 @@ class _DesktopViewState extends State<DesktopView> {
}
}
String _desktopConnectionErrorMessage(GatewayAcpException error) {
final code = (error.code ?? '').trim().toUpperCase();
if (code == 'ACP_HTTP_401' || code == 'ACP_HTTP_403') {
return appText(
'Bridge 认证已过期或被拒绝,请点击“重新同步”后再连接。',
'Bridge authorization expired or was rejected. Please re-sync, then connect again.',
);
}
return error.message;
}
Size _getViewportSize() {
final renderBox =
_viewportKey.currentContext?.findRenderObject() as RenderBox?;
@ -194,114 +221,117 @@ class _DesktopViewState extends State<DesktopView> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Wrap(
spacing: 16,
runSpacing: 16,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
// Connection Button
ElevatedButton.icon(
onPressed: _toggleConnection,
style: ElevatedButton.styleFrom(
backgroundColor: _connectionState == 'connected'
? Colors.redAccent
: (_connectionState == 'connecting'
? Colors.orangeAccent
: theme.colorScheme.primary),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
icon: Icon(
_connectionState == 'connected'
? Icons.portable_wifi_off_rounded
: Icons.settings_remote_rounded,
),
label: Text(
_connectionState == 'connected'
? appText('断开连接', 'Disconnect')
: (_connectionState == 'connecting'
? appText('正在连接...', 'Connecting...')
: appText('连接AI工作空间', 'Connect AI Workspace')),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
// Status Indicator
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: _connectionState == 'connected'
? Colors.green.withValues(alpha: 0.15)
: (_connectionState == 'connecting'
? Colors.orange.withValues(alpha: 0.15)
: Colors.grey.withValues(alpha: 0.15)),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _connectionState == 'connected'
? Colors.green
Wrap(
spacing: 16,
runSpacing: 16,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
// Connection Button
ElevatedButton.icon(
onPressed: _toggleConnection,
style: ElevatedButton.styleFrom(
backgroundColor: _connectionState == 'connected'
? Colors.redAccent
: (_connectionState == 'connecting'
? Colors.orange
: Colors.grey),
width: 1,
? Colors.orangeAccent
: theme.colorScheme.primary),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
icon: Icon(
_connectionState == 'connected'
? Icons.portable_wifi_off_rounded
: Icons.settings_remote_rounded,
),
label: Text(
_connectionState == 'connected'
? appText('断开连接', 'Disconnect')
: (_connectionState == 'connecting'
? appText('正在连接...', 'Connecting...')
: appText(
'连接AI工作空间',
'Connect AI Workspace',
)),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _connectionState == 'connected'
? Colors.green
: (_connectionState == 'connecting'
? Colors.orange
: Colors.grey),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
_connectionState == 'connected'
? appText('已连接', 'Connected')
// Status Indicator
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: _connectionState == 'connected'
? Colors.green.withValues(alpha: 0.15)
: (_connectionState == 'connecting'
? Colors.orange.withValues(alpha: 0.15)
: Colors.grey.withValues(alpha: 0.15)),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _connectionState == 'connected'
? Colors.green
: (_connectionState == 'connecting'
? appText('连接中', 'Connecting')
: appText('已断开', 'Disconnected')),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: _connectionState == 'connected'
? Colors.green
: (_connectionState == 'connecting'
? Colors.orange
: (isDark
? Colors.white70
: Colors.black87)),
),
? Colors.orange
: Colors.grey),
width: 1,
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _connectionState == 'connected'
? Colors.green
: (_connectionState == 'connecting'
? Colors.orange
: Colors.grey),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
_connectionState == 'connected'
? appText('已连接', 'Connected')
: (_connectionState == 'connecting'
? appText('连接中', 'Connecting')
: appText('已断开', 'Disconnected')),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: _connectionState == 'connected'
? Colors.green
: (_connectionState == 'connecting'
? Colors.orange
: (isDark
? Colors.white70
: Colors.black87)),
),
),
],
),
),
),
// Advanced Options Toggle
TextButton.icon(
onPressed: () => setState(
() => _showAdvancedOptions = !_showAdvancedOptions,
// Advanced Options Toggle
TextButton.icon(
onPressed: () => setState(
() => _showAdvancedOptions = !_showAdvancedOptions,
),
icon: Icon(
_showAdvancedOptions
? Icons.expand_less
: Icons.expand_more,
),
label: const Text('高级选项'),
),
icon: Icon(
_showAdvancedOptions
? Icons.expand_less
: Icons.expand_more,
),
label: const Text('高级选项'),
),
// Maximize Toggle
if (widget.onToggleMaximize != null)
IconButton(
@ -315,104 +345,120 @@ class _DesktopViewState extends State<DesktopView> {
),
// Collapse Toggle
IconButton(
onPressed: () => setState(() => _showControlPanel = false),
onPressed: () =>
setState(() => _showControlPanel = false),
icon: const Icon(Icons.expand_less),
tooltip: '折叠面板',
),
],
),
if (_showAdvancedOptions) ...[
const SizedBox(height: 16),
Wrap(
spacing: 16,
runSpacing: 16,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
// Display Selector
SizedBox(
width: 100,
child: TextField(
controller: _displayController,
enabled: _connectionState == 'disconnected',
decoration: const InputDecoration(
labelText: 'Display',
prefixIcon: Icon(Icons.monitor_rounded, size: 16),
if (_showAdvancedOptions) ...[
const SizedBox(height: 16),
Wrap(
spacing: 16,
runSpacing: 16,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
// Display Selector
SizedBox(
width: 100,
child: TextField(
controller: _displayController,
enabled: _connectionState == 'disconnected',
decoration: const InputDecoration(
labelText: 'Display',
prefixIcon: Icon(
Icons.monitor_rounded,
size: 16,
),
),
),
),
),
// Adaptive Resolution Toggle
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(appText('自适应分辨率', 'Adaptive Resolution')),
Switch(
value: _adaptiveResolution,
onChanged: _connectionState == 'disconnected'
? (val) => setState(() => _adaptiveResolution = val)
: null,
),
],
),
// Resolution settings
SizedBox(
width: 90,
child: TextField(
controller: _widthController,
enabled: _connectionState == 'disconnected' && !_adaptiveResolution,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '宽度'),
// Adaptive Resolution Toggle
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(appText('自适应分辨率', 'Adaptive Resolution')),
Switch(
value: _adaptiveResolution,
onChanged: _connectionState == 'disconnected'
? (val) => setState(
() => _adaptiveResolution = val,
)
: null,
),
],
),
),
SizedBox(
width: 90,
child: TextField(
controller: _heightController,
enabled: _connectionState == 'disconnected' && !_adaptiveResolution,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '高度'),
),
),
// FPS / Bitrate
SizedBox(
width: 70,
child: TextField(
controller: _fpsController,
enabled: _connectionState == 'disconnected',
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '帧率'),
),
),
SizedBox(
width: 90,
child: TextField(
controller: _bitrateController,
enabled: _connectionState == 'disconnected',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '码率 (kbps)',
// Resolution settings
SizedBox(
width: 90,
child: TextField(
controller: _widthController,
enabled:
_connectionState == 'disconnected' &&
!_adaptiveResolution,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '宽度',
),
),
),
),
// GPU accelerator toggle
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('GPU 加速'),
Switch(
value: _useGpu,
onChanged: _connectionState == 'disconnected'
? (val) => setState(() => _useGpu = val)
: null,
SizedBox(
width: 90,
child: TextField(
controller: _heightController,
enabled:
_connectionState == 'disconnected' &&
!_adaptiveResolution,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '高度',
),
),
],
),
],
),
),
// FPS / Bitrate
SizedBox(
width: 70,
child: TextField(
controller: _fpsController,
enabled: _connectionState == 'disconnected',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '帧率',
),
),
),
SizedBox(
width: 90,
child: TextField(
controller: _bitrateController,
enabled: _connectionState == 'disconnected',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '码率 (kbps)',
),
),
),
// GPU accelerator toggle
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('GPU 加速'),
Switch(
value: _useGpu,
onChanged: _connectionState == 'disconnected'
? (val) => setState(() => _useGpu = val)
: null,
),
],
),
],
),
],
],
],
),
),
),
),
if (!_showControlPanel)
Align(
@ -427,8 +473,7 @@ class _DesktopViewState extends State<DesktopView> {
),
),
if (_showControlPanel)
const SizedBox(height: 16),
if (_showControlPanel) const SizedBox(height: 16),
// Stream Viewport Card
Expanded(
@ -516,7 +561,8 @@ class _DesktopViewState extends State<DesktopView> {
},
child: RTCVideoView(
_localRenderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
objectFit: RTCVideoViewObjectFit
.RTCVideoViewObjectFitContain,
filterQuality: FilterQuality.medium,
),
),
@ -541,8 +587,14 @@ class _DesktopViewState extends State<DesktopView> {
const SizedBox(height: 16),
Text(
_connectionState == 'connecting'
? appText('正在建立 WebRTC 连接,请稍候...', 'Establishing WebRTC connection, please wait...')
: appText('未开启 AI 工作空间流。点击“连接AI工作空间”启动视频流。', 'AI Workspace stream not enabled. Click "Connect AI Workspace" to start the video stream.'),
? appText(
'正在建立 WebRTC 连接,请稍候...',
'Establishing WebRTC connection, please wait...',
)
: appText(
'未开启 AI 工作空间流。点击“连接AI工作空间”启动视频流。',
'AI Workspace stream not enabled. Click "Connect AI Workspace" to start the video stream.',
),
style: TextStyle(
color: theme.colorScheme.onSurface
.withValues(alpha: 0.6),

View File

@ -64,7 +64,8 @@ Future<Map<String, dynamic>> loadBridgeMetadataForSettingsAbout({
response.statusCode == HttpStatus.forbidden) {
return const <String, dynamic>{
'status': 'unauthorized',
'message': 'Bridge authorization rejected',
'message':
'Bridge authorization rejected. Please re-sync the account token.',
'version': '',
'commit': '',
'image': '',
@ -86,7 +87,8 @@ Future<Map<String, dynamic>> loadBridgeMetadataForSettingsAbout({
if (decoded is Map) {
return decoded.cast<String, dynamic>();
}
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
} catch (e, stackTrace) {
debugPrint('Error: $e\n$stackTrace');
return const <String, dynamic>{
'status': 'unavailable',
'version': '',
@ -296,13 +298,15 @@ class _SettingsPageState extends State<SettingsPage> {
await controller.refreshSingleAgentCapabilitiesInternal(
forceRefresh: true,
);
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
} catch (e, stackTrace) {
debugPrint('Error: $e\n$stackTrace');
// Best effort only. Account sync should still succeed if runtime refresh
// is temporarily unavailable.
}
try {
await controller.refreshAcpCapabilitiesInternal(forceRefresh: true);
} catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace');
} catch (e, stackTrace) {
debugPrint('Error: $e\n$stackTrace');
// Best effort only. Runtime capabilities can be retried later.
}
}
@ -400,7 +404,9 @@ class _SettingsPageState extends State<SettingsPage> {
}
if (status == 'unauthorized') {
await widget.controller.settingsController
.markAccountBridgeRuntimeUnavailable('Bridge authorization rejected');
.markAccountBridgeRuntimeUnavailable(
'Bridge token expired or rejected. Please re-sync the account token.',
);
}
if (mounted) {
setState(() {
@ -576,10 +582,10 @@ class _SettingsTabSelector extends StatelessWidget {
tab == SettingsTab.remoteDesktop
? Icons.desktop_windows_outlined
: (tab == SettingsTab.logs
? Icons.terminal_outlined
: (tab == SettingsTab.archivedTasks
? Icons.inventory_2_outlined
: Icons.hub_outlined)),
? Icons.terminal_outlined
: (tab == SettingsTab.archivedTasks
? Icons.inventory_2_outlined
: Icons.hub_outlined)),
),
label: Text(tab.label),
),

View File

@ -50,6 +50,13 @@ extension SettingsControllerAccountExtension on SettingsController {
.clamp(0, kGatewayProfileListLength - 1);
if (resolvedProfileIndex == kGatewayRemoteProfileIndex) {
final managedBridgeToken = (await storeInternal.loadAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
))?.trim();
if (managedBridgeToken?.isNotEmpty == true) {
return managedBridgeToken!;
}
final effective = snapshotInternal.acpBridgeServerModeConfig.effective;
if (effective.tokenRef.isNotEmpty) {
final token = await loadSecretValueByRef(effective.tokenRef);

View File

@ -127,7 +127,10 @@ void main() {
);
expect(metadata['status'], 'unauthorized');
expect(metadata['message'], 'Bridge authorization rejected');
expect(
metadata['message'],
'Bridge authorization rejected. Please re-sync the account token.',
);
expect(metadata['version'], '');
expect(metadata['commit'], '');
expect(metadata['image'], '');

View File

@ -10,6 +10,78 @@ import 'package:xworkmate/runtime/secure_config_store.dart';
void main() {
group('SettingsController account sync', () {
test(
'prefers managed bridge token over stale profile token for remote gateway auth',
() async {
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-account-sync-token-precedence-',
);
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();
await store.saveSettingsSnapshot(
SettingsSnapshot.defaults().copyWith(
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults()
.copyWith(
selfHosted: const AcpBridgeServerSelfHostedConfig(
serverUrl: 'https://xworkmate-bridge.svc.plus',
username: 'ubuntu',
passwordRef: 'legacy.bridge.token',
),
),
),
);
await store.saveSecretValueByRef('legacy.bridge.token', 'stale-token');
await store.saveAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
value: 'managed-token',
);
await store.saveAccountSessionToken('session-token');
await store.saveAccountSessionSummary(
const AccountSessionSummary(
userId: 'user-1',
email: 'review@svc.plus',
name: 'Review User',
role: 'reviewer',
mfaEnabled: true,
),
);
await store.saveAccountSyncState(
AccountSyncState.defaults().copyWith(
syncState: 'ready',
tokenConfigured: const AccountTokenConfigured(
bridge: true,
vault: false,
),
),
);
final controller = AppController(
store: store,
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
await controller.settingsControllerInternal.initialize();
final token = await controller.settingsControllerInternal
.loadEffectiveGatewayToken(
profileIndex: kGatewayRemoteProfileIndex,
);
expect(token, 'managed-token');
},
);
test(
'updates in-memory blocked state when bridge authorization is unavailable',
() async {
@ -495,7 +567,7 @@ void main() {
await controller.settingsControllerInternal
.markAccountBridgeRuntimeUnavailable(
'Bridge authorization rejected',
'Bridge token expired or rejected. Please re-sync the account token.',
);
expect(controller.isBridgeAcpRuntimeConfiguredInternal(), isFalse);