feat: Remote Desktop UI and Client WebRTC Integration
This commit is contained in:
parent
c4ca5a2c34
commit
81ecfba85a
@ -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
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
- [XHS 风格文案](./xhs-copy.md)
|
||||
- [微信文章文案](./wechat-article.md)
|
||||
- [PPT 演示稿](./ai-security-evolution-scenario.pptx)
|
||||
- [章节拆分 PDF 文稿](./ai-security-evolution-pdf.pdf)
|
||||
|
||||
## App 手动测试提示词
|
||||
|
||||
|
||||
@ -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 身份与模型知识保护,正在成为下一轮企业安全的核心命题。
|
||||
Binary file not shown.
@ -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 ??
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
158
lib/features/desktop/desktop_client.dart
Normal file
158
lib/features/desktop/desktop_client.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
94
lib/features/desktop/desktop_input_handler.dart
Normal file
94
lib/features/desktop/desktop_input_handler.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
458
lib/features/desktop/desktop_view.dart
Normal file
458
lib/features/desktop/desktop_view.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
|
||||
49
lib/features/settings/settings_remote_desktop_panel.dart
Normal file
49
lib/features/settings/settings_remote_desktop_panel.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
flutter_webrtc
|
||||
irondash_engine_context
|
||||
super_native_extensions
|
||||
)
|
||||
|
||||
@ -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"))
|
||||
|
||||
40
pubspec.lock
40
pubspec.lock
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
115
test/features/settings/settings_remote_desktop_panel_test.dart
Normal file
115
test/features/settings/settings_remote_desktop_panel_test.dart
Normal 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;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
flutter_webrtc
|
||||
irondash_engine_context
|
||||
super_native_extensions
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user