diff --git a/lib/features/desktop/desktop_view.dart b/lib/features/desktop/desktop_view.dart index 3598e1c5..ce122ea1 100644 --- a/lib/features/desktop/desktop_view.dart +++ b/lib/features/desktop/desktop_view.dart @@ -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 { 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 { _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 { 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 { } } + 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 { 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 { ), // 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 { ), ), - if (_showControlPanel) - const SizedBox(height: 16), + if (_showControlPanel) const SizedBox(height: 16), // Stream Viewport Card Expanded( @@ -516,7 +561,8 @@ class _DesktopViewState extends State { }, child: RTCVideoView( _localRenderer, - objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, + objectFit: RTCVideoViewObjectFit + .RTCVideoViewObjectFitContain, filterQuality: FilterQuality.medium, ), ), @@ -541,8 +587,14 @@ class _DesktopViewState extends State { 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), diff --git a/lib/features/settings/settings_page_core.dart b/lib/features/settings/settings_page_core.dart index d135c1e9..4d01ddc5 100644 --- a/lib/features/settings/settings_page_core.dart +++ b/lib/features/settings/settings_page_core.dart @@ -64,7 +64,8 @@ Future> loadBridgeMetadataForSettingsAbout({ response.statusCode == HttpStatus.forbidden) { return const { 'status': 'unauthorized', - 'message': 'Bridge authorization rejected', + 'message': + 'Bridge authorization rejected. Please re-sync the account token.', 'version': '', 'commit': '', 'image': '', @@ -86,7 +87,8 @@ Future> loadBridgeMetadataForSettingsAbout({ if (decoded is Map) { return decoded.cast(); } - } catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace'); + } catch (e, stackTrace) { + debugPrint('Error: $e\n$stackTrace'); return const { 'status': 'unavailable', 'version': '', @@ -296,13 +298,15 @@ class _SettingsPageState extends State { 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 { } 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), ), diff --git a/lib/runtime/runtime_controllers_settings_account.dart b/lib/runtime/runtime_controllers_settings_account.dart index 1064d37d..6b23fd59 100644 --- a/lib/runtime/runtime_controllers_settings_account.dart +++ b/lib/runtime/runtime_controllers_settings_account.dart @@ -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); diff --git a/test/features/settings/settings_about_bridge_metadata_test.dart b/test/features/settings/settings_about_bridge_metadata_test.dart index fc6ca716..f2a06788 100644 --- a/test/features/settings/settings_about_bridge_metadata_test.dart +++ b/test/features/settings/settings_about_bridge_metadata_test.dart @@ -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'], ''); diff --git a/test/runtime/runtime_controllers_settings_account_test.dart b/test/runtime/runtime_controllers_settings_account_test.dart index 435b758e..b82f5af8 100644 --- a/test/runtime/runtime_controllers_settings_account_test.dart +++ b/test/runtime/runtime_controllers_settings_account_test.dart @@ -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 {}, + ); + 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);