From be1499bc630f9e31a0de707e1e6c7e5c6701d0a3 Mon Sep 17 00:00:00 2001 From: MistEO Date: Thu, 29 Oct 2026 17:17:17 +0800 Subject: [PATCH 1/2] refactor: remove non-standard PI extensions --- README.md | 106 ------------- docs/add-special-task.md | 13 +- src-tauri/src/commands/app_config.rs | 2 +- src-tauri/src/commands/maa_agent.rs | 5 +- src-tauri/src/commands/maa_core.rs | 24 --- src-tauri/src/commands/types.rs | 9 -- src/components/ConnectionPanel.tsx | 150 +----------------- src/components/DeviceSelector.tsx | 90 +---------- src/components/FormControls.tsx | 5 +- src/components/OptionEditor.tsx | 69 ++++---- src/components/RichLabel.tsx | 45 ------ src/components/TaskItem.tsx | 10 +- src/components/Toolbar.tsx | 51 ------ .../connection/useDeviceConnection.ts | 88 +--------- src/i18n/locales/en-US.ts | 1 - src/i18n/locales/ja-JP.ts | 1 - src/i18n/locales/ko-KR.ts | 1 - src/i18n/locales/zh-CN.ts | 1 - src/i18n/locales/zh-TW.ts | 1 - src/services/contentResolver.ts | 119 +------------- src/services/interfaceLoader.ts | 19 +-- src/services/telemetryService.ts | 11 +- src/types/interface.ts | 54 +++---- src/types/maa.ts | 8 - src/types/specialTasks.ts | 33 +++- src/utils/controller.ts | 1 - src/utils/pipelineOverride.ts | 16 +- src/utils/richText.ts | 81 +--------- src/utils/useMaaCallbackLogger.ts | 72 ++------- tests/protocolUi.test.ts | 111 +++++++++++++ tests/richText.test.ts | 59 +------ 31 files changed, 248 insertions(+), 1008 deletions(-) delete mode 100644 src/components/RichLabel.tsx create mode 100644 tests/protocolUi.test.ts diff --git a/README.md b/README.md index 7abff75a..8c8eb14a 100644 --- a/README.md +++ b/README.md @@ -73,112 +73,6 @@ mxu.exe --autostart -i "日常任务" --quit-after-run 用户配置保存在 `config` 文件夹中,调试日志保存在 `debug` 文件夹中。亦可在 设置 - 调试 中直接打开文件夹。 -## 🧩 MXU 扩展 - -MXU 在 [PI V2 协议](https://github.com/MaaXYZ/MaaFramework/blob/main/docs/zh_cn/3.3-ProjectInterfaceV2%E5%8D%8F%E8%AE%AE.md) 之外提供了一组**可选扩展字段**,用于补齐协议未覆盖的界面细节。 - -它们都不是协议的组成部分,**不写也能正常工作**;写了之后其它客户端未必识别,具体行为以本仓库实现为准(代码里的 `MXU 扩展` 注释即这些字段的登记处)。 - -### 行内 Markdown label - -`option` / `case` / `input` 的 `label` 支持行内 Markdown,最常见的用法是给选项名称配一个小图标: - -```json -{ - "option": { - "Stage": { - "type": "select", - "label": "关卡选择", - "cases": [ - { "name": "Stage1", "label": "![](resource/image/icon/stage1.png) 第一关 ★1" }, - { "name": "Stage2", "label": "![](resource/image/icon/stage2.png) 第二关 ★2" } - ] - } - } -} -``` - -支持的行内语法(判定规则见 `src/utils/richText.ts`): - -| 语法 | 效果 | -| ----------------------------- | ------------------------------------------------------ | -| `![](路径)` / `![说明](路径)` | 行内 16px 图标,相对路径基于 `interface.json` 所在目录 | -| `[文字](https://…)` | 链接 | -| `` `代码` `` | 行内代码样式 | -| `**加粗**` | 加粗 | -| `` `
` `` 等 | 常见的行内 HTML 标签(渲染前经 DOMPurify 清理) | - -注意事项: - -- label 默认按纯文本渲染,只有出现上表中的标记时才走 Markdown 解析,因此**纯文本 label 没有任何额外开销**。 -- 只支持**行内**语法。多行段落、列表、标题、表格等块级内容请写在 `description` 里(`description` 支持 Markdown、文件路径与 URL)。 -- 悬停提示(tooltip)始终显示去掉标记的纯文本,不会出现 `![](...)` 这类源码。 -- 图标读取失败时会被忽略,不会显示破图;建议控制在 16px 左右,与 `option` / `case` 的 `icon` 字段视觉一致。 -- 该能力只作用于 `option`、`case`、`input` 的 `label`;`task`、`resource`、`controller`、任务分组的 `label` 仍为纯文本。 - -### 输入项扩展字段 - -`input` 类型的 `inputs[]` 在协议字段之外支持以下扩展: - -| 字段 | 类型 | 说明 | -| ------------- | -------------------------------- | -------------------------------------------------------------------- | -| `input_type` | `"text"` \| `"file"` \| `"time"` | 渲染对应控件:`file` 文件选择器、`time` 时间选择器,缺省为普通文本框 | -| `placeholder` | `string` | 输入框占位提示,支持 `$` 国际化 | -| `password` | `boolean` | 密码字段:界面掩码显示、配置加密存储,日志与遥测中同样脱敏 | - -```json -{ - "input": { - "type": "input", - "label": "启动参数", - "inputs": [ - { "name": "ConfigPath", "label": "配置文件", "input_type": "file" }, - { "name": "Token", "label": "访问令牌", "password": true }, - { - "name": "StartAt", - "label": "启动时间", - "input_type": "time", - "placeholder": "$task.startAtHint" - } - ] - } -} -``` - -### 任务设置页(setting) - -`interface.json` 顶层可声明 `setting`,把这些 option 收纳到「设置 - 任务设置」中,并自定义分组外观: - -```json -{ - "setting": [ - { - "name": "advanced", - "label": "$settings.advanced", - "description": "日常任务用到的高级参数", - "icon": "resource/image/icon/advanced.png", - "default_expand": false, - "option": ["MaxRetry", "Timeout"] - } - ] -} -``` - -| 字段 | 类型 | 说明 | -| ---------------- | ---------- | ------------------------------------------------ | -| `name` | `string` | 分组唯一标识,同时作为设置页锚点 ID | -| `label` | `string` | 分组显示名称,支持 `$` 国际化;缺省回退到 `name` | -| `description` | `string` | 分组说明,支持 `$` 国际化 | -| `icon` | `string` | 分组图标,相对路径基于 `interface.json` 所在目录 | -| `default_expand` | `boolean` | 默认是否展开,缺省展开 | -| `option` | `string[]` | 该分组包含的顶层 option 键名;不存在的键会被忽略 | - -`setting` 只描述“怎么展示”,option 本身仍定义在顶层 `option` 里。使用 `import` 导入其它 PI 文件时,导入文件的 `setting` 会按导入顺序追加合并。 - -> [!TIP] -> -> MXU 还内置了若干与 pipeline 无关的「特殊任务」(延迟、等待到指定时间点、启动外部程序),它们由 MXU 自身实现,无需资源提供 pipeline;新增方式见 [docs/add-special-task.md](docs/add-special-task.md)。 - ## 📖 开发调试 ### 🧭 开发说明 diff --git a/docs/add-special-task.md b/docs/add-special-task.md index cb27d92a..9b6af32a 100644 --- a/docs/add-special-task.md +++ b/docs/add-special-task.md @@ -168,9 +168,7 @@ const MXU_EXAMPLE_OPTION: InputOption = { pipeline_type: 'int', // 'string' | 'int' | 'bool' verify: '^[1-9]\\d*$', // 可选,输入校验正则 pattern_msg: 'specialTask.example.inputError', // 校验失败提示 i18n key - // MXU 扩展字段(可选): - input_type: 'file', // 'text'(默认) | 'file'(渲染文件选择器) - placeholder: 'specialTask.example.placeholder', // 占位提示文本 i18n key + input_type: 'file', // PI v2.10.5: 'text' | 'file' | 'directory' }, ], pipeline_override: { @@ -183,12 +181,7 @@ const MXU_EXAMPLE_OPTION: InputOption = { }; ``` -**MXU 扩展字段说明**(`InputItem` 上的可选字段,不影响 PI V2 协议): - -| 字段 | 类型 | 说明 | -| ------ | ------ | ------ | -| `input_type` | `'text' \| 'file' \| 'time'` | 控制 UI 渲染:`'file'` 渲染带浏览按钮的文件选择器;`'time'` 渲染 HH:MM 时间选择器 | -| `placeholder` | `string` | 输入框占位提示文本的 i18n key,通过 `t()` 翻译。若未设置则 fallback 到 `default` | +选项定义使用 PI V2 标准字段。内置任务如需时间选择器或占位提示,请在 `src/types/specialTasks.ts` 的 `MXU_INPUT_UI` 中配置 `timePicker` / `placeholderKey`;这些是客户端内部界面配置,不从 `interface.json` 读取。 **switch 类型**(开关): @@ -316,6 +309,6 @@ specialTask: { - **任务添加/删除**:`addMxuSpecialTask` 根据注册表初始化选项值 - **UI 渲染**:`TaskItem` / `AddTaskPanel` / `OptionEditor` 检测 `__MXU_` 前缀自动适配 - **选项定义查找**:`OptionEditor` 通过 `findMxuOptionByKey()` 遍历所有特殊任务反查选项定义,无需选项 key 与任务名严格对应 -- **i18n 翻译**:选项的 `label`、`description`、`placeholder`、`pattern_msg` 等字段均通过 `t()` 翻译 +- **i18n 翻译**:选项的 `label`、`description`、`pattern_msg` 等字段均通过 `t()` 翻译 - **配置持久化**:`appStore` 中的配置恢复逻辑通过 `isMxuSpecialTask` 正确保留选项值 - **任务校验**:配置加载时通过 `MXU_SPECIAL_TASKS` key 集合判断任务有效性 diff --git a/src-tauri/src/commands/app_config.rs b/src-tauri/src/commands/app_config.rs index 2c42e3c7..3c7cc678 100644 --- a/src-tauri/src/commands/app_config.rs +++ b/src-tauri/src/commands/app_config.rs @@ -651,7 +651,7 @@ fn merge_imported(interface: &mut serde_json::Value, imported: &serde_json::Valu } } - // MXU 扩展:合并 setting 数组(追加到末尾,保持导入顺序) + // PI v2.8.0: 合并 setting 数组(追加到末尾,保持导入顺序) if let Some(settings) = imported.get("setting").and_then(|v| v.as_array()) { if let Some(arr) = interface.get_mut("setting").and_then(|v| v.as_array_mut()) { arr.extend(settings.iter().cloned()); diff --git a/src-tauri/src/commands/maa_agent.rs b/src-tauri/src/commands/maa_agent.rs index 1f0d798b..c20b536b 100644 --- a/src-tauri/src/commands/maa_agent.rs +++ b/src-tauri/src/commands/maa_agent.rs @@ -521,9 +521,8 @@ async fn start_single_agent( ); } - // 设置连接超时 - let timeout = agent.timeout.unwrap_or(-1); - if let Err(e) = client.set_timeout(timeout) { + // Agent 连接沿用无限等待,不从 PI 读取额外的超时字段。 + if let Err(e) = client.set_timeout(-1) { warn!("Failed to set timeout for agent #{}: {}", agent_index, e); } diff --git a/src-tauri/src/commands/maa_core.rs b/src-tauri/src/commands/maa_core.rs index c32d227a..1aee2f38 100644 --- a/src-tauri/src/commands/maa_core.rs +++ b/src-tauri/src/commands/maa_core.rs @@ -906,27 +906,6 @@ pub async fn connect_controller_impl( input_method, .. } => create_macos_controller(*handle, *screencap_method, *input_method)?, - ControllerConfig::WlRoots { - wlr_socket_path, - use_win32_vk_code, - .. - } => { - // 迁移:WlRoots 已废弃,内部改用 Linux 控制器(Wlr 截图 + Wlr 输入) - ensure_linux_maafw_version()?; - let linux_config = build_linux_controller_config( - maa_framework::common::LinuxScreencapMethod::WLR.bits(), - maa_framework::common::LinuxInputMethod::WLR.bits(), - Some(wlr_socket_path.as_str()), - None, - None, - None, - None, - None, - None, - Some(*use_win32_vk_code), - ); - Controller::new_linux(&linux_config).map_err(|e| e.to_string())? - } ControllerConfig::PlayCover { address, uuid, .. } => { let uuid_str = uuid.as_deref().unwrap_or(""); Controller::new_playcover(address, uuid_str).map_err(|e| e.to_string())? @@ -1034,9 +1013,6 @@ pub async fn connect_controller_impl( | ControllerConfig::MacOS { display_short_side, .. } - | ControllerConfig::WlRoots { - display_short_side, .. - } | ControllerConfig::Gamepad { display_short_side, .. } diff --git a/src-tauri/src/commands/types.rs b/src-tauri/src/commands/types.rs index abe9de50..3ce4dd24 100644 --- a/src-tauri/src/commands/types.rs +++ b/src-tauri/src/commands/types.rs @@ -96,13 +96,6 @@ pub enum ControllerConfig { #[serde(default)] display_short_side: Option, }, - WlRoots { - wlr_socket_path: String, - #[serde(default)] - use_win32_vk_code: bool, - #[serde(default)] - display_short_side: Option, - }, Gamepad { handle: u64, #[serde(default)] @@ -402,8 +395,6 @@ pub struct AgentConfig { pub child_exec: String, pub child_args: Option>, pub identifier: Option, - /// 连接超时时间(毫秒),-1 表示无限等待 - pub timeout: Option, } /// 任务配置 diff --git a/src/components/ConnectionPanel.tsx b/src/components/ConnectionPanel.tsx index 499d1d7e..341642af 100644 --- a/src/components/ConnectionPanel.tsx +++ b/src/components/ConnectionPanel.tsx @@ -355,10 +355,7 @@ export function ConnectionPanel() { // 判断是否需要搜索设备(PlayCover 不需要搜索) const needsDeviceSearch = - controllerType === 'Adb' || - isDesktopWindowController || - controllerType === 'WlRoots' || - controllerType === 'Linux'; + controllerType === 'Adb' || isDesktopWindowController || controllerType === 'Linux'; // 记录上一次的控制器名称,用于检测切换 const prevControllerNameRef = useRef(currentControllerName); @@ -392,7 +389,6 @@ export function ConnectionPanel() { savedDevice && ((controllerType === 'Adb' && savedDevice.adbDeviceName) || (isDesktopWindowController && savedDevice.windowName) || - (controllerType === 'WlRoots' && savedDevice.wlrSocketPath) || (controllerType === 'Linux' && ((savedDevice.gamescopeDisplayNo !== undefined && (linuxNeeds?.needGamescopeNode || linuxNeeds?.needEisSocket)) || @@ -503,36 +499,6 @@ export function ConnectionPanel() { // 有保存窗口但匹配失败,显示下拉框让用户选择 setShowDeviceDropdown(true); } - } else if (controllerType === 'WlRoots') { - const sockets = await maaService.findWlrootsSockets(); - setCachedWlrootsSockets(sockets); - - let autoSelected: string | null = null; - if (savedDevice?.wlrSocketPath) { - const matched = sockets.filter((s) => s === savedDevice.wlrSocketPath); - if (matched.length === 1) { - autoSelected = matched[0]; - } - } else if (sockets.length > 0) { - // 没有保存连接时自动选择第一个 - autoSelected = sockets[0]; - // 给出首次自动匹配提示 - if (instanceId) { - addLog(instanceId, { - type: 'info', - message: t('taskList.autoConnect.autoSelectedDevice', { - name: autoSelected, - }), - }); - } - } - - if (autoSelected) { - handleSelectWlrootsSocket(autoSelected); - } else if (sockets.length > 0) { - // 有保存连接但匹配失败,显示下拉框让用户选择 - setShowDeviceDropdown(true); - } } else if (controllerType === 'Linux' && linuxNeeds) { // 发现所需的全部设备(按当前配置的截图/输入方法) let wlrSockets: string[] = []; @@ -672,14 +638,6 @@ export function ConnectionPanel() { config = desktopConfig; deviceName = selectedWindow.window_name || selectedWindow.class_name; targetType = 'window'; - } else if (controllerType === 'WlRoots' && selectedWlrootsSocket) { - config = { - type: 'WlRoots', - wlr_socket_path: selectedWlrootsSocket, - use_win32_vk_code: currentController?.wlroots?.use_win32_vk_code ?? false, - }; - deviceName = selectedWlrootsSocket; - targetType = 'device'; } else if (controllerType === 'PlayCover') { // 保存 PlayCover 地址到实例配置 setInstanceSavedDevice(instanceId, { playcoverAddress }); @@ -817,7 +775,6 @@ export function ConnectionPanel() { case 'Adb': return ; case 'Win32': - case 'WlRoots': case 'Linux': return ; case 'MacOS': @@ -854,16 +811,7 @@ export function ConnectionPanel() { } return t('controller.selectWindow'); } - if (controllerType === 'WlRoots') { - if (selectedWlrootsSocket) { - return selectedWlrootsSocket; - } - // 缓存为空但有历史窗口名称 - if (savedDevice?.wlrSocketPath) { - return savedDevice.wlrSocketPath; - } - return t('controller.selectDevice'); - } + if (controllerType === 'Linux') { const parts: string[] = []; if (linuxNeeds?.needGamescopeNode || linuxNeeds?.needEisSocket) { @@ -993,48 +941,6 @@ export function ConnectionPanel() { } }; - // 选择 WlRoots socket 并自动连接(如已连接会先断开旧连接) - const handleSelectWlrootsSocket = async (socketPath: string) => { - setSelectedWlrootsSocket(socketPath); - setShowDeviceDropdown(false); - - // 保存 socket path 到实例配置 - setInstanceSavedDevice(instanceId, { wlrSocketPath: socketPath }); - - // 自动连接 - setIsConnecting(true); - setDeviceError(null); - - try { - // 先断开旧连接 - if (isConnected) { - await maaService.destroyInstance(instanceId).catch(() => {}); - setIsConnected(false); - setInstanceResourceLoaded(instanceId, false); - } - - const initialized = await ensureMaaInitialized(); - if (!initialized) { - throw new Error(t('maa.initFailed')); - } - - await maaService.createInstance(instanceId).catch(() => {}); - - const config: ControllerConfig = { - type: 'WlRoots', - wlr_socket_path: socketPath, - use_win32_vk_code: currentController?.wlroots?.use_win32_vk_code ?? false, - }; - - await connectControllerInternal(config, socketPath, 'device'); - } catch (err) { - setDeviceError(err instanceof Error ? err.message : t('controller.connectionFailed')); - setIsConnected(false); - setInstanceConnectionStatus(instanceId, 'Disconnected'); - setIsConnecting(false); - } - }; - // === Linux 控制器连接逻辑 === // Linux:判断给定选择是否满足当前配置所需的全部设备 @@ -1195,27 +1101,6 @@ export function ConnectionPanel() { if (windows.length > 0) { setShowDeviceDropdown(true); } - } else if (controllerType === 'WlRoots') { - const sockets = await maaService.findWlrootsSockets(); - setCachedWlrootsSockets(sockets); - - // 尝试匹配保存的窗口名称 - if (savedDevice?.wlrSocketPath) { - const matched = sockets.find((s) => s === savedDevice.wlrSocketPath); - if (matched) { - // 找到匹配的,自动连接 - setIsSearching(false); - handleSelectWlrootsSocket(matched); - return; - } - // 没找到匹配的,显示错误提示 - setDeviceError(t('controller.savedDeviceNotFound')); - } - - // 显示搜索结果供用户选择 - if (sockets.length > 0) { - setShowDeviceDropdown(true); - } } else if (controllerType === 'Linux' && linuxNeeds) { // Linux:复用 handleSearch 的发现+匹配+自动连接逻辑 setIsSearching(false); @@ -1291,35 +1176,7 @@ export function ConnectionPanel() { return []; } - if (controllerType === 'WlRoots') { - // 如果缓存中有窗口,使用缓存列表 - if (cachedWlrootsSockets.length > 0) { - return cachedWlrootsSockets.map((socket) => ({ - id: `wlr:${socket}`, - name: socket, - description: socket, - selected: selectedWlrootsSocket === socket, - onClick: () => handleSelectWlrootsSocket(socket), - isHistorical: false, - })); - } - // 缓存为空但有历史窗口名称,显示历史窗口条目 - if (savedDevice?.wlrSocketPath) { - return [ - { - id: 'historical-wlroots-socket', - name: savedDevice.wlrSocketPath, - description: t('controller.lastSelected'), - selected: true, - onClick: handleSearchAndConnectHistorical, - isHistorical: true, - }, - ]; - } - - return []; - } if (controllerType === 'Linux' && linuxNeeds) { // 展示所有已发现的选择项(含当前选中项),保证下拉不为空 const instanceItems = @@ -1363,7 +1220,7 @@ export function ConnectionPanel() { const canConnect = () => { if (controllerType === 'Adb') return !!selectedAdbDevice; if (isDesktopWindowController) return !!selectedWindow; - if (controllerType === 'WlRoots') return !!selectedWlrootsSocket; + if (controllerType === 'Linux') { return isLinuxReadyWith(selectedGamescopeInstance, selectedWlrootsSocket); } @@ -1437,7 +1294,6 @@ export function ConnectionPanel() { return ( s.adbDeviceName || s.windowName || - (controllerType === 'WlRoots' && s.wlrSocketPath) || (controllerType === 'Linux' && ((s.gamescopeDisplayNo !== undefined && (linuxNeeds?.needGamescopeNode || linuxNeeds?.needEisSocket)) || diff --git a/src/components/DeviceSelector.tsx b/src/components/DeviceSelector.tsx index 2546c918..ab3aabcf 100644 --- a/src/components/DeviceSelector.tsx +++ b/src/components/DeviceSelector.tsx @@ -50,17 +50,14 @@ export function DeviceSelector({ const { cachedAdbDevices, cachedWin32Windows, - cachedWlrootsSockets, setCachedAdbDevices, setCachedWin32Windows, - setCachedWlrootsSockets, registerCtrlIdName, } = useAppStore(); // 选中的设备(本地状态) const [selectedAdbDevice, setSelectedAdbDevice] = useState(null); const [selectedWindow, setSelectedWindow] = useState(null); - const [selectedWlrootsSocket, setSelectedWlrootsSocket] = useState(null); const [showDropdown, setShowDropdown] = useState(false); @@ -146,8 +143,7 @@ export function DeviceSelector({ }, [pendingCtrlId, onConnectionChange]); // 判断是否需要搜索设备(PlayCover 不需要搜索) - const needsDeviceSearch = - controllerType === 'Adb' || isDesktopWindowController || controllerType === 'WlRoots'; + const needsDeviceSearch = controllerType === 'Adb' || isDesktopWindowController; // 初始化 MaaFramework(如果还没初始化) const ensureMaaInitialized = async () => { @@ -196,15 +192,6 @@ export function DeviceSelector({ if (windows.length > 0) { setShowDropdown(true); } - } else if (controllerType === 'WlRoots') { - const sockets = await maaService.findWlrootsSockets(); - setCachedWlrootsSockets(sockets); - if (sockets.length === 1) { - setSelectedWlrootsSocket(sockets[0]); - } - if (sockets.length > 0) { - setShowDropdown(true); - } } } catch (err) { log.error('搜索设备失败:', err); @@ -250,12 +237,6 @@ export function DeviceSelector({ throw new Error(t('controller.selectWindow')); } config = desktopConfig; - } else if (controllerType === 'WlRoots' && selectedWlrootsSocket) { - config = { - type: 'WlRoots', - wlr_socket_path: selectedWlrootsSocket, - use_win32_vk_code: controllerDef.wlroots?.use_win32_vk_code ?? false, - }; } else if (controllerType === 'PlayCover') { config = { type: 'PlayCover', @@ -277,9 +258,6 @@ export function DeviceSelector({ } else if (isDesktopWindowController && selectedWindow) { deviceName = selectedWindow.window_name || selectedWindow.class_name; targetType = 'window'; - } else if (controllerType === 'WlRoots' && selectedWlrootsSocket) { - deviceName = selectedWlrootsSocket; - targetType = 'device'; } else if (controllerType === 'PlayCover') { deviceName = playcoverAddress; targetType = 'device'; @@ -318,9 +296,7 @@ export function DeviceSelector({ if (isDesktopWindowController && selectedWindow) { return selectedWindow.window_name || selectedWindow.class_name; } - if (controllerType === 'WlRoots' && selectedWlrootsSocket) { - return selectedWlrootsSocket; - } + return t('controller.selectController'); }; @@ -405,45 +381,6 @@ export function DeviceSelector({ } }; - // 选择 WlRoots socket 并自动连接 - const handleSelectWlrootsSocket = async (socketPath: string) => { - setSelectedWlrootsSocket(socketPath); - setShowDropdown(false); - - // 自动连接 - setIsConnecting(true); - setError(null); - - try { - const initialized = await ensureMaaInitialized(); - if (!initialized) { - throw new Error('无法初始化 MaaFramework,请确保 MaaFramework 动态库在正确的位置'); - } - - await maaService.createInstance(instanceId).catch(() => {}); - - const config: ControllerConfig = { - type: 'WlRoots', - wlr_socket_path: socketPath, - use_win32_vk_code: controllerDef.wlroots?.use_win32_vk_code ?? false, - }; - - const ctrlId = await maaService.connectController(instanceId, config); - - // 注册 ctrl_id 与窗口名/类型的映射 - registerCtrlIdName(instanceId, ctrlId, socketPath, 'device'); - - // 记录等待中的 ctrl_id,后续由回调处理完成状态 - setPendingCtrlId(ctrlId); - } catch (err) { - log.error('自动连接失败:', err); - setError(err instanceof Error ? err.message : '连接失败'); - setIsConnected(false); - onConnectionChange?.(false); - setIsConnecting(false); - } - }; - // 获取设备列表 const getDeviceList = () => { if (controllerType === 'Adb') { @@ -464,15 +401,7 @@ export function DeviceSelector({ onClick: () => handleSelectWindow(window), })); } - if (controllerType === 'WlRoots') { - return cachedWlrootsSockets.map((socket) => ({ - id: `wlr:${socket}`, - name: socket, - description: socket, - selected: selectedWlrootsSocket === socket, - onClick: () => handleSelectWlrootsSocket(socket), - })); - } + return []; }; @@ -480,7 +409,7 @@ export function DeviceSelector({ const canConnect = () => { if (controllerType === 'Adb') return !!selectedAdbDevice; if (isDesktopWindowController) return !!selectedWindow; - if (controllerType === 'WlRoots') return !!selectedWlrootsSocket; + if (controllerType === 'PlayCover') return playcoverAddress.trim().length > 0; return false; }; @@ -493,7 +422,6 @@ export function DeviceSelector({ case 'Adb': return ; case 'Win32': - case 'WlRoots': return ; case 'MacOS': case 'PlayCover': @@ -512,8 +440,6 @@ export function DeviceSelector({ return t('controller.adb'); case 'Win32': return t('controller.win32'); - case 'WlRoots': - return t('controller.wlroots'); case 'PlayCover': return t('controller.playcover'); case 'MacOS': @@ -587,13 +513,7 @@ export function DeviceSelector({ )} - + {label} ); } @@ -257,7 +255,7 @@ function InputField({ onChange, langKey, resolveI18nText, - basePath, + mxuUi, disabled, isMxuOption = false, isHotkey = false, @@ -268,7 +266,7 @@ function InputField({ onChange: (val: string) => void; langKey: string; resolveI18nText: (text: string | undefined, lang: string) => string; - basePath: string; + mxuUi?: MxuInputUi; disabled?: boolean; isMxuOption?: boolean; isHotkey?: boolean; @@ -292,11 +290,9 @@ function InputField({ : undefined : resolveI18nText(input.pattern_msg, langKey); const inputPlaceholder = - isMxuOption && t - ? input.placeholder - ? t(input.placeholder) - : input.default || undefined - : resolveI18nText(input.placeholder, langKey) || input.default || undefined; + isMxuOption && t && mxuUi?.placeholderKey + ? t(mxuUi.placeholderKey) + : input.default || undefined; // 验证输入 const validationError = useMemo(() => { @@ -316,14 +312,7 @@ function InputField({
- {input.icon && ( - - )} - + {inputLabel} {inputDescription && ( @@ -338,15 +327,16 @@ function InputField({ disabled={disabled} className="min-w-[min(12rem,100%)] flex-1 basis-[30%]" /> - ) : input.input_type === 'file' ? ( + ) : input.input_type === 'file' || input.input_type === 'directory' ? ( - ) : input.input_type === 'time' ? ( + ) : isMxuOption && mxuUi?.timePicker ? ( )} - + {caseLabel} ); @@ -716,7 +706,15 @@ export function OptionEditor({ // Input / Hotkey 类型 if (optionDef.type === 'input' || optionDef.type === 'hotkey') { - const fields = optionDef.type === 'input' ? optionDef.inputs : optionDef.hotkeys; + const fields = + optionDef.type === 'input' + ? optionDef.inputs + : optionDef.hotkeys.map(({ name, label, description, default: defaultValue }) => ({ + name, + label, + description, + default: defaultValue, + })); const inputValues = effectiveValue?.type === 'input' || effectiveValue?.type === 'hotkey' ? effectiveValue.values @@ -762,7 +760,7 @@ export function OptionEditor({ }} langKey={langKey} resolveI18nText={resolveI18nText} - basePath={basePath} + mxuUi={isMxuOption ? getMxuInputUi(optionKey, input.name) : undefined} disabled={effectiveDisabled} isMxuOption={isMxuOption} t={t} @@ -1153,7 +1151,7 @@ function OptionSelectDropdown({ className="w-4 h-4 object-contain flex-shrink-0" /> )} - + {selectedOption?.label ?? ''} {opt.icon && ( @@ -1215,7 +1213,7 @@ function OptionSelectDropdown({ className="w-4 h-4 object-contain flex-shrink-0" /> )} - + {opt.label} {isSelected && } @@ -1250,14 +1248,12 @@ function OptionSelectComboBox({ const selectedOption = options.find((opt) => opt.value === value) ?? options[0]; - // 过滤选项:label 可能含行内 Markdown(图标等),按去掉标记的纯文本匹配 + // 按选项名称和取值过滤 const filteredOptions = useMemo(() => { if (!searchQuery.trim()) return options; const query = searchQuery.toLowerCase(); return options.filter( - (opt) => - stripInlineRichText(opt.label).toLowerCase().includes(query) || - opt.value.toLowerCase().includes(query), + (opt) => opt.label.toLowerCase().includes(query) || opt.value.toLowerCase().includes(query), ); }, [options, searchQuery]); @@ -1408,7 +1404,7 @@ function OptionSelectComboBox({ className="w-4 h-4 object-contain flex-shrink-0" /> )} - + {selectedOption?.label ?? ''} {opt.icon && ( @@ -1496,7 +1492,7 @@ function OptionSelectComboBox({ className="w-4 h-4 object-contain flex-shrink-0" /> )} - + {opt.label} {isSelected && } @@ -1514,7 +1510,6 @@ function OptionSelectComboBox({ /** Switch 网格组件的单个项 */ interface SwitchGridItemData { optionKey: string; - /** UI 展示名称,可能含行内 Markdown(图标等);原生 title 等纯文本场景需先 stripInlineRichText */ label: string; description?: string; isChecked: boolean; @@ -1563,9 +1558,9 @@ export function SwitchGrid({ instanceId, taskId, items, disabled = false }: Swit : 'bg-bg-primary text-text-secondary border-border hover:border-accent hover:text-accent', itemDisabled && 'opacity-60 cursor-not-allowed', )} - title={item.description || stripInlineRichText(item.label)} + title={item.description || item.label} > - + {item.label} ); diff --git a/src/components/RichLabel.tsx b/src/components/RichLabel.tsx deleted file mode 100644 index 0388f9a3..00000000 --- a/src/components/RichLabel.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useAppStore } from '@/stores/appStore'; -import { hasInlineRichText, stripInlineRichText } from '@/utils/richText'; -import { useInlineLabelHtml } from '@/services/contentResolver'; - -interface RichLabelProps { - /** 已经解析过国际化的 label 文本 */ - text: string; - className?: string; - /** 覆盖 tooltip 文案;默认用去掉富文本标记的纯文本 */ - title?: string; -} - -/** - * 选项 / 输入项的 label 渲染。 - * - * 资源作者可以在 label 里写行内 Markdown(如 `![](resource/image/icon/xxx.png) 名称 ★5`), - * 支持范围见 {@link hasInlineRichText}:图片 / 链接 / 行内代码 / 加粗 / 白名单行内 HTML 标签。 - * 判定不通过时直接按纯文本输出,不引入解析开销。 - * - * - 解析完成前先用纯文本占位,避免闪出 `![](xxx.png)` 这类源码; - * - tooltip 始终使用去掉标记的纯文本(不放 HTML 与 data URL); - * - 本地图片解析失败时对应 `` 会被移除,不留破图。 - */ -export function RichLabel({ text, className, title }: RichLabelProps) { - const basePath = useAppStore((state) => state.basePath); - const { html, pending } = useInlineLabelHtml(text, basePath); - const rich = hasInlineRichText(text); - const plainText = rich ? stripInlineRichText(text) : text; - - if (pending || !html) { - return ( - - {plainText} - - ); - } - - return ( - - ); -} diff --git a/src/components/TaskItem.tsx b/src/components/TaskItem.tsx index e1195426..712f3516 100644 --- a/src/components/TaskItem.tsx +++ b/src/components/TaskItem.tsx @@ -29,7 +29,6 @@ import type { SelectedTask, CaseItem } from '@/types/interface'; import { isMxuSpecialTask, getMxuSpecialTask, findMxuOptionByKey } from '@/types/specialTasks'; import { isPretaskName, getPretaskItem, buildPretaskDef } from '@/types/pretasks'; import { getInterfaceLangKey } from '@/i18n'; -import { stripInlineRichText } from '@/utils/richText'; import clsx from 'clsx'; import { loggers } from '@/utils/logger'; import { isPasswordInput } from '@/utils/passwordOptionValues'; @@ -48,9 +47,6 @@ function OptionPreviewTag({ const truncateText = (text: string, max: number) => text.length > max ? text.slice(0, max) + '…' : text; - // label 可能含行内 Markdown(图标等),预览标签空间有限,统一按纯文本处理 - const plainLabel = stripInlineRichText(label); - return ( {type === 'switch' ? ( // Switch 类型:显示选项名 + 状态圆点 <> - {truncateText(plainLabel, 6)} + {truncateText(label, 6)} - {truncateText(plainLabel, 4)} + {truncateText(label, 4)} : {truncateText(value, 6)} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 505ea102..47658f82 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -645,13 +645,6 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr } else { deviceFound = windows.length > 0; } - } else if (controllerType === 'WlRoots') { - const sockets = await maaService.findWlrootsSockets(); - if (savedDevice?.wlrSocketPath) { - deviceFound = sockets.includes(savedDevice.wlrSocketPath); - } else { - deviceFound = sockets.length > 0; - } } else { deviceFound = true; } @@ -702,16 +695,6 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr }), }); } - } else if (controllerType === 'WlRoots') { - const sockets = await maaService.findWlrootsSockets(); - if (sockets.length > 0) { - addLog(targetId, { - type: 'info', - message: t('taskList.autoConnect.autoSelectedDevice', { - name: sockets[0], - }), - }); - } } } catch { // 忽略二次搜索错误 @@ -821,20 +804,6 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr config = buildDesktopWindowControllerConfig(controller, matchedWindow.handle); deviceName = matchedWindow.window_name || matchedWindow.class_name; targetType = 'window'; - } else if (controllerType === 'WlRoots' && savedDevice.wlrSocketPath) { - const sockets = await maaService.findWlrootsSockets(); - if (!sockets.includes(savedDevice.wlrSocketPath)) { - return failStart( - t('taskList.autoConnect.deviceNotFound', { name: savedDevice.wlrSocketPath }), - ); - } - config = { - type: 'WlRoots', - wlr_socket_path: savedDevice.wlrSocketPath, - use_win32_vk_code: controller.wlroots?.use_win32_vk_code ?? false, - }; - deviceName = savedDevice.wlrSocketPath; - targetType = 'device'; } else if (controllerType === 'PlayCover' && savedDevice.playcoverAddress) { config = buildPlayCoverControllerConfig( savedDevice.playcoverAddress, @@ -903,26 +872,6 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr config = buildDesktopWindowControllerConfig(controller, firstWindow.handle); deviceName = firstWindow.window_name || firstWindow.class_name; targetType = 'window'; - } else if (controllerType === 'WlRoots') { - const sockets = await maaService.findWlrootsSockets(); - if (sockets.length === 0) { - return failStart(t('taskList.autoConnect.noDeviceFound')); - } - const firstSocket = sockets[0]; - log.info(`实例 ${targetInstance.name}: 自动选择 WlRoots socket: ${firstSocket}`); - addLog(targetId, { - type: 'info', - message: t('taskList.autoConnect.autoSelectedDevice', { - name: firstSocket, - }), - }); - config = { - type: 'WlRoots', - wlr_socket_path: firstSocket, - use_win32_vk_code: controller.wlroots?.use_win32_vk_code ?? false, - }; - deviceName = firstSocket; - targetType = 'device'; } else if (controllerType === 'PlayCover') { // PlayCover 没有搜索功能,无法自动连接 return failStart(t('taskList.autoConnect.needConfig')); diff --git a/src/components/connection/useDeviceConnection.ts b/src/components/connection/useDeviceConnection.ts index 9a9d6977..570342c2 100644 --- a/src/components/connection/useDeviceConnection.ts +++ b/src/components/connection/useDeviceConnection.ts @@ -27,10 +27,8 @@ export function useDeviceConnection({ const { cachedAdbDevices, cachedWin32Windows, - cachedWlrootsSockets, setCachedAdbDevices, setCachedWin32Windows, - setCachedWlrootsSockets, setInstanceConnectionStatus, setInstanceResourceLoaded, setInstanceSavedDevice, @@ -46,7 +44,6 @@ export function useDeviceConnection({ const [deviceError, setDeviceError] = useState(null); const [selectedAdbDevice, setSelectedAdbDevice] = useState(null); const [selectedWindow, setSelectedWindow] = useState(null); - const [selectedWlrootsSocket, setSelectedWlrootsSocket] = useState(null); const [showDeviceDropdown, setShowDeviceDropdown] = useState(false); const [playcoverAddress, setPlaycoverAddress] = useState( activeInstance?.savedDevice?.playcoverAddress || '127.0.0.1:1717', @@ -151,25 +148,6 @@ export function useDeviceConnection({ } else if (windows.length > 0) { setShowDeviceDropdown(true); } - } else if (controllerType === 'WlRoots') { - const sockets = await maaService.findWlrootsSockets(); - setCachedWlrootsSockets(sockets); - - let autoSelected: string | null = null; - if (savedDevice?.wlrSocketPath) { - const matched = sockets.filter((s) => s === savedDevice.wlrSocketPath); - if (matched.length === 1) { - autoSelected = matched[0]; - } - } else if (sockets.length > 0) { - autoSelected = sockets[0]; - } - - if (autoSelected) { - handleSelectWlrootsSocket(autoSelected); - } else if (sockets.length > 0) { - setShowDeviceDropdown(true); - } } } catch (err) { setDeviceError(err instanceof Error ? err.message : t('controller.connectionFailed')); @@ -185,7 +163,6 @@ export function useDeviceConnection({ desktopWindowTitleRegex, setCachedAdbDevices, setCachedWin32Windows, - setCachedWlrootsSockets, t, ]); @@ -294,56 +271,6 @@ export function useDeviceConnection({ ], ); - // 选择 WlRoots socket 并自动连接 - const handleSelectWlrootsSocket = useCallback( - async (socketPath: string) => { - setSelectedWlrootsSocket(socketPath); - setShowDeviceDropdown(false); - - setInstanceSavedDevice(instanceId, { wlrSocketPath: socketPath }); - - setIsConnecting(true); - setDeviceError(null); - - try { - if (isConnected) { - await maaService.destroyInstance(instanceId).catch(() => {}); - setIsConnected(false); - setInstanceResourceLoaded(instanceId, false); - } - - const initialized = await ensureMaaInitialized(); - if (!initialized) { - throw new Error(t('maa.initFailed')); - } - - await maaService.createInstance(instanceId).catch(() => {}); - - const config: ControllerConfig = { - type: 'WlRoots', - wlr_socket_path: socketPath, - use_win32_vk_code: currentController?.wlroots?.use_win32_vk_code ?? false, - }; - - await connectControllerInternal(config, socketPath, 'device'); - } catch (err) { - setDeviceError(err instanceof Error ? err.message : t('controller.connectionFailed')); - setIsConnected(false); - setInstanceConnectionStatus(instanceId, 'Disconnected'); - setIsConnecting(false); - } - }, - [ - instanceId, - isConnected, - setInstanceSavedDevice, - setInstanceConnectionStatus, - setInstanceResourceLoaded, - connectControllerInternal, - t, - ], - ); - // PlayCover 连接 const handleConnectPlayCover = useCallback(async () => { setIsConnecting(true); @@ -404,22 +331,13 @@ export function useDeviceConnection({ } return t('controller.selectWindow'); } - if (controllerType === 'WlRoots') { - if (selectedWlrootsSocket) { - return selectedWlrootsSocket; - } - if (savedDevice?.wlrSocketPath) { - return savedDevice.wlrSocketPath; - } - return t('controller.selectDevice'); - } + return t('controller.selectDevice'); }, [ controllerType, isDesktopWindowController, selectedAdbDevice, selectedWindow, - selectedWlrootsSocket, activeInstance?.savedDevice, t, ]); @@ -428,7 +346,7 @@ export function useDeviceConnection({ const canConnect = useCallback(() => { if (controllerType === 'Adb') return !!selectedAdbDevice; if (isDesktopWindowController) return !!selectedWindow; - if (controllerType === 'WlRoots') return !!selectedWlrootsSocket; + if (controllerType === 'PlayCover') return playcoverAddress.trim().length > 0; return false; }, [ @@ -436,7 +354,6 @@ export function useDeviceConnection({ isDesktopWindowController, selectedAdbDevice, selectedWindow, - selectedWlrootsSocket, playcoverAddress, ]); @@ -452,7 +369,6 @@ export function useDeviceConnection({ playcoverAddress, cachedAdbDevices, cachedWin32Windows, - cachedWlrootsSockets, // Refs deviceDropdownRef, deviceMenuRef, diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index dd052f03..922b7293 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -407,7 +407,6 @@ export default { selectController: 'Select Controller', adb: 'Android Device', win32: 'Windows Window', - wlroots: 'WlRoots (Linux)', linux: 'Linux', portal: 'Portal', uinputWidth: 'Width (px)', diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts index 52e6a68e..7572faac 100644 --- a/src/i18n/locales/ja-JP.ts +++ b/src/i18n/locales/ja-JP.ts @@ -402,7 +402,6 @@ export default { selectController: 'コントローラーを選択', adb: 'Android デバイス', win32: 'Windows ウィンドウ', - wlroots: 'WlRoots (Linux)', linux: 'Linux', portal: 'Portal', uinputWidth: '幅 (px)', diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts index 04917402..75b2e4a3 100644 --- a/src/i18n/locales/ko-KR.ts +++ b/src/i18n/locales/ko-KR.ts @@ -397,7 +397,6 @@ export default { selectController: '컨트롤러 선택', adb: 'Android 기기', win32: 'Windows 창', - wlroots: 'WlRoots (Linux)', linux: 'Linux', portal: 'Portal', uinputWidth: '너비 (px)', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index cdbca16a..4a55e344 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -394,7 +394,6 @@ export default { selectController: '选择控制器', adb: 'Android 设备', win32: 'Windows 窗口', - wlroots: 'WlRoots (Linux)', linux: 'Linux', portal: 'Portal', uinputWidth: '宽 (px)', diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index fa4098b9..bb48ff1d 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -390,7 +390,6 @@ export default { selectController: '選擇控制器', adb: 'Android 裝置', win32: 'Windows 視窗', - wlroots: 'WlRoots (Linux)', linux: 'Linux', portal: 'Portal', uinputWidth: '寬 (px)', diff --git a/src/services/contentResolver.ts b/src/services/contentResolver.ts index 9ed8b52c..bbc0f9d8 100644 --- a/src/services/contentResolver.ts +++ b/src/services/contentResolver.ts @@ -9,12 +9,11 @@ import { invoke } from '@tauri-apps/api/core'; import { loggers } from '@/utils/logger'; -import { marked, Marked, type RendererObject } from 'marked'; +import { marked, type RendererObject } from 'marked'; import DOMPurify from 'dompurify'; import { cachedFetch } from './cacheService'; import { isTauri, joinPath } from '@/utils/paths'; import { getApiBase } from '@/utils/backendApi'; -import { hasInlineRichText } from '@/utils/richText'; const log = loggers.app; @@ -349,24 +348,6 @@ export function resolveIconPath( return resolved; } -/** - * 行内 Markdown 渲染:把 label 里的 Markdown 转成 HTML,并把本地图片转成 data URL。 - * - * 与 description 的区别: - * 1. 使用 {@link inlineMarked},段落不包 `

`,方便放进 label 的 ``; - * 2. 图片按行内小图标处理(16px、垂直居中、无上下外边距), - * 与 option case 的 `icon` 字段(`w-4 h-4`)视觉一致; - * 3. 相对路径图片读取失败时直接移除 ``,label 空间很小,不留破图占位。 - */ -export async function inlineMarkdownToHtmlWithLocalImages( - markdown: string, - basePath: string = '', -): Promise { - return resolveLocalImages(markdownToHtml(markdown, inlineMarked), basePath, { - dropFailedImages: true, - }); -} - /** * 图标 data URL 缓存(会话内)。 * @@ -481,8 +462,6 @@ function getMimeType(ext: string): string { /** * 全局 Markdown 渲染样式(description、focus 日志等共用),使用 Tailwind class。 - * - * 行内 label 复用同一份样式,只覆盖 paragraph / image(见 {@link inlineMarked})。 */ const tailwindRenderer: RendererObject = { heading({ tokens, depth }) { @@ -590,36 +569,6 @@ const tailwindRenderer: RendererObject = { // 配置全局 marked 实例(description、focus 日志等) marked.use({ breaks: true, gfm: true, renderer: tailwindRenderer }); -/** 行内 label 里图片的样式:16px 行内图标,与 option case 的 `icon` 字段(`w-4 h-4`)视觉一致 */ -const INLINE_IMAGE_CLASS = 'mx-1 inline-block h-4 w-4 object-contain rounded-sm align-middle'; - -/** - * 行内 label 专用实例。 - * - * 用独立实例而不是"解析后正则改写 HTML",避免与渲染器产出的 class 字面量强耦合: - * 1. `paragraph` 直接返回行内内容,不包 `

`; - * 2. `image` 输出 16px 行内图标,而不是 description 里的大图。 - */ -const inlineMarked = new Marked({ - breaks: true, - gfm: true, - renderer: { - ...tailwindRenderer, - paragraph({ tokens }) { - return this.parser.parseInline(tokens); - }, - image({ href, title, text }) { - const titleAttr = title ? ` title="${title}"` : ''; - return `${text}`; - }, - }, -}); - -/** markdownToHtml 需要的最小解析能力;全局 marked 与 {@link inlineMarked} 都满足 */ -interface MarkdownParser { - parse(src: string, options: { async: false }): string; -} - /** * DOMPurify 配置:集中一处,避免不同调用点的白名单漂移。 * @@ -633,19 +582,9 @@ const PURIFY_CONFIG = { ADD_ATTR: ['target', 'rel', 'style'] }; * 使用 marked 解析 markdown,使用 DOMPurify 清理 HTML 防止 XSS * * @param markdown Markdown 文本 - * @param parser 使用的 marked 实例;默认全局实例,行内 label 传入 {@link inlineMarked} */ -export function markdownToHtml(markdown: string, parser: MarkdownParser = marked): string { - return DOMPurify.sanitize(parser.parse(markdown, { async: false }), PURIFY_CONFIG); -} - -/** 本地图片处理选项 */ -export interface LocalImageOptions { - /** - * 相对路径图片读取失败时是否移除对应 ``。 - * 默认 false(保留原始 src,交给渲染方决定降级方式);label 等窄空间场景传 true。 - */ - dropFailedImages?: boolean; +export function markdownToHtml(markdown: string): string { + return DOMPurify.sanitize(marked.parse(markdown, { async: false }), PURIFY_CONFIG); } /** @@ -658,11 +597,7 @@ export interface LocalImageOptions { * * 用 DOM 而不是正则改写:属性顺序、alt/title 里的特殊字符、路径里的引号都不会影响结果。 */ -async function resolveLocalImages( - html: string, - basePath: string, - options: LocalImageOptions = {}, -): Promise { +async function resolveLocalImages(html: string, basePath: string): Promise { // 解析富文本片段用 Range.createContextualFragment,而不是 `container.innerHTML = ...`。 // 值本身已经是 DOMPurify 的产物,但静态扫描(opengrep 的 insecure-innerhtml / // insecure-document-method)对 innerHTML 写入没有 sanitizer 例外,会一直把它报成 XSS 风险; @@ -681,8 +616,6 @@ async function resolveLocalImages( const dataUrl = await loadIconAsDataUrl(src, basePath); if (dataUrl) { img.setAttribute('src', dataUrl); - } else if (options.dropFailedImages) { - img.remove(); } }), ); @@ -694,15 +627,13 @@ async function resolveLocalImages( * 将 Markdown 转换为 HTML,并将相对路径图片转换为 data URL * @param markdown Markdown 文本 * @param basePath 资源基础路径(用于解析相对路径图片) - * @param options 本地图片处理选项 * @returns 处理后的 HTML */ export async function markdownToHtmlWithLocalImages( markdown: string, basePath: string = '', - options: LocalImageOptions = {}, ): Promise { - return resolveLocalImages(markdownToHtml(markdown), basePath, options); + return resolveLocalImages(markdownToHtml(markdown), basePath); } /** @@ -716,46 +647,6 @@ export const simpleMarkdownToHtml = markdownToHtml; import { useState, useEffect } from 'react'; -/** {@link useInlineLabelHtml} 的返回值 */ -export interface InlineLabelHtmlResult { - /** 解析完成的 HTML;不含富文本标记、或尚未解析完成时为空串 */ - html: string; - /** 是否正在解析。为 true 时调用方应先用纯文本占位,避免闪出 Markdown 源码 */ - pending: boolean; -} - -/** - * React Hook:把 label 文本渲染成行内 HTML。 - * - * 结果按 `basePath|text` 记账:只有解析结果与当前文本匹配时才返回 HTML, - * 因此文本变化时不会短暂返回上一条 label 的 HTML(图标串台)。 - */ -export function useInlineLabelHtml( - text: string | undefined, - basePath: string = '', -): InlineLabelHtmlResult { - // 纯文本 label 不解析:绝大多数 label 走这条快路径,不付 marked / 读图开销 - const key = text && hasInlineRichText(text) ? `${basePath}|${text}` : ''; - const [resolved, setResolved] = useState<{ key: string; html: string }>({ key: '', html: '' }); - - useEffect(() => { - if (!text || !hasInlineRichText(text)) { - setResolved({ key: '', html: '' }); - return; - } - let cancelled = false; - inlineMarkdownToHtmlWithLocalImages(text, basePath).then((html) => { - if (!cancelled) setResolved({ key: `${basePath}|${text}`, html }); - }); - return () => { - cancelled = true; - }; - }, [text, basePath]); - - if (!key || resolved.key !== key) return { html: '', pending: !!key }; - return { html: resolved.html, pending: false }; -} - /** useResolvedContent Hook 的返回值 */ export interface UseResolvedContentResult { /** 解析后的内容 */ diff --git a/src/services/interfaceLoader.ts b/src/services/interfaceLoader.ts index aa3744c2..5fcfa36d 100644 --- a/src/services/interfaceLoader.ts +++ b/src/services/interfaceLoader.ts @@ -9,7 +9,7 @@ import type { InterfaceSettingSection, PretaskItem, } from '@/types/interface'; -import { normalizePretaskConfigs } from '@/types/interface'; +import { isControllerType, normalizePretaskConfigs } from '@/types/interface'; import { loggers } from '@/utils/logger'; import { parseJsonc } from '@/utils/jsonc'; import { isTauri } from '@/utils/paths'; @@ -29,7 +29,7 @@ interface ImportableInterface { preset?: PresetItem[]; /** v2.4.0: 支持导入 group */ group?: GroupItem[]; - /** MXU 扩展:支持导入 setting */ + /** v2.8.0: 支持导入 setting */ setting?: InterfaceSettingSection[]; /** v2.7.0: 支持导入 pretask */ pretask?: PretaskItem | PretaskItem[]; @@ -256,7 +256,7 @@ function mergeImported(pi: ProjectInterface, imported: ImportableInterface): voi log.info(`合并了 ${Object.keys(imported.option).length} 个导入的 option`); } - // MXU 扩展:合并 setting 数组(追加到末尾,保持导入顺序) + // v2.8.0: 合并 setting 数组(追加到末尾,保持导入顺序) if (imported.setting && imported.setting.length > 0) { pi.setting = [...(pi.setting || []), ...imported.setting]; log.info(`合并了 ${imported.setting.length} 个导入的 setting`); @@ -373,9 +373,8 @@ function getUnsupportedControllerTypes(os: string): Set { unsupported.add('MacOS'); unsupported.add('PlayCover'); } - // 非 Linux 系统不支持 WlRoots 和 Linux 控制器 + // 非 Linux 系统不支持 Linux 控制器 if (!isLinux) { - unsupported.add('WlRoots'); unsupported.add('Linux'); } return unsupported; @@ -387,18 +386,10 @@ function getUnsupportedControllerTypes(os: string): Set { * @param os 后端真实 OS;空串时回退到浏览器平台(dev 预览) */ function filterControllersByPlatform(pi: ProjectInterface, os: string): void { - // WlRoots 已废弃:仍保留兼容(类型合法),但打印 deprecated 提示 - for (const c of pi.controller) { - if (c.type === 'WlRoots') { - log.warn(`[interface] 控制器 "${c.name}" 使用了已废弃的 WlRoots 类型,请迁移到 Linux 控制器`); - } - } - const unsupported = getUnsupportedControllerTypes(os); - if (unsupported.size === 0) return; const originalCount = pi.controller.length; - pi.controller = pi.controller.filter((c) => !unsupported.has(c.type)); + pi.controller = pi.controller.filter((c) => isControllerType(c.type) && !unsupported.has(c.type)); const filteredCount = originalCount - pi.controller.length; if (filteredCount > 0) { diff --git a/src/services/telemetryService.ts b/src/services/telemetryService.ts index cee86306..83c00530 100644 --- a/src/services/telemetryService.ts +++ b/src/services/telemetryService.ts @@ -39,12 +39,10 @@ const MAX_OPTION_VALUE_LENGTH = 512; const summarizeInputValue = ( value: string, pipelineType?: 'string' | 'int' | 'bool', - inputType?: 'text' | 'file' | 'time', password?: boolean, ): string => { if (password) return value ? 'filled' : 'empty'; - const isSafeToReport = - pipelineType === 'int' || pipelineType === 'bool' || (inputType === 'time' && !!value); + const isSafeToReport = pipelineType === 'int' || pipelineType === 'bool'; if (isSafeToReport) return value; return value ? 'filled' : 'empty'; }; @@ -103,12 +101,7 @@ const collectOptionSummary = ( const raw = optionValue.values[inputDef.name] ?? inputDef.default ?? ''; put( `${optionKey}.${inputDef.name}`, - summarizeInputValue( - raw, - inputDef.pipeline_type, - inputDef.input_type, - isPasswordInput(inputDef), - ), + summarizeInputValue(raw, inputDef.pipeline_type, isPasswordInput(inputDef)), ); } } diff --git a/src/types/interface.ts b/src/types/interface.ts index 523f5b7d..d9043750 100644 --- a/src/types/interface.ts +++ b/src/types/interface.ts @@ -25,7 +25,7 @@ export interface ProjectInterface { /** v2.3.0: 全局选项配置,参与到所有任务的 pipeline override 中 */ global_option?: string[]; /** - * MXU 扩展:任务设置页的 UI 元数据 + * v2.8.0: 任务设置页的 UI 元数据 * 仅描述如何展示设置区域,不定义 option 本身;实际可编辑项仍复用 `option` */ setting?: InterfaceSettingSection[]; @@ -98,7 +98,7 @@ export function normalizePretaskConfigs( return Array.isArray(pretask) ? pretask : [pretask]; } -/** MXU 扩展:任务设置页 section 定义 */ +/** v2.8.0: 任务设置页 section 定义 */ export interface InterfaceSettingSection { name: string; label?: string; @@ -121,8 +121,6 @@ export interface AgentConfig { child_exec: string; child_args?: string[]; identifier?: string; - /** 连接超时时间(毫秒),-1 表示无限等待 */ - timeout?: number; } /** @@ -136,14 +134,13 @@ export function normalizeAgentConfigs( return Array.isArray(agent) ? agent : [agent]; } -export type ControllerType = - | 'Adb' - | 'Win32' - | 'MacOS' - | 'WlRoots' - | 'Linux' - | 'PlayCover' - | 'Gamepad'; +export const CONTROLLER_TYPES = ['Adb', 'Win32', 'MacOS', 'Linux', 'PlayCover', 'Gamepad'] as const; + +export type ControllerType = (typeof CONTROLLER_TYPES)[number]; + +export function isControllerType(value: unknown): value is ControllerType { + return CONTROLLER_TYPES.some((type) => type === value); +} export interface ControllerItem { name: string; @@ -162,7 +159,6 @@ export interface ControllerItem { adb?: Record; win32?: Win32Config; macos?: MacOSConfig; - wlroots?: WlRootsConfig; linux?: LinuxConfig; playcover?: PlayCoverConfig; gamepad?: GamepadConfig; @@ -182,11 +178,6 @@ export interface MacOSConfig { input?: string; } -export interface WlRootsConfig { - wlr_socket_path?: string; - use_win32_vk_code?: boolean; -} - export interface LinuxConfig { screencap?: string; input?: string; @@ -236,7 +227,7 @@ export type OptionType = 'select' | 'checkbox' | 'input' | 'switch' | 'hotkey'; export interface CaseItem { name: string; - /** 可选。UI 展示名称,支持国际化字符串(以 $ 开头)。MXU 扩展:支持行内 Markdown,渲染见 components/RichLabel.tsx */ + /** 可选。UI 展示名称,支持国际化字符串(以 $ 开头) */ label?: string; description?: string; icon?: string; @@ -246,27 +237,22 @@ export interface CaseItem { export interface InputItem { name: string; - /** 可选。UI 展示名称,支持国际化字符串(以 $ 开头)。MXU 扩展:支持行内 Markdown,渲染见 components/RichLabel.tsx */ + /** 可选。UI 展示名称,支持国际化字符串(以 $ 开头) */ label?: string; description?: string; - icon?: string; default?: string; pipeline_type?: 'string' | 'int' | 'bool'; verify?: string; pattern_msg?: string; - /** - * MXU 扩展:输入控件类型,'file' 会渲染文件选择器,'time' 会渲染时间选择器 - */ - input_type?: 'text' | 'file' | 'time'; - /** MXU 扩展:输入框占位提示文本(i18n key) */ - placeholder?: string; - /** MXU 扩展:密码字段;启用后 UI 掩码显示,配置加密存储,日志/遥测脱敏 */ + /** v2.10.5: 输入控件类型;文件和文件夹选择结果均为路径字符串 */ + input_type?: 'text' | 'file' | 'directory'; + /** v2.10.0: 密码字段;启用后 UI 掩码显示,配置加密存储,日志/遥测脱敏 */ password?: boolean; } export interface SelectOption { type?: 'select'; - /** 可选。UI 展示名称,支持国际化字符串(以 $ 开头)。MXU 扩展:支持行内 Markdown,渲染见 components/RichLabel.tsx */ + /** 可选。UI 展示名称,支持国际化字符串(以 $ 开头) */ label?: string; description?: string; icon?: string; @@ -280,7 +266,7 @@ export interface SelectOption { /** v2.3.0: 多选框类型 */ export interface CheckboxOption { type: 'checkbox'; - /** 可选。UI 展示名称,支持国际化字符串(以 $ 开头)。MXU 扩展:支持行内 Markdown,渲染见 components/RichLabel.tsx */ + /** 可选。UI 展示名称,支持国际化字符串(以 $ 开头) */ label?: string; description?: string; icon?: string; @@ -296,7 +282,7 @@ export interface CheckboxOption { export interface SwitchOption { type: 'switch'; - /** 可选。UI 展示名称,支持国际化字符串(以 $ 开头)。MXU 扩展:支持行内 Markdown,渲染见 components/RichLabel.tsx */ + /** 可选。UI 展示名称,支持国际化字符串(以 $ 开头) */ label?: string; description?: string; icon?: string; @@ -308,7 +294,7 @@ export interface SwitchOption { export interface InputOption { type: 'input'; - /** 可选。UI 展示名称,支持国际化字符串(以 $ 开头)。MXU 扩展:支持行内 Markdown,渲染见 components/RichLabel.tsx */ + /** 可选。UI 展示名称,支持国际化字符串(以 $ 开头) */ label?: string; description?: string; icon?: string; @@ -320,14 +306,14 @@ export interface InputOption { export interface HotkeyOption { type: 'hotkey'; - /** 可选。UI 展示名称,支持国际化字符串(以 $ 开头)。MXU 扩展:支持行内 Markdown,渲染见 components/RichLabel.tsx */ + /** 可选。UI 展示名称,支持国际化字符串(以 $ 开头) */ label?: string; description?: string; icon?: string; controller?: string[]; resource?: string[]; /** 热键项,支持单键或组合键捕获 */ - hotkeys: InputItem[]; + hotkeys: Pick[]; pipeline_override?: Record; } diff --git a/src/types/maa.ts b/src/types/maa.ts index b5e23cd5..ee3871c1 100644 --- a/src/types/maa.ts +++ b/src/types/maa.ts @@ -56,13 +56,6 @@ export interface MacOSControllerConfig { display_short_side?: number; } -/** WlRoots 控制器配置 (Linux) */ -export interface WlRootsControllerConfig { - type: 'WlRoots'; - wlr_socket_path: string; - use_win32_vk_code?: boolean; -} - /** Linux 原生控制器配置(截图:Wlr/PipeWire,输入:Wlr/UInput/Libei) */ export interface LinuxControllerConfig { type: 'Linux'; @@ -100,7 +93,6 @@ export type ControllerConfig = | AdbControllerConfig | Win32ControllerConfig | MacOSControllerConfig - | WlRootsControllerConfig | LinuxControllerConfig | PlayCoverControllerConfig | GamepadControllerConfig; diff --git a/src/types/specialTasks.ts b/src/types/specialTasks.ts index 70a47cb6..8521a7c4 100644 --- a/src/types/specialTasks.ts +++ b/src/types/specialTasks.ts @@ -10,6 +10,32 @@ import type { OptionDefinition, } from './interface'; +/** 内置任务的界面配置,独立于 interface.json 的 PI 字段。 */ +export interface MxuInputUi { + timePicker?: boolean; + placeholderKey?: string; +} + +const MXU_INPUT_UI: Record> = { + __MXU_WAITUNTIL_OPTION__: { target_time: { timePicker: true } }, + __MXU_LAUNCH_OPTION__: { + program: { placeholderKey: 'specialTask.launch.programPlaceholder' }, + args: { placeholderKey: 'specialTask.launch.argsPlaceholder' }, + }, + __MXU_WEBHOOK_OPTION__: { url: { placeholderKey: 'specialTask.webhook.urlPlaceholder' } }, + __MXU_NOTIFY_OPTION__: { + title: { placeholderKey: 'specialTask.notify.titlePlaceholder' }, + body: { placeholderKey: 'specialTask.notify.bodyPlaceholder' }, + }, + __MXU_KILLPROC_NAME_OPTION__: { + process_name: { placeholderKey: 'specialTask.killProc.namePlaceholder' }, + }, +}; + +export function getMxuInputUi(optionKey: string, inputName: string): MxuInputUi | undefined { + return MXU_INPUT_UI[optionKey]?.[inputName]; +} + /** * MXU 特殊任务定义接口 * 用于注册 MXU 内置的特殊任务(通过 custom_action 实现) @@ -140,7 +166,6 @@ const MXU_WAITUNTIL_OPTION_DEF_INTERNAL: InputOption = { label: 'specialTask.waitUntil.inputLabel', default: '08:00', pipeline_type: 'string', - input_type: 'time', }, ], pipeline_override: { @@ -183,14 +208,12 @@ const MXU_LAUNCH_INPUT_OPTION_DEF_INTERNAL: InputOption = { default: '', pipeline_type: 'string', input_type: 'file', - placeholder: 'specialTask.launch.programPlaceholder', }, { name: 'args', label: 'specialTask.launch.argsLabel', default: '', pipeline_type: 'string', - placeholder: 'specialTask.launch.argsPlaceholder', }, ], pipeline_override: { @@ -324,7 +347,6 @@ const MXU_WEBHOOK_OPTION_DEF_INTERNAL: InputOption = { label: 'specialTask.webhook.urlLabel', default: '', pipeline_type: 'string', - placeholder: 'specialTask.webhook.urlPlaceholder', }, ], pipeline_override: { @@ -361,14 +383,12 @@ const MXU_NOTIFY_OPTION_DEF_INTERNAL: InputOption = { label: 'specialTask.notify.titleLabel', default: 'MXU', pipeline_type: 'string', - placeholder: 'specialTask.notify.titlePlaceholder', }, { name: 'body', label: 'specialTask.notify.bodyLabel', default: '', pipeline_type: 'string', - placeholder: 'specialTask.notify.bodyPlaceholder', }, ], pipeline_override: { @@ -439,7 +459,6 @@ const MXU_KILLPROC_NAME_OPTION_DEF_INTERNAL: InputOption = { label: 'specialTask.killProc.nameLabel', default: '', pipeline_type: 'string', - placeholder: 'specialTask.killProc.namePlaceholder', }, ], pipeline_override: { diff --git a/src/utils/controller.ts b/src/utils/controller.ts index 27af5f12..fb5cf43c 100644 --- a/src/utils/controller.ts +++ b/src/utils/controller.ts @@ -116,7 +116,6 @@ const WORKSTATION_UNLOCK_REQUIREMENT: Record = { Adb: false, Win32: true, MacOS: false, - WlRoots: false, Linux: false, PlayCover: false, Gamepad: true, diff --git a/src/utils/pipelineOverride.ts b/src/utils/pipelineOverride.ts index e2c7bd8c..2fba7085 100644 --- a/src/utils/pipelineOverride.ts +++ b/src/utils/pipelineOverride.ts @@ -259,7 +259,7 @@ const HOTKEY_KEY_MAP: Record> = { F11: 141, F12: 142, }, - WlRoots: { + Linux: { BACKSPACE: 14, TAB: 15, ENTER: 28, @@ -331,12 +331,9 @@ const HOTKEY_KEY_MAP: Record> = { }, }; -// Linux 控制器复用 WlRoots 的 evdev 键码表(use_win32_vk_code=false 时) -HOTKEY_KEY_MAP.Linux = HOTKEY_KEY_MAP.WlRoots; - /** * 将单个按键名(如 "A"、"F1"、"Ctrl")转换为指定控制器类型的虚拟键码。 - * 注意:这里按控制器 **类型**(Win32 / MacOS / Adb / WlRoots / Linux)查表,而非控制器 name。 + * 注意:这里按控制器 **类型**(Win32 / MacOS / Adb / Linux)查表,而非控制器 name。 * 未知按键返回 null。 */ const convertHotkeyKeyName = (keyName: string, controllerType?: string): number | null => { @@ -471,7 +468,7 @@ const collectOptionOverrides = ( continue; } - const pipelineType = inputDef.pipeline_type || 'string'; + const pipelineType = ('pipeline_type' in inputDef && inputDef.pipeline_type) || 'string'; const placeholder = `{${inputName}}`; const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const placeholderRegex = new RegExp(escapedPlaceholder, 'g'); @@ -534,14 +531,13 @@ export const generateTaskPipelineOverride = ( if (!projectInterface) return '[]'; - // 热键键码按控制器 **类型**(Win32 / MacOS / Adb / WlRoots)查表,这里先由 name 解析出 type。 - // WlRoots 可配置接受 Win32 VK 或原始 evdev 键码,热键 override 必须与控制器配置一致。 + // 热键键码按控制器 **类型**(Win32 / MacOS / Adb / Linux)查表,这里先由 name 解析出 type。 + // Linux 可配置接受 Win32 VK 或原始 evdev 键码,热键 override 必须与控制器配置一致。 const controllerDef = controllerName ? projectInterface.controller.find((c) => c.name === controllerName) : undefined; const controllerType = - (controllerDef?.type === 'WlRoots' && controllerDef.wlroots?.use_win32_vk_code) || - (controllerDef?.type === 'Linux' && controllerDef.linux?.use_win32_vk_code) + controllerDef?.type === 'Linux' && controllerDef.linux?.use_win32_vk_code ? 'Win32' : controllerDef?.type; diff --git a/src/utils/richText.ts b/src/utils/richText.ts index 773277ea..d1d7bf06 100644 --- a/src/utils/richText.ts +++ b/src/utils/richText.ts @@ -1,83 +1,4 @@ -/** - * 富文本(Markdown / HTML)标记判定与清理。 - * - * 本文件集中管理"这段文本要不要按富文本渲染"的规则,避免各处各写一套正则导致 - * 行为漂移(历史上 label、focus 日志各有一套判定): - * - * - {@link hasInlineRichText}:label 等短文本,只认行内语法(图片 / 链接 / 行内代码 / - * 加粗 / 白名单行内 HTML 标签)。纯文本里出现的 ``(例如 `模拟器 `) - * 不会被误判成富文本。 - * - {@link hasRichTextFeatures}:focus 日志等长文本,语法面更宽(含多行、裸 URL)。 - * - {@link stripInlineRichText}:去掉行内标记取纯文本,用于 title / 搜索匹配 / 截断预览。 - * - * 纯函数、无任何依赖,便于单测和跨环境复用。 - */ - -/** 图片 `![alt](path)` 或链接 `[text](url)` */ -const INLINE_IMAGE_OR_LINK = /!?\[[^\]]*\]\([^)]*\)/; - -/** 行内代码 `` `code` `` */ -const INLINE_CODE = /`[^`]+`/; - -/** 加粗 `**bold**` */ -const INLINE_STRONG = /\*\*[^*]+\*\*/; - -/** - * 支持的行内 HTML 标签名。 - * - * 判定与清理必须共用同一份名单:两处各写一套时会出现「判定为纯文本、清理却仍删标签」 - * 的不一致(例如纯文本 `模拟器 启动` 被清理成 `模拟器 启动`)。 - */ -const INLINE_HTML_TAGS = 'img|br|b|strong|i|em|code|span|a|small|sub|sup'; - -/** 开标签:用于判定文本是否含富文本标记 */ -const INLINE_HTML_TAG = new RegExp(`<(?:${INLINE_HTML_TAGS})\\b[^>]*>`, 'i'); - -/** 开 / 闭标签:用于清理,只匹配白名单内的标签名 */ -const INLINE_HTML_TAG_ANY = new RegExp(`]*>`, 'gi'); - -/** - * 文本是否包含行内富文本标记(用于 label、输入项标题等短文本)。 - * - * @param text 已解析过国际化的文本 - */ -export function hasInlineRichText(text: string | undefined | null): boolean { - if (!text) return false; - return ( - INLINE_IMAGE_OR_LINK.test(text) || - INLINE_CODE.test(text) || - INLINE_STRONG.test(text) || - INLINE_HTML_TAG.test(text) - ); -} - -/** - * 去除行内富文本标记,得到用于 title / 搜索 / 截断预览的纯文本。 - * - * - 图片:保留 alt 文本(`![三星](x.png)` → `三星`),无 alt 时整体去掉; - * - 链接:保留链接文字; - * - 行内代码 / 加粗:保留内容,去掉定界符; - * - HTML 标签:只去掉白名单内的行内标签;纯文本里的尖括号内容(`模拟器 启动`) - * 不是标签,原样保留。 - */ -export function stripInlineRichText(text: string): string { - return text - .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') - .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') - .replace(INLINE_HTML_TAG_ANY, '') - .replace(/`([^`]+)`/g, '$1') - .replace(/\*\*([^*]+)\*\*/g, '$1') - .replace(/ /g, ' ') - .replace(/\s+/g, ' ') - .trim(); -} - -/** - * 文本是否包含富文本特征(用于 focus 日志等长文本,语法面比 label 宽)。 - * - * 与 {@link hasInlineRichText} 的区别:这里额外认多行内容与裸 URL, - * 因为日志正文经常是整段 Markdown 或直接给一个链接。 - */ +/** 检测 focus 日志正文中的 Markdown、HTML、多行内容或链接。 */ export function hasRichTextFeatures(text: string): boolean { return ( /[*_`#\[\]!]/.test(text) || // Markdown 语法 diff --git a/src/utils/useMaaCallbackLogger.ts b/src/utils/useMaaCallbackLogger.ts index 0e3c055e..6e09a15c 100644 --- a/src/utils/useMaaCallbackLogger.ts +++ b/src/utils/useMaaCallbackLogger.ts @@ -93,14 +93,12 @@ function parseFocusEntry( }; } -// Focus 消息的占位符替换(不包含 {image},由专门函数处理) +// 使用回调详情替换 Focus 消息占位符 function replaceFocusPlaceholders( template: string, details: MaaCallbackDetails & Record, ): string { return template.replace(/\{(\w+)\}/g, (match, key) => { - // {image} 由专门的函数处理,这里跳过 - if (key === 'image') return match; const value = details[key]; if (value !== undefined && value !== null) { return String(value); @@ -111,53 +109,29 @@ function replaceFocusPlaceholders( /** * 解析 focus 消息内容 - * 支持国际化($开头)、URL、文件路径、Markdown 格式、{image} 截图占位符 + * 支持国际化($开头)、URL、文件路径、Markdown 格式 * @param template 模板字符串 * @param details 回调详情(用于占位符替换) - * @param instanceId 实例 ID(用于获取截图) */ async function resolveFocusContent( template: string, details: MaaCallbackDetails & Record, - instanceId: string, ): Promise<{ message: string; html?: string }> { const state = useAppStore.getState(); const langKey = getInterfaceLangKey(state.language); const translations = state.interfaceTranslations[langKey]; const basePath = state.basePath; - // 1. 替换普通占位符(不包含 {image}) - let withPlaceholders = replaceFocusPlaceholders(template, details); - - // 2. 处理 {image} 占位符 - 获取控制器缓存的截图(带超时保护) - if (withPlaceholders.includes('{image}')) { - try { - // 添加超时保护,避免长时间阻塞 - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('获取截图超时')), 5000); - }); - const imagePromise = maaService.getCachedImage(instanceId); + // 1. 使用回调详情替换占位符 + const withPlaceholders = replaceFocusPlaceholders(template, details); - const imageDataUrl = await Promise.race([imagePromise, timeoutPromise]); - if (imageDataUrl) { - // 直接替换为 data URL,用户可自行组装到 Markdown/HTML 中 - withPlaceholders = withPlaceholders.replace(/\{image\}/g, imageDataUrl); - } else { - withPlaceholders = withPlaceholders.replace(/\{image\}/g, ''); - } - } catch (err) { - log.warn('获取截图失败:', err); - withPlaceholders = withPlaceholders.replace(/\{image\}/g, ''); - } - } - - // 3. 处理国际化 + // 2. 处理国际化 const resolved = resolveI18nText(withPlaceholders, translations); - // 4. 检测内容类型 + // 3. 检测内容类型 const contentType = detectContentType(resolved); - // 5. 如果是直接文本,检查是否包含富文本特征 + // 4. 如果是直接文本,检查是否包含富文本特征 if (contentType === 'text') { // 判定规则集中在 utils/richText,避免各处各写一套正则 if (hasRichTextFeatures(resolved)) { @@ -167,7 +141,7 @@ async function resolveFocusContent( return { message: resolved }; } - // 6. 加载外部内容(URL 或文件) + // 5. 加载外部内容(URL 或文件) try { const loadedContent = await resolveContent(resolved, { translations, basePath }); // 将加载的内容转换为 HTML(支持 Markdown) @@ -210,8 +184,6 @@ function inferCtrlInfoFromInstance( return { type: 'window', name: savedDevice?.windowName }; } else if (controller.type === 'Adb') { return { type: 'device', name: savedDevice?.adbDeviceName }; - } else if (controller.type === 'WlRoots') { - return { type: 'device', name: savedDevice?.wlrSocketPath }; } else if (controller.type === 'Linux') { // 按当前配置实际需要的设备派生名称:不依赖 gamescope 的配置(如 Portal+Uinput) // 会忽略残留的 savedDevice.gamescopeDisplayNo,避免显示过期的 `gamescope-`。 @@ -364,30 +336,18 @@ function handleCallback( if (focusEntry) { const { content: focusTemplate, displayChannels } = focusEntry; - // 如果包含 {image} 占位符,先快速显示不含图片的版本,避免阻塞 - const hasImagePlaceholder = focusTemplate.includes('{image}'); - if (hasImagePlaceholder && displayChannels.includes('log')) { - const tempMessage = replaceFocusPlaceholders(focusTemplate, details).replace( - /\{image\}/g, - '[图片加载中...]', - ); - addLog(instanceId, { type: 'focus', message: tempMessage }); - } - - resolveFocusContent(focusTemplate, details, instanceId) + resolveFocusContent(focusTemplate, details) .then((resolved) => { // 根据 display 渠道分发消息;toast 与 notification 同为系统通知,去重避免双发 let notificationDispatched = false; for (const channel of displayChannels) { switch (channel) { case 'log': - if (!hasImagePlaceholder) { - addLog(instanceId, { - type: 'focus', - message: resolved.message, - html: resolved.html, - }); - } + addLog(instanceId, { + type: 'focus', + message: resolved.message, + html: resolved.html, + }); break; case 'toast': case 'notification': @@ -406,7 +366,7 @@ function handleCallback( }) .catch((err) => { log.warn('Failed to resolve focus content:', err); - if (!hasImagePlaceholder && displayChannels.includes('log')) { + if (displayChannels.includes('log')) { addLog(instanceId, { type: 'focus', message: focusTemplate }); } }); @@ -680,7 +640,7 @@ export function useMaaAgentLogger() { }; const emitAgentLog = (instanceId: string, mergedLine: string) => { - resolveFocusContent(mergedLine, {} as MaaCallbackDetails & Record, instanceId) + resolveFocusContent(mergedLine, {} as MaaCallbackDetails & Record) .then((resolved) => { addLog(instanceId, { type: 'agent', diff --git a/tests/protocolUi.test.ts b/tests/protocolUi.test.ts new file mode 100644 index 00000000..d4a4d56d --- /dev/null +++ b/tests/protocolUi.test.ts @@ -0,0 +1,111 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { createServer } from 'vite'; + +test('PI UI ignores private fields while retaining standard options and Linux controllers', async () => { + // Vite supplies the same module aliases and import.meta.env as the application. + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { getItem: () => null, setItem: () => {} }, + }); + const server = await createServer({ + server: { middlewareMode: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + appType: 'custom', + }); + try { + const { useAppStore } = await server.ssrLoadModule('/src/stores/appStore.ts'); + const { OptionEditor, SwitchGrid } = await server.ssrLoadModule( + '/src/components/OptionEditor.tsx', + ); + const { isControllerType } = await server.ssrLoadModule('/src/types/interface.ts'); + const { buildLinuxControllerConfig } = await server.ssrLoadModule('/src/utils/controller.ts'); + const { generateTaskPipelineOverride } = await server.ssrLoadModule( + '/src/utils/pipelineOverride.ts', + ); + const projectInterface = { + interface_version: 2, + name: 'test', + resource: [], + controller: [{ name: 'linux', type: 'Linux', linux: { input: 'Wlr', screencap: 'Wlr' } }], + task: [{ name: 'test', entry: 'Entry', option: ['Hotkey'] }], + option: { + Input: { + type: 'input', + label: '**Option**', + inputs: [ + { + name: 'plain', + label: 'Input', + input_type: 'time', + placeholder: 'PRIVATE', + icon: 'private.png', + }, + { name: 'secret', password: true }, + { name: 'folder', input_type: 'directory' }, + ], + }, + Cases: { + type: 'select', + label: '**Select**', + cases: [{ name: 'first', label: '![](icon.png) Case' }], + }, + Hotkey: { + type: 'hotkey', + hotkeys: [{ name: 'key', default: 'A', input_type: 'file', placeholder: 'PRIVATE' }], + pipeline_override: { Entry: { key: '{key}' } }, + }, + }, + }; + // Zustand's server renderer reads the initial snapshot. + Object.assign(useAppStore.getInitialState(), { + projectInterface, + language: 'en-US', + instances: [], + globalOptionValues: {}, + }); + const renderOption = (optionKey: string) => + renderToStaticMarkup(createElement(OptionEditor, { optionKey, globalScope: true })); + const inputHtml = renderOption('Input'); + assert.match(inputHtml, /\*\*Option\*\*/); + assert.match(inputHtml, /<b>Input<\/b>/); + assert.doesNotMatch(inputHtml, /PRIVATE|private\.png|type="time"|/); + assert.match(inputHtml, /type="password"/); + const casesHtml = renderOption('Cases'); + assert.match(casesHtml, /!\[\]\(icon\.png\) Case/); + assert.doesNotMatch(casesHtml, / { - assert.equal(hasInlineRichText('![](resource/image/icon/xxx.png) 名称 ★5'), true); - assert.equal(hasInlineRichText('![三星](icon.png) 名称'), true); - assert.equal(hasInlineRichText('见 [文档](https://example.com)'), true); - assert.equal(hasInlineRichText('等级 `max` 说明'), true); - assert.equal(hasInlineRichText('**重要**选项'), true); - assert.equal(hasInlineRichText('名称 加粗'), true); - assert.equal(hasInlineRichText('名称
换行'), true); -}); - -test('hasInlineRichText:纯文本不会被误判(含尖括号的场景)', () => { - assert.equal(hasInlineRichText('模拟器 启动'), false); - assert.equal(hasInlineRichText('使用 <空格> 键暂停'), false); - assert.equal(hasInlineRichText('攻击力 <100 时撤退'), false); - assert.equal(hasInlineRichText('总进度 100%'), false); - assert.equal(hasInlineRichText('名称 ★5 (推荐)'), false); - assert.equal(hasInlineRichText(''), false); - assert.equal(hasInlineRichText(undefined), false); -}); - -test('stripInlineRichText:图片与链接按语义降级', () => { - assert.equal(stripInlineRichText('![](resource/image/icon/xxx.png) 名称 ★5'), '名称 ★5'); - assert.equal(stripInlineRichText('![三星](icon.png) 名称'), '三星 名称'); - assert.equal(stripInlineRichText('见 [文档](https://example.com) 说明'), '见 文档 说明'); -}); - -test('stripInlineRichText:去掉定界符与标签,保留内容', () => { - assert.equal(stripInlineRichText('**重要**选项'), '重要选项'); - assert.equal(stripInlineRichText('等级 `max` 说明'), '等级 max 说明'); - assert.equal(stripInlineRichText('名称 加粗 结束'), '名称 加粗 结束'); - assert.equal(stripInlineRichText(' 多 空白\n换行 '), '多 空白 换行'); -}); - -test('stripInlineRichText:白名单之外的尖括号纯文本原样保留(回归)', () => { - // 曾经的 bug:通用 /<[^>]+>/ 会把纯文本里的 一起删掉, - // 使 ComboBox 搜索、任务预览、SwitchGrid title 显示的字凭空变少 - assert.equal(stripInlineRichText('模拟器 启动'), '模拟器 启动'); - assert.equal(stripInlineRichText('使用 <空格> 键暂停'), '使用 <空格> 键暂停'); - assert.equal(stripInlineRichText('攻击力 <100 时撤退'), '攻击力 <100 时撤退'); - // 白名单内的行内标签仍然会被去掉 - assert.equal(stripInlineRichText('
换行'), '换行'); - assert.equal(stripInlineRichText('名称 强调 结束'), '名称 强调 结束'); -}); +import { hasRichTextFeatures } from '../src/utils/richText.ts'; test('hasRichTextFeatures:日志正文语法面更宽(多行 / 裸 URL)', () => { assert.equal(hasRichTextFeatures('第一行\n第二行'), true); From b8c5543ab748c4c6ef82f335f59b44be65bedf6b Mon Sep 17 00:00:00 2001 From: MistEO Date: Thu, 29 Oct 2026 17:31:21 +0800 Subject: [PATCH 2/2] fix: address protocol extension review findings --- src/components/FormControls.tsx | 3 ++ src/components/OptionEditor.tsx | 1 + src/types/maa.ts | 8 +---- tests/protocolUi.test.ts | 52 ++++++++++++++++++++++++++++++--- 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/components/FormControls.tsx b/src/components/FormControls.tsx index bcf79f67..d9db1ed1 100644 --- a/src/components/FormControls.tsx +++ b/src/components/FormControls.tsx @@ -396,6 +396,7 @@ interface FileInputProps { browseTitle?: string; className?: string; directory?: boolean; + password?: boolean; } const defaultFileFilters = [ @@ -412,6 +413,7 @@ export function FileInput({ browseTitle, className, directory = false, + password = false, }: FileInputProps) { const { t } = useTranslation(); @@ -438,6 +440,7 @@ export function FileInput({ diff --git a/src/types/maa.ts b/src/types/maa.ts index ee3871c1..10b3cd86 100644 --- a/src/types/maa.ts +++ b/src/types/maa.ts @@ -280,13 +280,7 @@ export function parseLinuxInputMethod(name: string | undefined): number { } /** Agent 配置(用于启动子进程) */ -export interface AgentConfig { - child_exec: string; - child_args?: string[]; - identifier?: string; - /** 连接超时时间(毫秒),-1 表示无限等待 */ - timeout?: number; -} +export type { AgentConfig } from './interface'; /** 任务配置 */ export interface TaskConfig { diff --git a/tests/protocolUi.test.ts b/tests/protocolUi.test.ts index d4a4d56d..e19d0bf6 100644 --- a/tests/protocolUi.test.ts +++ b/tests/protocolUi.test.ts @@ -25,11 +25,18 @@ test('PI UI ignores private fields while retaining standard options and Linux co const { generateTaskPipelineOverride } = await server.ssrLoadModule( '/src/utils/pipelineOverride.ts', ); + let inputIconReads = 0; const projectInterface = { interface_version: 2, name: 'test', resource: [], - controller: [{ name: 'linux', type: 'Linux', linux: { input: 'Wlr', screencap: 'Wlr' } }], + controller: [ + { + name: 'linux', + type: 'Linux', + linux: { input: 'Wlr', screencap: 'Wlr', use_win32_vk_code: false }, + }, + ], task: [{ name: 'test', entry: 'Entry', option: ['Hotkey'] }], option: { Input: { @@ -41,7 +48,10 @@ test('PI UI ignores private fields while retaining standard options and Linux co label: 'Input', input_type: 'time', placeholder: 'PRIVATE', - icon: 'private.png', + get icon() { + inputIconReads += 1; + return 'private.png'; + }, }, { name: 'secret', password: true }, { name: 'folder', input_type: 'directory' }, @@ -57,6 +67,15 @@ test('PI UI ignores private fields while retaining standard options and Linux co hotkeys: [{ name: 'key', default: 'A', input_type: 'file', placeholder: 'PRIVATE' }], pipeline_override: { Entry: { key: '{key}' } }, }, + ProtectedPaths: { + type: 'input', + inputs: [ + { name: 'file', input_type: 'file', password: true }, + { name: 'directory', input_type: 'directory', password: true }, + { name: 'plainFile', input_type: 'file' }, + { name: 'plainDirectory', input_type: 'directory' }, + ], + }, }, }; // Zustand's server renderer reads the initial snapshot. @@ -64,19 +83,44 @@ test('PI UI ignores private fields while retaining standard options and Linux co projectInterface, language: 'en-US', instances: [], - globalOptionValues: {}, + globalOptionValues: { + ProtectedPaths: { + type: 'input', + values: { + file: 'private-file', + directory: 'private-directory', + plainFile: 'public-file', + plainDirectory: 'public-directory', + }, + }, + }, }); const renderOption = (optionKey: string) => renderToStaticMarkup(createElement(OptionEditor, { optionKey, globalScope: true })); const inputHtml = renderOption('Input'); assert.match(inputHtml, /\*\*Option\*\*/); assert.match(inputHtml, /<b>Input<\/b>/); - assert.doesNotMatch(inputHtml, /PRIVATE|private\.png|type="time"|/); + assert.doesNotMatch(inputHtml, /PRIVATE|type="time"|/); + // AsyncIcon renders null on the server; detect metadata reads before any effect runs. + assert.equal(inputIconReads, 0, 'PI input icon metadata must not be read'); assert.match(inputHtml, /type="password"/); const casesHtml = renderOption('Cases'); assert.match(casesHtml, /!\[\]\(icon\.png\) Case/); assert.doesNotMatch(casesHtml, /]*>/g) || []; + assert.equal(pathInputs.length, 4); + for (const value of ['private-file', 'private-directory']) { + const input = pathInputs.find((html) => html.includes(`value="${value}"`)); + assert.ok(input, `Missing protected path input: ${value}`); + assert.match(input, /type="password"/); + assert.match(input, /autoComplete="off"/); + } + for (const value of ['public-file', 'public-directory']) { + const input = pathInputs.find((html) => html.includes(`value="${value}"`)); + assert.ok(input, `Missing plain path input: ${value}`); + assert.match(input, /type="text"/); + } assert.match(renderOption('__MXU_WAITUNTIL_OPTION__'), /type="time"/); assert.match(renderOption('__MXU_LAUNCH_OPTION__'), /placeholder="[^\"]+"/); const gridHtml = renderToStaticMarkup(