feat: Remote Desktop UI and Client WebRTC Integration

This commit is contained in:
Haitao Pan 2026-06-03 10:50:06 +08:00
parent c4ca5a2c34
commit 81ecfba85a
26 changed files with 1278 additions and 7 deletions

View File

@ -67,6 +67,12 @@ mobile:
build_modes: [debug, profile, release]
description: Mobile archived task management
ui_surface: settings_page
remote_desktop:
enabled: true
release_tier: stable
build_modes: [debug, profile, release]
description: Mobile WebRTC Remote Desktop view
ui_surface: settings_page
account_access:
enabled: true
release_tier: stable
@ -162,6 +168,12 @@ desktop:
build_modes: [debug, profile, release]
description: Desktop archived task management
ui_surface: settings_page
remote_desktop:
enabled: true
release_tier: stable
build_modes: [debug, profile, release]
description: Desktop WebRTC Remote Desktop view
ui_surface: settings_page
account_access:
enabled: true
release_tier: stable

View File

@ -20,6 +20,7 @@
- [XHS 风格文案](./xhs-copy.md)
- [微信文章文案](./wechat-article.md)
- [PPT 演示稿](./ai-security-evolution-scenario.pptx)
- [章节拆分 PDF 文稿](./ai-security-evolution-pdf.pdf)
## App 手动测试提示词

View File

@ -0,0 +1,65 @@
# 从单机权限到 AI 模型与知识保护
## 内容生产流程
拆章节 -> 每章调用 Codex 生成正文 -> 每章调用 GPT Images2 生成配图 -> 汇总排版 -> 输出 PDF。
## 1. 单机权限:安全从“这台机器归谁管”开始
早期安全的核心问题很朴素:谁能登录这台机器,谁能读写本地文件,谁能安装程序、修改配置、提升权限。管理员、普通用户、服务账号构成了最基本的权限边界。这个阶段的重点是账号、密码、本地组、文件 ACL 和系统审计。
但单机权限并没有消失。今天的终端仍然是身份、数据和任务链路的入口。开发者电脑、运维跳板机、员工笔记本一旦失守攻击者就可能拿到浏览器会话、SSH Key、云凭证和内部系统访问能力。
图像提示一台现代工作站屏幕上叠加本地账号、文件权限、SSH Key 和浏览器会话的安全边界,写实科技风,清晰信息图。
## 2. 网络边界:从机房防火墙到组合攻击面
企业上网、服务器入站、内外网隔离、防火墙策略,曾经定义了很长一段时间的安全主战场。只要守住入口,默认内部网络可信,这是传统网络安全的基本假设。
云、移动办公、SaaS 和 API 普及后,边界被拆散了。今天的网络边界不只是 IP 和端口,而是 API、设备、身份、供应链、运行时和第三方集成的组合面。攻击也不再只从“打穿防火墙”开始而可能从一个泄露 Token、一个开放对象存储桶、一个未校验 Webhook 或一个依赖包开始。
图像提示:传统防火墙边界逐渐碎裂,变成 API、设备、身份、供应链、运行时节点组成的网络拓扑冷静企业安全风格。
## 3. Web 安全:页面漏洞扩展为业务流风险
Web 安全曾经最容易被理解为漏洞清单SQL 注入、XSS、CSRF、文件上传、越权访问。它们仍然重要但真正的风险已经扩展到业务流本身。攻击者更关心如何绕过审批、滥用接口、批量枚举、接管会话、自动化薅取权益或者把插件和第三方脚本变成数据外泄通道。
所以 Web 安全不能只停留在扫描器和 WAF。它需要和身份、风控、审计、数据分级、API 治理一起工作。尤其当 AI Agent 可以自动浏览网页、调用后台接口、读写文件时Web 不再只是用户界面,而是 Agent 执行动作的工作台。
图像提示浏览器窗口中展示登录、API、插件、自动化脚本和数据流旁边标注业务流风险专业信息图白底高对比。
## 4. 云身份:默认控制平面
进入云时代后,身份成为事实上的控制平面。谁能 Assume Role谁能创建密钥谁能访问对象存储谁能改安全组谁能跨账号部署资源往往比某台服务器本身更关键。云环境里的风险也常常来自权限漂移、长期密钥暴露、过宽角色、跨账号信任和 CI/CD 凭证泄露。
云身份的治理重点,是把权限从“能不能登录机器”升级为“能不能代表某个主体操作某类资源”。最小权限、短期凭证、条件访问、资源标签、审计日志和权限边界,构成了云安全的基础。
图像提示:云控制平面中的 IAM、角色、短期凭证、跨账号访问和审计日志连接到计算与数据资源现代云安全架构图。
## 5. Zero Trust持续验证替代默认信任
Zero Trust 的价值不在口号,而在执行层:不因为你在内网就信任你,不因为你登录过一次就永久信任你,不因为你是某个部门的人就默认给你全部权限。每一次访问都要结合身份、设备、位置、会话、资源敏感度和行为上下文进行判断。
在实践中Zero Trust 更像一套纪律:强身份、设备可信、最小权限、分段访问、持续评估、完整审计。它把过去一次性的准入控制,变成贯穿整个会话和操作过程的动态决策。
图像提示:用户、设备、应用、数据之间有动态策略引擎逐次校验访问请求,表达持续验证和最小权限,清爽企业图形风。
## 6. AI Agent 身份:谁在替人行动
AI Agent 的出现,把安全问题继续向前推了一步。过去系统主要判断“人是谁”;现在还要判断“这个 Agent 是谁创建的、代表谁、能做什么、能持续多久、能不能被撤销”。Agent 不是普通 API Key也不应该混用人的全部权限。
一个可控的 Agent 身份需要独立标识、授权范围、任务上下文、工具白名单、数据访问边界、审计链路和撤销机制。它可以替人执行任务,但不能无限继承人的身份,也不能在缺少上下文时自行扩大权限。
图像提示:一个 AI Agent 作为独立身份站在人类用户与工具系统之间,权限边界、任务授权、审计链和撤销按钮清晰可见。
## 7. AI 模型与知识保护:新资产边界
AI 时代的新资产不只有模型权重。提示词、系统指令、RAG 知识库、企业文档、工具调用记录、对话历史、评测集和业务语料,都会影响模型行为,也都可能泄露业务能力。保护模型,本质上是在保护知识、意图和执行路径。
因此安全边界最终从“谁能访问系统”演进为“谁能携带什么知识以什么身份替谁执行什么动作”。未来的安全体系必须同时治理身份边界、知识边界和行动边界。人、应用、Agent、模型和工具都要可区分、可授权、可审计、可撤销。
图像提示模型、RAG 知识库、提示词、业务文档和工具调用记录构成新的资产边界,中心是受保护的 AI 系统,精致信息图。
## 汇总判断
安全的演进不是旧问题被新问题替代而是边界不断外移、主体不断增多、资产不断抽象。单机权限仍是入口网络边界仍是基础Web 安全仍是主战场云身份成为控制平面Zero Trust 成为执行纪律,而 AI Agent 身份与模型知识保护,正在成为下一轮企业安全的核心命题。

View File

@ -78,6 +78,7 @@ extension AppControllerDesktopSkillPermissions on AppController {
List<String>? lastTaskArtifactRelativePaths,
OpenClawTaskAssociation? openClawTaskAssociation,
bool clearOpenClawTaskAssociation = false,
List<TaskInputAttachmentRecord>? taskInputAttachments,
}) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
@ -204,6 +205,7 @@ extension AppControllerDesktopSkillPermissions on AppController {
lastArtifactSyncAtMs: null,
lastArtifactSyncStatus: null,
lastTaskArtifactRelativePaths: const <String>[],
taskInputAttachments: const <TaskInputAttachmentRecord>[],
))
.copyWith(
messages: nextMessages,
@ -229,6 +231,7 @@ extension AppControllerDesktopSkillPermissions on AppController {
lastTaskArtifactRelativePaths: lastTaskArtifactRelativePaths,
openClawTaskAssociation: openClawTaskAssociation,
clearOpenClawTaskAssociation: clearOpenClawTaskAssociation,
taskInputAttachments: taskInputAttachments,
);
final nextStatus =
lifecycleStatus ??

View File

@ -459,8 +459,13 @@ extension AppControllerDesktopThreadActions on AppController {
final capturedSelectedSkillLabels = List<String>.unmodifiable(
selectedSkillLabels,
);
final inlineAttachmentsToUpload =
registerTaskInputAttachmentsForGatewayTurnInternal(
normalizedSessionKey,
attachments,
);
final capturedAttachments = List<GatewayChatAttachmentPayload>.unmodifiable(
attachments,
inlineAttachmentsToUpload,
);
final capturedLocalAttachments = List<CollaborationAttachment>.unmodifiable(
localAttachments,
@ -523,6 +528,61 @@ extension AppControllerDesktopThreadActions on AppController {
recomputeTasksInternal();
}
List<GatewayChatAttachmentPayload>
registerTaskInputAttachmentsForGatewayTurnInternal(
String sessionKey,
List<GatewayChatAttachmentPayload> attachments,
) {
if (attachments.isEmpty) {
return const <GatewayChatAttachmentPayload>[];
}
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
final existing = taskThreadForSessionInternal(normalizedSessionKey);
final existingByKey = <String, TaskInputAttachmentRecord>{
for (final item
in existing?.taskInputAttachments ??
const <TaskInputAttachmentRecord>[])
if (item.key.isNotEmpty) item.key: item,
};
final inlineAttachmentsToUpload = <GatewayChatAttachmentPayload>[];
final nextByKey = <String, TaskInputAttachmentRecord>{...existingByKey};
final uploadedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble();
for (final attachment in attachments) {
final key = gatewayAttachmentPayloadSha256Internal(attachment);
if (key.isEmpty) {
inlineAttachmentsToUpload.add(attachment);
continue;
}
if (!existingByKey.containsKey(key)) {
inlineAttachmentsToUpload.add(attachment);
}
nextByKey.putIfAbsent(
key,
() => TaskInputAttachmentRecord(
name: attachment.fileName.trim(),
mimeType: attachment.mimeType.trim(),
sha256: key,
type: attachment.type.trim(),
uploadedAtMs: uploadedAtMs,
),
);
}
if (nextByKey.length != existingByKey.length) {
upsertTaskThreadInternal(
normalizedSessionKey,
taskInputAttachments: nextByKey.values.toList(growable: false),
updatedAtMs: uploadedAtMs,
);
}
return inlineAttachmentsToUpload;
}
String gatewayAttachmentPayloadSha256Internal(
GatewayChatAttachmentPayload attachment,
) => goTaskServiceAttachmentSha256(attachment);
Future<void> runGatewayChatTurnInternal({
required String sessionKey,
required AssistantExecutionTarget target,
@ -558,6 +618,9 @@ extension AppControllerDesktopThreadActions on AppController {
executionWorkingDirectory: executionWorkingDirectory ?? workingDirectory,
remoteWorkingDirectoryHint: remoteWorkingDirectoryHint,
target: target,
taskInputAttachments:
taskThreadForSessionInternal(sessionKey)?.taskInputAttachments ??
const <TaskInputAttachmentRecord>[],
);
if (appendUserTurn) {
appendGatewayUserTurnInternal(sessionKey, message);
@ -592,7 +655,11 @@ extension AppControllerDesktopThreadActions on AppController {
}
},
);
if (!aiGatewayPendingSessionKeysInternal.contains(sessionKey)) {
if (!aiGatewayPendingSessionKeysInternal.contains(sessionKey) &&
taskThreadForSessionInternal(
sessionKey,
)?.lifecycleState.lastResultCode ==
'aborted') {
clearAiGatewayStreamingTextInternal(sessionKey);
return;
}
@ -802,6 +869,8 @@ extension AppControllerDesktopThreadActions on AppController {
String? executionWorkingDirectory,
required String remoteWorkingDirectoryHint,
required AssistantExecutionTarget target,
List<TaskInputAttachmentRecord> taskInputAttachments =
const <TaskInputAttachmentRecord>[],
}) {
final requestText = userPrompt.trim().isEmpty
? 'See attached.'
@ -824,6 +893,17 @@ extension AppControllerDesktopThreadActions on AppController {
? '(Use the runtime-provided default workspace)'
: workingDirectory.trim();
buffer.writeln('- currentTaskWorkspace: $resolvedWorkspace');
final visibleTaskInputAttachments = taskInputAttachments
.where((item) => item.name.trim().isNotEmpty && item.key.isNotEmpty)
.toList(growable: false);
if (visibleTaskInputAttachments.isNotEmpty) {
buffer.writeln('- taskInputAttachments:');
for (final attachment in visibleTaskInputAttachments) {
buffer.writeln(
' - ${attachment.name.trim()} (${attachment.mimeType.trim()}, sha256: ${attachment.key})',
);
}
}
buffer
..writeln()
..writeln('Workspace isolation rules:')
@ -845,6 +925,9 @@ extension AppControllerDesktopThreadActions on AppController {
..writeln(
'6. The app syncs final artifacts from currentTaskWorkspace back into localWorkspace.',
)
..writeln(
'7. Files listed in taskInputAttachments already belong to this TaskThread; reuse them from the task context and do not ask the user to upload them again.',
)
..writeln();
buffer
..writeln('User request:')
@ -1250,6 +1333,13 @@ extension AppControllerDesktopThreadActions on AppController {
recomputeTasksInternal();
notifyIfActiveInternal();
await persistGoTaskArtifactsForSessionInternal(sessionKey, result);
upsertTaskThreadInternal(
sessionKey,
lifecycleStatus: 'ready',
lastRunAtMs: completedAtMs,
lastResultCode: terminalResultCode,
updatedAtMs: completedAtMs,
);
}
Future<void> applyGatewayChatFailureInternal({

View File

@ -47,6 +47,7 @@ abstract final class UiFeatureKeys {
static const settingsGateway = 'settings.gateway';
static const settingsArchivedTasks = 'settings.archived_tasks';
static const settingsRemoteDesktop = 'settings.remote_desktop';
static const settingsAccountAccess = 'settings.account_access';
static const settingsVaultServer = 'settings.vault_server';
static const settingsExperimentalCanvas = 'settings.experimental_canvas';
@ -366,6 +367,7 @@ class UiFeatureAccess {
<String, SettingsTab>{
UiFeatureKeys.settingsGateway: SettingsTab.gateway,
UiFeatureKeys.settingsArchivedTasks: SettingsTab.archivedTasks,
UiFeatureKeys.settingsRemoteDesktop: SettingsTab.remoteDesktop,
};
bool isEnabledPath(String path) {

View File

@ -1,6 +1,8 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart' as crypto;
import '../../runtime/runtime_models.dart';
import 'assistant_page_composer_clipboard.dart';
@ -81,6 +83,7 @@ buildAssistantAttachmentPayloadsInternal(
mimeType: mimeType,
fileName: attachment.name,
content: base64Encode(bytes),
sha256: crypto.sha256.convert(bytes).toString(),
),
);
}

View File

@ -0,0 +1,158 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import '../../app/app_controller.dart';
class DesktopClient {
DesktopClient({required this.controller, required this.sessionId});
final AppController controller;
final String sessionId;
RTCPeerConnection? _peerConnection;
RTCDataChannel? _dataChannel;
MediaStream? _remoteStream;
final StreamController<MediaStream> _streamController =
StreamController<MediaStream>.broadcast();
Stream<MediaStream> get onRemoteStream => _streamController.stream;
final StreamController<String> _stateController =
StreamController<String>.broadcast();
Stream<String> get onConnectionState => _stateController.stream;
bool _isConnecting = false;
bool get isConnecting => _isConnecting;
bool get isConnected =>
_peerConnection?.connectionState ==
RTCPeerConnectionState.RTCPeerConnectionStateConnected;
Future<void> connect({
String display = '',
int width = 1280,
int height = 720,
int fps = 30,
int bitrate = 2000,
bool useGpu = false,
}) async {
if (_isConnecting || isConnected) return;
_isConnecting = true;
_stateController.add('connecting');
try {
final config = {
'iceServers': [
{'url': 'stun:stun.l.google.com:19302'}
],
'sdpSemantics': 'unified-plan',
};
_peerConnection = await createPeerConnection(config);
// Listen for remote streams
_peerConnection!.onTrack = (event) {
if (event.track.kind == 'video') {
if (event.streams.isNotEmpty) {
_remoteStream = event.streams.first;
_streamController.add(_remoteStream!);
}
}
};
_peerConnection!.onConnectionState = (state) {
_stateController.add(state.toString().split('.').last);
};
// Create data channel for inputs
final dcConfig = RTCDataChannelInit()..ordered = true;
_dataChannel =
await _peerConnection!.createDataChannel('input', dcConfig);
// Handle ICE Candidates generated locally
_peerConnection!.onIceCandidate = (RTCIceCandidate? candidate) {
if (candidate != null) {
_sendIceCandidate(candidate);
}
};
// Create SDP Offer
final offer = await _peerConnection!.createOffer({
'offerToReceiveVideo': true,
'offerToReceiveAudio': false,
});
await _peerConnection!.setLocalDescription(offer);
// Send SDP Offer to Bridge
final response = await controller.gatewayAcpClientInternal.request(
method: 'xworkmate.desktop.offer',
params: {
'sessionId': sessionId,
'sdpOffer': offer.sdp,
'display': display,
'width': width.toString(),
'height': height.toString(),
'fps': fps.toString(),
'bitrate': bitrate.toString(),
'useGpu': useGpu.toString(),
},
);
final sdpAnswer = response['result']?['sdpAnswer'] as String?;
if (sdpAnswer == null) {
throw Exception('Bridge failed to return SDP Answer');
}
// Apply SDP Answer
final answer = RTCSessionDescription(sdpAnswer, 'answer');
await _peerConnection!.setRemoteDescription(answer);
_isConnecting = false;
} catch (e) {
_isConnecting = false;
_stateController.add('failed');
await disconnect();
rethrow;
}
}
Future<void> _sendIceCandidate(RTCIceCandidate candidate) async {
try {
await controller.gatewayAcpClientInternal.request(
method: 'xworkmate.desktop.ice',
params: {
'sessionId': sessionId,
'candidate': {
'candidate': candidate.candidate,
'sdpMid': candidate.sdpMid,
'sdpMLineIndex': candidate.sdpMLineIndex,
},
},
);
} catch (_) {}
}
void sendInput(Map<String, dynamic> event) {
final channel = _dataChannel;
if (channel != null &&
channel.state == RTCDataChannelState.RTCDataChannelOpen) {
final jsonStr = jsonEncode(event);
channel.send(RTCDataChannelMessage(jsonStr));
}
}
Future<void> disconnect() async {
try {
await controller.gatewayAcpClientInternal.request(
method: 'xworkmate.desktop.close',
params: {'sessionId': sessionId},
);
} catch (_) {}
await _dataChannel?.close();
await _peerConnection?.close();
_dataChannel = null;
_peerConnection = null;
_remoteStream = null;
_stateController.add('disconnected');
}
}

View File

@ -0,0 +1,94 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class DesktopInputHandler {
DesktopInputHandler({required this.onSendInput});
final void Function(Map<String, dynamic> event) onSendInput;
int _lastPressedButton = 1; // Default to left click
void handlePointerMove(PointerEvent event, Size widgetSize) {
if (widgetSize.width == 0 || widgetSize.height == 0) return;
final x = event.localPosition.dx / widgetSize.width;
final y = event.localPosition.dy / widgetSize.height;
final cx = x.clamp(0.0, 1.0);
final cy = y.clamp(0.0, 1.0);
onSendInput({
'type': 'mouse_move',
'x': cx,
'y': cy,
});
}
void handlePointerDown(PointerDownEvent event, Size widgetSize) {
if (widgetSize.width == 0 || widgetSize.height == 0) return;
final x = event.localPosition.dx / widgetSize.width;
final y = event.localPosition.dy / widgetSize.height;
final cx = x.clamp(0.0, 1.0);
final cy = y.clamp(0.0, 1.0);
// Send move event first to ensure click hits the exact coordinates
onSendInput({
'type': 'mouse_move',
'x': cx,
'y': cy,
});
_lastPressedButton = _mapPointerButtons(event.buttons);
onSendInput({
'type': 'mouse_down',
'button': _lastPressedButton,
});
}
void handlePointerUp(PointerUpEvent event, Size widgetSize) {
// Under pointer up, buttons bitmask represents STILL pressed buttons.
// If it is 0, then the released button is the one we tracked in PointerDown.
int releasedButton = _lastPressedButton;
if (event.buttons != 0) {
releasedButton = _mapPointerButtons(event.buttons);
}
onSendInput({
'type': 'mouse_up',
'button': releasedButton,
});
}
void handleScroll(PointerScrollEvent event) {
// 4 = scroll up, 5 = scroll down in X11/xdotool button maps
final button = event.scrollDelta.dy < 0 ? 4 : 5;
onSendInput({
'type': 'scroll',
'button': button,
});
}
void handleKeyEvent(KeyEvent event) {
final isDown = event is KeyDownEvent || event is KeyRepeatEvent;
final keyLabel = event.logicalKey.keyLabel;
onSendInput({
'type': isDown ? 'key_down' : 'key_up',
'key': keyLabel,
});
}
int _mapPointerButtons(int buttons) {
// Flutter buttons bitmask:
// 1 = primary (left click)
// 2 = secondary (right click)
// 4 = middle click
// Linux/xdotool mouse mapping: 1=left, 2=middle, 3=right
if (buttons & 1 != 0) return 1;
if (buttons & 4 != 0) return 2; // middle click
if (buttons & 2 != 0) return 3; // right click
return 1;
}
}

View File

@ -0,0 +1,458 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'desktop_client.dart';
import 'desktop_input_handler.dart';
import '../../app/app_controller.dart';
import '../../widgets/surface_card.dart';
class DesktopView extends StatefulWidget {
const DesktopView({super.key, required this.controller});
final AppController controller;
@override
State<DesktopView> createState() => _DesktopViewState();
}
class _DesktopViewState extends State<DesktopView> {
final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
late DesktopClient _client;
DesktopInputHandler? _inputHandler;
// Settings controllers
final TextEditingController _displayController = TextEditingController(text: ':0.0');
final TextEditingController _widthController = TextEditingController(text: '1280');
final TextEditingController _heightController = TextEditingController(text: '720');
final TextEditingController _fpsController = TextEditingController(text: '30');
final TextEditingController _bitrateController = TextEditingController(text: '2000');
bool _useGpu = false;
String _connectionState = 'disconnected';
bool _hasStream = false;
bool _isFocused = false;
final FocusNode _viewportFocusNode = FocusNode();
final GlobalKey _viewportKey = GlobalKey();
StreamSubscription<MediaStream>? _streamSubscription;
StreamSubscription<String>? _stateSubscription;
@override
void initState() {
super.initState();
_initRenderer();
_client = DesktopClient(
controller: widget.controller,
sessionId: 'remote-desktop-session',
);
_inputHandler = DesktopInputHandler(onSendInput: (event) {
if (_connectionState == 'connected') {
_client.sendInput(event);
}
});
_streamSubscription = _client.onRemoteStream.listen((stream) {
if (mounted) {
setState(() {
_localRenderer.srcObject = stream;
_hasStream = true;
});
}
});
_stateSubscription = _client.onConnectionState.listen((state) {
if (mounted) {
setState(() {
_connectionState = state.toLowerCase();
if (_connectionState == 'disconnected' || _connectionState == 'failed') {
_hasStream = false;
_localRenderer.srcObject = null;
}
});
}
});
}
Future<void> _initRenderer() async {
await _localRenderer.initialize();
}
@override
void dispose() {
_streamSubscription?.cancel();
_stateSubscription?.cancel();
_client.disconnect();
_localRenderer.dispose();
_displayController.dispose();
_widthController.dispose();
_heightController.dispose();
_fpsController.dispose();
_bitrateController.dispose();
_viewportFocusNode.dispose();
super.dispose();
}
void _toggleConnection() async {
if (_connectionState == 'connected' || _connectionState == 'connecting') {
await _client.disconnect();
} else {
final display = _displayController.text.trim();
final width = int.tryParse(_widthController.text) ?? 1280;
final height = int.tryParse(_heightController.text) ?? 720;
final fps = int.tryParse(_fpsController.text) ?? 30;
final bitrate = int.tryParse(_bitrateController.text) ?? 2000;
try {
await _client.connect(
display: display,
width: width,
height: height,
fps: fps,
bitrate: bitrate,
useGpu: _useGpu,
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to connect remote desktop: $e'),
backgroundColor: Colors.redAccent,
),
);
}
}
}
}
Size _getViewportSize() {
final renderBox = _viewportKey.currentContext?.findRenderObject() as RenderBox?;
return renderBox?.size ?? Size.zero;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Control panel card
SurfaceCard(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: 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'
? '断开连接'
: (_connectionState == 'connecting' ? '正在连接...' : '连接桌面'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
// Display Selector
SizedBox(
width: 100,
child: TextField(
controller: _displayController,
enabled: _connectionState == 'disconnected',
decoration: const InputDecoration(
labelText: 'Display',
prefixIcon: Icon(Icons.monitor_rounded, size: 16),
),
),
),
// Resolution settings
SizedBox(
width: 90,
child: TextField(
controller: _widthController,
enabled: _connectionState == 'disconnected',
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '宽度'),
),
),
SizedBox(
width: 90,
child: TextField(
controller: _heightController,
enabled: _connectionState == 'disconnected',
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,
),
],
),
// 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'
? 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'
? '已连接'
: (_connectionState == 'connecting' ? '连接中' : '未连接'),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: _connectionState == 'connected'
? Colors.green
: (_connectionState == 'connecting'
? Colors.orange
: (isDark ? Colors.white70 : Colors.black87)),
),
),
],
),
),
],
),
),
),
const SizedBox(height: 16),
// Stream Viewport Card
Expanded(
child: Focus(
focusNode: _viewportFocusNode,
onFocusChange: (focused) {
setState(() {
_isFocused = focused;
});
},
onKeyEvent: (node, event) {
if (_isFocused && _connectionState == 'connected' && _inputHandler != null) {
_inputHandler!.handleKeyEvent(event);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Container(
key: _viewportKey,
decoration: BoxDecoration(
color: isDark ? Colors.black26 : Colors.black.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _isFocused
? theme.colorScheme.primary
: (isDark ? Colors.white10 : Colors.black12),
width: 2,
),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Stream Viewport Renderer
if (_hasStream)
Positioned.fill(
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerHover: (event) {
if (_inputHandler != null) {
_inputHandler!.handlePointerMove(event, _getViewportSize());
}
},
onPointerMove: (event) {
if (_inputHandler != null) {
_inputHandler!.handlePointerMove(event, _getViewportSize());
}
},
onPointerDown: (event) {
if (!_viewportFocusNode.hasFocus) {
_viewportFocusNode.requestFocus();
}
if (_inputHandler != null) {
_inputHandler!.handlePointerDown(event, _getViewportSize());
}
},
onPointerUp: (event) {
if (_inputHandler != null) {
_inputHandler!.handlePointerUp(event, _getViewportSize());
}
},
onPointerSignal: (event) {
if (event is PointerScrollEvent && _inputHandler != null) {
_inputHandler!.handleScroll(event);
}
},
child: RTCVideoView(
_localRenderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
),
),
),
// Placeholder/Status UI overlay
if (!_hasStream)
Positioned.fill(
child: Container(
color: isDark ? Colors.black54 : Colors.grey[100],
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.monitor_rounded,
size: 64,
color: theme.colorScheme.onSurface.withValues(alpha: 0.2),
),
const SizedBox(height: 16),
Text(
_connectionState == 'connecting'
? '正在建立 WebRTC 连接,请稍候...'
: '未开启远程桌面流。点击“连接桌面”启动视频流。',
style: TextStyle(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
fontSize: 14,
),
),
if (_connectionState == 'connecting') ...[
const SizedBox(height: 24),
const CircularProgressIndicator(),
],
],
),
),
),
),
// Focus watermark badge
if (_hasStream)
Positioned(
right: 8,
bottom: 8,
child: AnimatedOpacity(
opacity: _isFocused ? 0.3 : 0.8,
duration: const Duration(milliseconds: 200),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_isFocused
? Icons.keyboard_rounded
: Icons.keyboard_hide_rounded,
color: Colors.white,
size: 14,
),
const SizedBox(width: 4),
Text(
_isFocused ? '捕获键盘输入中' : '点击屏幕以捕获键盘',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
),
),
],
),
),
),
),
],
),
),
),
),
],
),
);
}
}

View File

@ -17,6 +17,7 @@ import '../../widgets/surface_card.dart';
import 'settings_account_panel.dart';
import 'settings_about_panel.dart';
import 'settings_archived_tasks_panel.dart';
import 'settings_remote_desktop_panel.dart';
Future<Map<String, dynamic>> loadBridgeMetadataForSettingsAbout({
required Uri bridgeEndpoint,
@ -514,7 +515,7 @@ class _SettingsPageState extends State<SettingsPage> {
onRefresh: _refreshAboutSnapshot,
),
),
] else if (currentTab == SettingsTab.archivedTasks)
] else if (currentTab == SettingsTab.archivedTasks) ...[
SurfaceCard(
key: const ValueKey('settings-archived-tasks-panel-card'),
child: SettingsArchivedTasksPanel(
@ -523,6 +524,12 @@ class _SettingsPageState extends State<SettingsPage> {
onDelete: _deleteArchivedTask,
),
),
] else if (currentTab == SettingsTab.remoteDesktop) ...[
SurfaceCard(
key: const ValueKey('settings-remote-desktop-panel-card'),
child: SettingsRemoteDesktopPanel(controller: controller),
),
],
],
);
},
@ -555,9 +562,11 @@ class _SettingsTabSelector extends StatelessWidget {
ButtonSegment<SettingsTab>(
value: tab,
icon: Icon(
tab == SettingsTab.archivedTasks
? Icons.inventory_2_outlined
: Icons.hub_outlined,
tab == SettingsTab.remoteDesktop
? Icons.desktop_windows_outlined
: (tab == SettingsTab.archivedTasks
? Icons.inventory_2_outlined
: Icons.hub_outlined),
),
label: Text(tab.label),
),

View File

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../i18n/app_language.dart';
import '../../theme/app_palette.dart';
import '../desktop/desktop_view.dart';
class SettingsRemoteDesktopPanel extends StatefulWidget {
const SettingsRemoteDesktopPanel({super.key, required this.controller});
final AppController controller;
@override
State<SettingsRemoteDesktopPanel> createState() =>
_SettingsRemoteDesktopPanelState();
}
class _SettingsRemoteDesktopPanelState extends State<SettingsRemoteDesktopPanel> {
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Column(
key: const ValueKey('settings-remote-desktop-panel'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.desktop_windows_outlined, color: palette.textSecondary),
const SizedBox(width: 10),
Expanded(
child: Text(
appText('远程桌面', 'Remote Desktop'),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 12),
SizedBox(
height: 640,
child: DesktopView(controller: widget.controller),
),
],
);
}
}

View File

@ -149,12 +149,13 @@ extension AssistantModeCopy on AssistantMode {
};
}
enum SettingsTab { gateway, archivedTasks }
enum SettingsTab { gateway, archivedTasks, remoteDesktop }
extension SettingsTabCopy on SettingsTab {
String get label => switch (this) {
SettingsTab.gateway => appText('集成', 'Integrations'),
SettingsTab.archivedTasks => appText('归档任务', 'Archived tasks'),
SettingsTab.remoteDesktop => appText('远程桌面', 'Remote Desktop'),
};
}

View File

@ -1,3 +1,7 @@
import 'dart:convert';
import 'package:crypto/crypto.dart' as crypto;
import 'runtime_models.dart';
enum GoTaskServiceRoute { externalAcpSingle, externalAcpMulti }
@ -308,6 +312,8 @@ class GoTaskServiceRequest {
'mimeType': item.mimeType,
'content': item.content,
'sizeBytes': goTaskServiceBase64Size(item.content),
if (goTaskServiceAttachmentSha256(item).isNotEmpty)
'sha256': goTaskServiceAttachmentSha256(item),
},
)
.toList(growable: false),
@ -1005,6 +1011,20 @@ int goTaskServiceBase64Size(String base64) {
return (normalized.length * 3 ~/ 4) - padding;
}
String goTaskServiceAttachmentSha256(GatewayChatAttachmentPayload attachment) {
final declared = attachment.sha256.trim().toLowerCase();
if (declared.isNotEmpty) {
return declared;
}
try {
return crypto.sha256
.convert(base64Decode(attachment.content.trim().split(',').last.trim()))
.toString();
} on FormatException {
return '';
}
}
Map<String, dynamic> _castMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;

View File

@ -16,12 +16,14 @@ class GatewayChatAttachmentPayload {
required this.mimeType,
required this.fileName,
required this.content,
this.sha256 = '',
});
final String type;
final String mimeType;
final String fileName;
final String content;
final String sha256;
Map<String, dynamic> toJson() {
return {
@ -29,6 +31,7 @@ class GatewayChatAttachmentPayload {
'mimeType': mimeType,
'fileName': fileName,
'content': content,
if (sha256.trim().isNotEmpty) 'sha256': sha256.trim(),
};
}
}

View File

@ -678,6 +678,7 @@ class ThreadContextState {
this.lastArtifactSyncStatus,
this.lastTaskArtifactRelativePaths = const <String>[],
this.openClawTaskAssociation,
this.taskInputAttachments = const <TaskInputAttachmentRecord>[],
});
final List<GatewayChatMessage> messages;
@ -696,6 +697,7 @@ class ThreadContextState {
final String? lastArtifactSyncStatus;
final List<String> lastTaskArtifactRelativePaths;
final OpenClawTaskAssociation? openClawTaskAssociation;
final List<TaskInputAttachmentRecord> taskInputAttachments;
ThreadContextState copyWith({
List<GatewayChatMessage>? messages,
@ -716,6 +718,7 @@ class ThreadContextState {
List<String>? lastTaskArtifactRelativePaths,
OpenClawTaskAssociation? openClawTaskAssociation,
bool clearOpenClawTaskAssociation = false,
List<TaskInputAttachmentRecord>? taskInputAttachments,
}) {
return ThreadContextState(
messages: messages ?? this.messages,
@ -745,6 +748,7 @@ class ThreadContextState {
openClawTaskAssociation: clearOpenClawTaskAssociation
? null
: (openClawTaskAssociation ?? this.openClawTaskAssociation),
taskInputAttachments: taskInputAttachments ?? this.taskInputAttachments,
);
}
@ -766,6 +770,9 @@ class ThreadContextState {
'lastArtifactSyncStatus': lastArtifactSyncStatus,
'lastTaskArtifactRelativePaths': lastTaskArtifactRelativePaths,
'openClawTaskAssociation': openClawTaskAssociation?.toJson(),
'taskInputAttachments': taskInputAttachments
.map((item) => item.toJson())
.toList(growable: false),
};
}
@ -834,10 +841,77 @@ class ThreadContextState {
openClawTaskAssociation: OpenClawTaskAssociation.fromJsonOrNull(
json['openClawTaskAssociation'],
),
taskInputAttachments: _taskInputAttachmentRecordsFromJson(
json['taskInputAttachments'],
),
);
}
}
class TaskInputAttachmentRecord {
const TaskInputAttachmentRecord({
required this.name,
required this.mimeType,
required this.sha256,
required this.type,
required this.uploadedAtMs,
});
final String name;
final String mimeType;
final String sha256;
final String type;
final double uploadedAtMs;
String get key => sha256.trim().toLowerCase();
Map<String, dynamic> toJson() {
return <String, dynamic>{
'name': name.trim(),
'mimeType': mimeType.trim(),
'sha256': key,
'type': type.trim(),
'uploadedAtMs': uploadedAtMs,
};
}
factory TaskInputAttachmentRecord.fromJson(Map<String, dynamic> json) {
double uploadedAtMs(Object? value) {
if (value is num) {
return value.toDouble();
}
return double.tryParse(value?.toString() ?? '') ?? 0;
}
return TaskInputAttachmentRecord(
name: json['name']?.toString().trim() ?? '',
mimeType: json['mimeType']?.toString().trim() ?? '',
sha256: json['sha256']?.toString().trim().toLowerCase() ?? '',
type: json['type']?.toString().trim() ?? '',
uploadedAtMs: uploadedAtMs(json['uploadedAtMs']),
);
}
}
List<TaskInputAttachmentRecord> _taskInputAttachmentRecordsFromJson(
Object? value,
) {
if (value is! List) {
return const <TaskInputAttachmentRecord>[];
}
final byKey = <String, TaskInputAttachmentRecord>{};
for (final item in value.whereType<Map>()) {
final record = TaskInputAttachmentRecord.fromJson(
item.cast<String, dynamic>(),
);
if (record.key.isEmpty || record.name.isEmpty) {
continue;
}
byKey.putIfAbsent(record.key, () => record);
}
return byKey.values.toList(growable: false);
}
class OpenClawTaskAssociation {
const OpenClawTaskAssociation({
required this.sessionId,
@ -1097,6 +1171,7 @@ class TaskThread {
String? lastArtifactSyncStatus,
List<String>? lastTaskArtifactRelativePaths,
OpenClawTaskAssociation? openClawTaskAssociation,
List<TaskInputAttachmentRecord>? taskInputAttachments,
}) : threadId = _resolveThreadId(threadId),
title = title ?? '',
ownerScope =
@ -1144,6 +1219,8 @@ class TaskThread {
lastTaskArtifactRelativePaths,
),
openClawTaskAssociation: openClawTaskAssociation,
taskInputAttachments:
taskInputAttachments ?? const <TaskInputAttachmentRecord>[],
),
lifecycleState =
lifecycleState ??
@ -1184,6 +1261,8 @@ class TaskThread {
contextState.lastTaskArtifactRelativePaths;
OpenClawTaskAssociation? get openClawTaskAssociation =>
contextState.openClawTaskAssociation;
List<TaskInputAttachmentRecord> get taskInputAttachments =>
contextState.taskInputAttachments;
String get latestResolvedRuntimeModel =>
contextState.latestResolvedRuntimeModel;
String get latestResolvedProviderId => contextState.latestResolvedProviderId;
@ -1229,6 +1308,7 @@ class TaskThread {
List<String>? lastTaskArtifactRelativePaths,
OpenClawTaskAssociation? openClawTaskAssociation,
bool clearOpenClawTaskAssociation = false,
List<TaskInputAttachmentRecord>? taskInputAttachments,
}) {
return TaskThread(
threadId: threadId ?? this.threadId,
@ -1254,6 +1334,7 @@ class TaskThread {
lastTaskArtifactRelativePaths: lastTaskArtifactRelativePaths,
openClawTaskAssociation: openClawTaskAssociation,
clearOpenClawTaskAssociation: clearOpenClawTaskAssociation,
taskInputAttachments: taskInputAttachments,
),
lifecycleState: (lifecycleState ?? this.lifecycleState).copyWith(
archived: archived,
@ -1371,6 +1452,7 @@ class TaskThread {
'lastArtifactSyncStatus': json['lastArtifactSyncStatus'],
'lastTaskArtifactRelativePaths': json['lastTaskArtifactRelativePaths'],
'openClawTaskAssociation': json['openClawTaskAssociation'],
'taskInputAttachments': json['taskInputAttachments'],
};
}

View File

@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <irondash_engine_context/irondash_engine_context_plugin.h>
#include <super_native_extensions/super_native_extensions_plugin.h>
@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin");
flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar);
g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "IrondashEngineContextPlugin");
irondash_engine_context_plugin_register_with_registrar(irondash_engine_context_registrar);

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_webrtc
irondash_engine_context
super_native_extensions
)

View File

@ -7,6 +7,7 @@ import Foundation
import device_info_plus
import file_selector_macos
import flutter_webrtc
import irondash_engine_context
import package_info_plus
import patrol
@ -16,6 +17,7 @@ import super_native_extensions
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PatrolPlugin.register(with: registry.registrar(forPlugin: "PatrolPlugin"))

View File

@ -97,6 +97,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
dart_webrtc:
dependency: transitive
description:
name: dart_webrtc
sha256: f6d615bddea5e458ce180a914f3055c234ffb52fb7397a51b3491e76d6d7edb2
url: "https://pub.dev"
source: hosted
version: "1.8.1"
device_info_plus:
dependency: "direct main"
description:
@ -274,6 +282,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_webrtc:
dependency: "direct main"
description:
name: flutter_webrtc
sha256: b832dc76c0d1577f14aaf35e9c38d4ed7667cbc89c492b7bf4505d8d5f62e08b
url: "https://pub.dev"
source: hosted
version: "0.12.12+hotfix.1"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
@ -348,6 +364,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.0"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: transitive
description:
@ -711,6 +735,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
url: "https://pub.dev"
source: hosted
version: "3.4.0+1"
term_glyph:
dependency: transitive
description:
@ -791,6 +823,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.0"
webrtc_interface:
dependency: transitive
description:
name: webrtc_interface
sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f
url: "https://pub.dev"
source: hosted
version: "1.5.1"
win32:
dependency: transitive
description:

View File

@ -28,6 +28,7 @@ dependencies:
shared_preferences: ^2.5.3
super_clipboard: ^0.9.0
web_socket_channel: ^3.0.3
flutter_webrtc: ^0.12.3
yaml: ^3.1.3
dev_dependencies:

View File

@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/features/settings/settings_remote_desktop_panel.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/theme/app_theme.dart';
import 'package:xworkmate/widgets/surface_card.dart';
void main() {
group('SettingsRemoteDesktopPanel', () {
testWidgets('renders the panel title and connection dashboard', (tester) async {
// Set desktop window size
tester.view.physicalSize = const Size(1280, 900);
tester.view.devicePixelRatio = 1.0;
final store = _MemorySecureConfigStore();
final controller = _NoopRefreshAppController(store: store);
addTearDown(() {
controller.dispose();
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
await tester.pumpWidget(
_buildTestApp(
child: SettingsRemoteDesktopPanel(controller: controller),
),
);
// Verify the panel headers and titles
expect(find.text('远程桌面'), findsOneWidget);
expect(find.text('连接桌面'), findsOneWidget);
expect(find.text('GPU 加速'), findsOneWidget);
// Verify inputs
expect(find.widgetWithText(TextField, 'Display'), findsOneWidget);
expect(find.text('Display'), findsOneWidget);
});
});
}
Widget _buildTestApp({required Widget child}) {
return MaterialApp(
theme: AppTheme.light(),
home: Material(
child: Center(
child: SizedBox(
width: 1100,
child: SurfaceCard(child: child),
),
),
),
);
}
class _NoopRefreshAppController extends AppController {
_NoopRefreshAppController({required SecureConfigStore store})
: super(environmentOverride: const <String, String>{}, store: store);
Future<void> refreshAcpCapabilitiesInternal({
bool forceRefresh = false,
bool persistMountTargets = false,
}) async {}
Future<void> refreshSingleAgentCapabilitiesInternal({
bool forceRefresh = false,
}) async {}
}
class _MemorySecureConfigStore extends SecureConfigStore {
_MemorySecureConfigStore() : super(enableSecureStorage: false);
SettingsSnapshot _settings = SettingsSnapshot.defaults();
final Map<String, String> _secrets = <String, String>{};
@override
Future<void> initialize() async {}
@override
Future<SettingsSnapshot> loadSettingsSnapshot() async => _settings;
@override
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) async {
_settings = snapshot;
}
@override
Future<Map<String, String>> loadSecureRefs() async => _secrets;
@override
Future<List<SecretAuditEntry>> loadAuditTrail() async =>
const <SecretAuditEntry>[];
@override
Future<void> appendAudit(SecretAuditEntry entry) async {}
@override
Future<String?> loadSecretValueByRef(String refName) async =>
_secrets[refName];
@override
Future<void> saveSecretValueByRef(String refName, String value) async {
_secrets[refName] = value;
}
@override
Future<String?> loadAccountSessionToken() async => null;
@override
Future<AccountSessionSummary?> loadAccountSessionSummary() async => null;
@override
Future<AccountSyncState?> loadAccountSyncState() async => null;
}

View File

@ -1351,6 +1351,10 @@ void main() {
base64Encode(utf8.encode('note body')),
);
expect(inlineAttachment['sizeBytes'], 9);
expect(
inlineAttachment['sha256'],
'1c727d26215adccb96d725e8b63b3ee11cf73215a554e60295877244b0778847',
);
final attachments = params['attachments'] as List<dynamic>;
final attachment = attachments.single as Map<String, dynamic>;
expect(attachment['name'], 'note.txt');
@ -1358,6 +1362,56 @@ void main() {
},
);
test(
'sendChatMessage records task input attachments and does not reupload duplicates',
() async {
final fakeGoTaskService = _RecordingGoTaskServiceClient();
final controller = _connectedGatewayController(fakeGoTaskService);
addTearDown(controller.dispose);
await controller.ensureActiveAssistantThreadInternal();
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
final imageAttachment = GatewayChatAttachmentPayload(
type: 'image',
mimeType: 'image/png',
fileName: 'diagram.png',
content: base64Encode(utf8.encode('image bytes')),
);
await controller.sendChatMessage(
'use the image',
attachments: <GatewayChatAttachmentPayload>[imageAttachment],
);
await controller.sendChatMessage(
'continue with the same image',
attachments: <GatewayChatAttachmentPayload>[imageAttachment],
);
expect(fakeGoTaskService.requests, hasLength(2));
expect(
fakeGoTaskService.requests.first.inlineAttachments,
hasLength(1),
);
expect(fakeGoTaskService.requests.last.inlineAttachments, isEmpty);
expect(
fakeGoTaskService.requests.last.prompt,
contains('- taskInputAttachments:'),
);
expect(
fakeGoTaskService.requests.last.prompt,
contains('diagram.png (image/png, sha256:'),
);
final thread = controller.requireTaskThreadForSessionInternal(
fakeGoTaskService.requests.last.sessionId,
);
expect(thread.taskInputAttachments, hasLength(1));
expect(thread.taskInputAttachments.single.name, 'diagram.png');
},
);
test(
'sendChatMessage resumes existing task after response interruption',
() async {

View File

@ -7,12 +7,15 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <irondash_engine_context/irondash_engine_context_plugin_c_api.h>
#include <super_native_extensions/super_native_extensions_plugin_c_api.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterWebRTCPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
IrondashEngineContextPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("IrondashEngineContextPluginCApi"));
SuperNativeExtensionsPluginCApiRegisterWithRegistrar(

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
flutter_webrtc
irondash_engine_context
super_native_extensions
)