核心目标:掌握 Tauri 调用系统能力的三条路径,并以 EMS Simulate 的浏览器打开、目录打开、文件选择和保存为主线,扩展到通知、剪贴板、全局快捷键、Shell 和平台原生 API。
前置知识 :已阅读 Part 1~Part 4,理解 capability、command 和应用生命周期。
验证基线:EMS Simulate 5.0.0;Tauri 2.11.2、Tauri JS API 2.11.0、Rust 1.95.0、Vue 3.5.26,Windows 11。最后复核日期:2026-08-26。
🚀 配套实战项目:EMS Simulate(能源管理系统模拟器)为了避免只讲零散 API,本系列统一使用我开发并持续维护的 EMS Simulate 作为贯穿案例:它是一款免费开源的工业协议仿真软件,支持 IEC 60870-5-104、IEC 61850、Modbus TCP/RTU、DL/T 645 等主流协议,可模拟 PCS 储能变流器、BMS 电池管理系统、电表等真实设备,并提供四遥(YC/YX/YK/YT)配置和报文实时查看。结合 Wireshark,读者可以直接观察协议报文,并验证 Tauri 界面、Python 后台、系统能力和安装包之间的完整调用链。
- 📦 GitHub 开源仓库(欢迎 Star ⭐)
- 📖 在线技术文档
- 🏪 Microsoft Store(Windows 10/11 免配置安装)
0. 问题场景:window.open() 为什么不是桌面系统调用方案
EMS 前端要做这些事情:
- 用系统浏览器打开项目主页和在线文档;
- 打开运行日志目录;
- 选择 SCL/ICD/CID 文件;
- 选择 CSV/Excel 点表;
- 导出模型、报文或诊断包;
- 仿真异常时发送系统通知;
- 复制设备地址或报文内容;
- 注册"显示主窗口"等全局快捷键;
- 拉起 bundled Python sidecar。
浏览器模式下可以用 window.open()、<input type="file"> 和 Blob 下载;桌面模式需要与操作系统对话。危险之处在于,系统调用一旦暴露得过宽,WebView 中的一次 XSS 就可能从"页面脚本问题"升级为"本地文件或进程问题"。
因此本篇不把 API 罗列成清单,而是建立选择与校验模型。
1. 三条系统调用路线
#mermaid-svg-cwRZdQARkkGSQDIH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-cwRZdQARkkGSQDIH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-cwRZdQARkkGSQDIH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-cwRZdQARkkGSQDIH .error-icon{fill:#552222;}#mermaid-svg-cwRZdQARkkGSQDIH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-cwRZdQARkkGSQDIH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-cwRZdQARkkGSQDIH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-cwRZdQARkkGSQDIH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-cwRZdQARkkGSQDIH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-cwRZdQARkkGSQDIH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-cwRZdQARkkGSQDIH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-cwRZdQARkkGSQDIH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-cwRZdQARkkGSQDIH .marker.cross{stroke:#333333;}#mermaid-svg-cwRZdQARkkGSQDIH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-cwRZdQARkkGSQDIH p{margin:0;}#mermaid-svg-cwRZdQARkkGSQDIH .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-cwRZdQARkkGSQDIH .cluster-label text{fill:#333;}#mermaid-svg-cwRZdQARkkGSQDIH .cluster-label span{color:#333;}#mermaid-svg-cwRZdQARkkGSQDIH .cluster-label span p{background-color:transparent;}#mermaid-svg-cwRZdQARkkGSQDIH .label text,#mermaid-svg-cwRZdQARkkGSQDIH span{fill:#333;color:#333;}#mermaid-svg-cwRZdQARkkGSQDIH .node rect,#mermaid-svg-cwRZdQARkkGSQDIH .node circle,#mermaid-svg-cwRZdQARkkGSQDIH .node ellipse,#mermaid-svg-cwRZdQARkkGSQDIH .node polygon,#mermaid-svg-cwRZdQARkkGSQDIH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-cwRZdQARkkGSQDIH .rough-node .label text,#mermaid-svg-cwRZdQARkkGSQDIH .node .label text,#mermaid-svg-cwRZdQARkkGSQDIH .image-shape .label,#mermaid-svg-cwRZdQARkkGSQDIH .icon-shape .label{text-anchor:middle;}#mermaid-svg-cwRZdQARkkGSQDIH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-cwRZdQARkkGSQDIH .rough-node .label,#mermaid-svg-cwRZdQARkkGSQDIH .node .label,#mermaid-svg-cwRZdQARkkGSQDIH .image-shape .label,#mermaid-svg-cwRZdQARkkGSQDIH .icon-shape .label{text-align:center;}#mermaid-svg-cwRZdQARkkGSQDIH .node.clickable{cursor:pointer;}#mermaid-svg-cwRZdQARkkGSQDIH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-cwRZdQARkkGSQDIH .arrowheadPath{fill:#333333;}#mermaid-svg-cwRZdQARkkGSQDIH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-cwRZdQARkkGSQDIH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-cwRZdQARkkGSQDIH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cwRZdQARkkGSQDIH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-cwRZdQARkkGSQDIH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cwRZdQARkkGSQDIH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-cwRZdQARkkGSQDIH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-cwRZdQARkkGSQDIH .cluster text{fill:#333;}#mermaid-svg-cwRZdQARkkGSQDIH .cluster span{color:#333;}#mermaid-svg-cwRZdQARkkGSQDIH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-cwRZdQARkkGSQDIH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-cwRZdQARkkGSQDIH rect.text{fill:none;stroke-width:0;}#mermaid-svg-cwRZdQARkkGSQDIH .icon-shape,#mermaid-svg-cwRZdQARkkGSQDIH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cwRZdQARkkGSQDIH .icon-shape p,#mermaid-svg-cwRZdQARkkGSQDIH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-cwRZdQARkkGSQDIH .icon-shape .label rect,#mermaid-svg-cwRZdQARkkGSQDIH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cwRZdQARkkGSQDIH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-cwRZdQARkkGSQDIH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-cwRZdQARkkGSQDIH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
是
否
需要系统能力
Tauri core 已提供?
使用 core API
官方或可信插件已提供?
插件 + capability + scope
成熟 Rust crate 可封装?
Rust service + command
平台 API / FFI
统一 trait、cfg、错误和资源释放
| 路线 | EMS 示例 | 优点 | 主要约束 |
|---|---|---|---|
| Tauri core | 窗口、菜单、托盘、路径 | 与运行时结合紧密 | 仍需 core permission |
| 官方插件 | opener、dialog、shell、process | 跨平台封装和权限模型成熟 | 需要注册插件与 capability |
| Rust crate/原生 API | 进程检测、平台目录、特殊设备 API | 能覆盖专用能力 | 需要自行维护平台差异和安全边界 |
选择原则不是"越底层越强",而是:
- 优先选择能表达需求的稳定高层抽象;
- 仍然验证输入,不把插件当作天然安全;
- 平台差异收进 Rust 层,不散落到 Vue 组件;
- 对外返回统一错误码,不把原生错误字符串当业务协议;
- 资源型 API 必须有 unregister、close、kill 等清理路径。
2. 打开浏览器、文件与目录
2.1 EMS 当前实现:指定 Microsoft Edge
当前 front/src/utils/tauri.ts:
ts
export async function openExternal(url: string) {
if (!isTauri()) {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
const { openUrl } = await import("@tauri-apps/plugin-opener");
await openUrl(url, "msedge");
}
当前 capability 同时限定了 URL 和应用:
json
{
"identifier": "opener:allow-open-url",
"allow": [
{ "url": "https://gitee.com/chen-dongyu123", "app": "msedge" },
{ "url": "https://github.com/600888", "app": "msedge" },
{ "url": "https://600888.github.io/ems_simulate/", "app": "msedge" }
]
}
这是一种明确的产品选择,但有两个后果:
- 用户没有安装 Edge,打开会失败;
- 用户选择 Firefox、Chrome 或其他默认浏览器也不会被尊重。
更通用的桌面体验是使用系统默认应用:
ts
const EMS_LINKS = {
docs: "https://600888.github.io/ems_simulate/",
github: "https://github.com/600888",
gitee: "https://gitee.com/chen-dongyu123",
} as const;
export async function openApprovedLink(key: keyof typeof EMS_LINKS) {
const url = EMS_LINKS[key];
if (!isTauri()) {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
const { openUrl } = await import("@tauri-apps/plugin-opener");
await openUrl(url);
}
对应 capability 也应改成不要求指定 app 的精确 URL scope。不要只改 TypeScript 而忘了权限配置,否则运行时会得到 scope 拒绝。
2.2 为什么使用 key,而不是接受任意 URL
不推荐:
ts
await openUrl(userInput);
推荐:
ts
await openApprovedLink("docs");
即使 capability 已有限制,前端的有限 key 仍然带来三点收益:
- UI 无法意外传入
file:、javascript:或自定义危险 scheme; - 链接变更有统一入口;
- 错误提示可以显示"复制文档地址",而不是暴露底层插件错误。
若确实需要动态 URL,例如打开本机 FastAPI /docs,应在 Rust 中解析:
rust
fn validate_backend_docs_url(raw: &str, expected_port: u16) -> Result<url::Url, String> {
let url = url::Url::parse(raw).map_err(|_| "文档地址格式无效")?;
let host_ok = matches!(url.host_str(), Some("127.0.0.1") | Some("localhost"));
let port_ok = url.port_or_known_default() == Some(expected_port);
let path_ok = matches!(url.path(), "/docs" | "/redoc" | "/openapi.json");
if url.scheme() == "http" && host_ok && port_ok && path_ok {
Ok(url)
} else {
Err("只允许打开当前 EMS 后台的 API 文档".to_string())
}
}
2.3 打开日志目录
当前 EMS 使用自定义 command:
rust
#[tauri::command]
fn open_directory(app: tauri::AppHandle, path: String) -> Result<(), String> {
let directory = resolve_directory(&path)?;
app.opener()
.open_path(directory.to_string_lossy(), None::<&str>)
.map_err(|error| format!("打开目录失败: {error}"))
}
resolve_directory() 会 trim、canonicalize,并确认目标确实是目录。这比直接将字符串传给系统打开器更可靠。
但从最小权限看,还可以继续收紧:不要让前端传路径,改成用途型 command:
rust
#[tauri::command]
fn open_log_directory(app: tauri::AppHandle) -> Result<(), CommandError> {
let log_dir = resolve_ems_log_dir(&app)?;
app.opener().open_path(log_dir, None::<&str>)?;
Ok(())
}
前端只能表达"打开 EMS 日志目录",无法借同一个 command 打开任意存在的目录。
3. 系统对话框:选择路径不等于永久授权
EMS 已注册 tauri-plugin-dialog,并在 capability 中允许 open/save/message/ask。典型 SCL 文件选择:
ts
import { open } from "@tauri-apps/plugin-dialog";
const selected = await open({
title: "导入 IEC 61850 模型",
multiple: false,
directory: false,
filters: [
{
name: "IEC 61850 SCL",
extensions: ["scl", "icd", "cid", "scd"],
},
],
});
if (!selected) {
return; // 用户取消不是错误
}
点表选择可以使用独立 filter:
ts
const selected = await open({
title: "导入设备点表",
multiple: false,
filters: [
{ name: "点表文件", extensions: ["csv", "xlsx"] },
],
});
注意四个边界:
- 扩展名 filter 主要改善选择体验,不替代内容校验;
null/取消应作为正常分支处理;- 返回路径只说明用户选择了当时的对象,不代表以后永远可以访问;
- 读取后仍要限制大小、解析格式、XML 实体、压缩包展开量和业务字段。
3.1 导出文件:dialog 与写入应分开
当前 EMS saveDownload() 采用:
text
save dialog → 获得用户目标路径 → invoke("save_file") → Rust 写入
ts
const destination = await save({ defaultPath: filename });
if (!destination) return false;
const contents = Array.from(new Uint8Array(await blob.arrayBuffer()));
await invoke("save_file", { path: destination, contents });
Rust 侧确认父目录存在,再执行写入。这条路线适合中小型导出。
大报文或诊断包不应把几十到几百 MB 转成 number[] 穿过 IPC。更合理的做法是:
- FastAPI/Rust 在本地临时目录生成文件;
- dialog 只选择目标;
- Rust 使用流式复制或原子重命名;
- 成功后删除临时文件;
- 失败保留可定位的错误,但不在日志泄漏敏感内容。
3.2 文件拖放
当前主窗口配置中 dragDropEnabled 为 false,因此不能一边沿用当前配置,一边假设 Tauri 拖放事件会工作。
启用后可监听 WebView 拖放事件:
ts
import { getCurrentWebview } from "@tauri-apps/api/webview";
const unlisten = await getCurrentWebview().onDragDropEvent((event) => {
if (event.payload.type === "drop") {
queueImportCandidates(event.payload.paths);
}
});
// 组件卸载
unlisten();
拖入路径仍要经过:canonicalize、类型/大小校验、符号链接策略和用户确认。拖放只是输入入口,不会自动扩大文件访问范围。
4. 通知、剪贴板与全局快捷键
下面三项是基于现有桌面能力的增量方案。当前 EMS Cargo.toml 尚未注册 notification、clipboard-manager 和 global-shortcut 插件;落地时需要分别安装并在 lib.rs 初始化,同时只给目标窗口加入所需 permission。不能只复制前端代码。
4.1 通知:先问权限,再决定渠道
仿真失败时不一定都要发系统通知:
| 场景 | 推荐提示 |
|---|---|
| 用户正在当前页面操作 | 页面内错误/Toast |
| 应用隐藏到托盘,关键协议退出 | 系统通知 |
| 高频点值异常 | 页面聚合,不逐条通知 |
| 后台启动失败 | 主窗口诊断页 + 可选系统通知 |
ts
import {
isPermissionGranted,
requestPermission,
sendNotification,
} from "@tauri-apps/plugin-notification";
let granted = await isPermissionGranted();
if (!granted) {
granted = (await requestPermission()) === "granted";
}
if (granted) {
sendNotification({
title: "EMS Simulate",
body: "IEC 104 通道已异常停止,请打开诊断信息。",
});
}
权限被拒绝不是异常崩溃条件。应用应退回页面提示,并在设置中说明如何重新授权。
4.2 剪贴板:便利能力也有数据边界
适合复制:
- 设备 IP/端口;
- 点地址;
- 已脱敏的错误详情;
- 报文中用户明确选中的文本。
不应默认复制:
- 数据库密码;
- localhost 认证 token;
- 私钥和证书原文;
- 整个诊断包内容。
ts
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
await writeText(selectedMessageText);
剪贴板可能被其他程序读取。复制敏感内容前要明确提示,必要时提供定时清空,但不能假定清空前内容没有被读取。
4.3 全局快捷键:注册只是开始
ts
import {
isRegistered,
register,
unregister,
} from "@tauri-apps/plugin-global-shortcut";
const accelerator = "CommandOrControl+Shift+E";
if (!(await isRegistered(accelerator))) {
await register(accelerator, async () => {
await showMainWindow();
});
}
// 禁用功能、设置变更或应用退出时
await unregister(accelerator);
必须处理:
- 与操作系统或其他软件冲突;
- 重复注册;
- 用户修改快捷键后的旧注册清理;
- 前端热重载造成重复 handler;
- macOS 权限与不同键盘布局;
- Linux 桌面环境差异。
全局快捷键适合低频应用动作,不适合把所有协议控制映射成系统级按键。
5. Opener 与 Shell 的边界
text
打开 URL/文件/目录 → opener
执行明确外部程序 → shell 或 Rust Command
随应用分发的后台 → bundled sidecar
复杂平台 API → Rust crate / 原生封装
不要为了打开网页使用:
text
cmd /c start <url>
sh -c "xdg-open <url>"
这会把 URL 变成 shell 语法的一部分,引入引号、空格、重定向和命令注入问题。opener 已经表达了"交给系统默认应用打开"这个语义。
5.1 Shell scope 必须描述固定程序和参数
sidecar 允许项应该尽量类似:
json
{
"identifier": "shell:allow-spawn",
"allow": [
{
"name": "binaries/ems_simulate_backend",
"sidecar": true,
"args": [
"--port",
{ "validator": "^[0-9]{5}$" }
]
}
]
}
实际字段要以当前项目 gen/schemas/desktop-schema.json 生成的 schema 为准。设计重点是:
- 固定 executable;
- 声明它是 sidecar;
- 参数逐项 allow/validate;
- 不接受整条 shell 字符串;
- stdout/stderr 有界消费;
- 进程句柄进入生命周期管理。
EMS 当前通过 ShellExt::sidecar("ems_simulate_backend") 拉起 externalBin,并传入 --port 与 EMS_ROOT_DIR。这与执行用户电脑上的任意命令不是一类能力。
5.2 spawn 与 execute
execute:等待程序结束并一次性获取结果,适合短任务;spawn:立即返回 child 和事件流,适合长驻后台;- stdin/stdout:必须定义编码、分帧和背压;
- kill:只操作当前应用持有的 child,不按进程名全局清理。
Python FastAPI 后台是长驻服务,因此使用 spawn,而不是让 setup 阻塞等待 execute 完成。
6. 官方插件不足时:把平台差异封装进 Rust
假设 EMS 未来需要查询 Windows 电源状态、macOS 特定 framework 或 Linux DBus/portal。不要在 command 中堆满 #[cfg]:
rust
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct PlatformStatus {
power_source: String,
metered_network: Option<bool>,
}
trait PlatformService: Send + Sync {
fn status(&self) -> Result<PlatformStatus, PlatformError>;
}
#[cfg(target_os = "windows")]
mod platform_impl {
pub struct NativePlatformService;
// Windows API 封装在此处
}
#[cfg(target_os = "linux")]
mod platform_impl {
pub struct NativePlatformService;
// DBus/desktop portal 封装在此处
}
对外 command 只依赖统一接口:
rust
#[tauri::command]
fn get_platform_status(
service: tauri::State<'_, Box<dyn PlatformService>>,
) -> Result<PlatformStatus, PlatformError> {
service.status()
}
原生边界要特别处理:
- API 是否只能在主线程调用;
- 句柄由谁释放;
- 回调是否跨线程;
- UTF-16、UTF-8 和本地编码转换;
- Windows HRESULT、errno、NSError 如何映射;
- panic 是否可能穿过 FFI;
- 功能不可用时返回
Unsupported,而不是伪造默认值。
6.1 何时抽成 Tauri plugin
适合自定义 plugin:
- 多个 Tauri 项目都会复用;
- 同时需要 Rust 与 JavaScript API;
- 希望定义自己的 permissions 和 scopes;
- 需要在 setup/事件循环/移动端注册中挂钩;
- 能为每个平台提供清晰实现或 Unsupported 语义。
只在 EMS 内部使用的一两个 command,先保留为 Rust service 通常更轻。
7. 权限拒绝是正常业务分支
系统 API 的完整调用链是:
Operating System Plugin/Command Tauri Runtime Authority Vue Operating System Plugin/Command Tauri Runtime Authority Vue #mermaid-svg-8NtG93qJlPIP3ZA9{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8NtG93qJlPIP3ZA9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8NtG93qJlPIP3ZA9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8NtG93qJlPIP3ZA9 .error-icon{fill:#552222;}#mermaid-svg-8NtG93qJlPIP3ZA9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8NtG93qJlPIP3ZA9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8NtG93qJlPIP3ZA9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8NtG93qJlPIP3ZA9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8NtG93qJlPIP3ZA9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8NtG93qJlPIP3ZA9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8NtG93qJlPIP3ZA9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8NtG93qJlPIP3ZA9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8NtG93qJlPIP3ZA9 .marker.cross{stroke:#333333;}#mermaid-svg-8NtG93qJlPIP3ZA9 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8NtG93qJlPIP3ZA9 p{margin:0;}#mermaid-svg-8NtG93qJlPIP3ZA9 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-8NtG93qJlPIP3ZA9 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-8NtG93qJlPIP3ZA9 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-8NtG93qJlPIP3ZA9 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-8NtG93qJlPIP3ZA9 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-8NtG93qJlPIP3ZA9 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-8NtG93qJlPIP3ZA9 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-8NtG93qJlPIP3ZA9 .sequenceNumber{fill:white;}#mermaid-svg-8NtG93qJlPIP3ZA9 #sequencenumber{fill:#333;}#mermaid-svg-8NtG93qJlPIP3ZA9 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-8NtG93qJlPIP3ZA9 .messageText{fill:#333;stroke:none;}#mermaid-svg-8NtG93qJlPIP3ZA9 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-8NtG93qJlPIP3ZA9 .labelText,#mermaid-svg-8NtG93qJlPIP3ZA9 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-8NtG93qJlPIP3ZA9 .loopText,#mermaid-svg-8NtG93qJlPIP3ZA9 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-8NtG93qJlPIP3ZA9 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-8NtG93qJlPIP3ZA9 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-8NtG93qJlPIP3ZA9 .noteText,#mermaid-svg-8NtG93qJlPIP3ZA9 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-8NtG93qJlPIP3ZA9 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-8NtG93qJlPIP3ZA9 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-8NtG93qJlPIP3ZA9 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-8NtG93qJlPIP3ZA9 .actorPopupMenu{position:absolute;}#mermaid-svg-8NtG93qJlPIP3ZA9 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-8NtG93qJlPIP3ZA9 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-8NtG93qJlPIP3ZA9 .actor-man circle,#mermaid-svg-8NtG93qJlPIP3ZA9 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-8NtG93qJlPIP3ZA9 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt OS 拒绝或能力缺失 成功 alt 未授权 已授权 调用 + origin + window label + 参数 匹配 capability/permission/scope permission/scope denied 执行受控调用 系统 API denied/not found/conflict 结构化错误 result typed result
建议统一错误:
ts
type SystemApiErrorCode =
| "PERMISSION_DENIED"
| "SCOPE_DENIED"
| "USER_CANCELLED"
| "NOT_FOUND"
| "ALREADY_REGISTERED"
| "UNSUPPORTED_PLATFORM"
| "OS_ERROR";
取消文件对话框属于 USER_CANCELLED 或直接返回 null,不应弹红色错误;通知未授权应回退页面提示;默认浏览器异常时应允许复制 URL。
8. 失败实验与根因
8.1 URL 明明是 HTTPS,仍被拒绝
根因 :capability 只批准了其他 URL,或批准项要求 app: msedge,实际调用使用默认浏览器。
8.2 Edge 未安装,点击无反应
根因:当前实现强制指定 Edge,但错误被 UI 静默吞掉。
改进:显示错误并提供"使用默认浏览器"或"复制地址"。
8.3 路径带中文或空格时打开失败
根因:把路径拼进 shell 字符串。应传结构化 path 给 opener/Rust API。
8.4 dialog 选择了 .xml 就认为是合法 SCL
根因:把扩展名过滤器当成安全验证。SCL 仍需大小限制、XML 安全解析和语义校验。
8.5 快捷键第一次有效,热更新后触发两次
根因:组件重挂载时重复注册,旧 listener/shortcut 没有 unregister。
8.6 通知在开发环境可用,安装后没有显示
检查:系统通知权限、应用标识、打包签名、平台设置以及 foreground/background 策略。
8.7 任意参数都能传给 sidecar
根因 :shell scope 使用 args: true 或过宽 validator。应逐项定义固定参数和范围。
9. 测试与验收
9.1 URL 策略测试
| 输入 | 预期 |
|---|---|
| EMS 文档精确 URL | 允许 |
| GitHub/Gitee 主页 | 允许 |
javascript:... |
拒绝 |
file:///... |
拒绝 |
| 同域但未批准路径 | 按策略拒绝 |
当前动态端口 /docs |
仅端口与路径均匹配时允许 |
9.2 路径与文件测试
- 空路径、相对路径、缺失目录;
- Unicode、空格、长路径;
- 文件伪装扩展名;
- 超大 SCL/Excel;
- 符号链接指向 scope 外;
- 保存目标父目录不存在;
- 用户取消 dialog;
- 大导出不经过
number[]IPC。
9.3 系统能力测试
- 通知允许、拒绝和系统关闭三种状态;
- 快捷键冲突、重复注册、反注册;
- 剪贴板写入失败和敏感字段脱敏;
- 默认浏览器缺失/异常;
- sidecar 参数注入、stdout 洪泛、异常退出;
- Windows、macOS、Linux 的 Unsupported 分支。
9.4 本篇验收清单
- 能在 core、插件、Rust crate、原生 FFI 之间做选择;
- EMS 外链使用有限 key 和 URL scope;
- 指定 Edge 与系统默认浏览器的策略差异明确;
- 日志目录使用用途型 command,不接受任意路径;
- dialog 取消是正常分支,文件内容仍二次验证;
- 大文件不通过 JSON 数组穿越 IPC;
- 快捷键和监听器有明确清理;
- shell 不拼接命令,sidecar 程序和参数均受限。
10. 常见误区
误区一:官方插件已经替我完成安全设计
插件提供权限与 scope 机制,但是否批准了过宽 URL、路径或参数仍由应用负责。
误区二:系统默认浏览器不够"可控"
除非产品确有 Edge 专属功能,尊重用户默认应用通常是更自然的桌面体验。
误区三:dialog 选中的文件永远可信
用户可能误选,文件可能恶意,选择器只解决交互,不解决内容安全。
误区四:Shell 能做所有系统调用,所以只装 Shell
Shell 是高风险通用能力。能用 opener、dialog 或专用 Rust API表达的操作,不应降级为命令字符串。
误区五:平台不支持时返回空值就行
空值可能被业务理解成"没有告警"。应返回明确的 UNSUPPORTED_PLATFORM。
11. 本篇小结与官方资料
系统能力的可靠调用链可以概括为:
text
有限业务意图
↓
capability + permission + scope
↓
core / plugin / Rust service / native API
↓
结构化结果与可恢复错误
↓
显式资源清理
EMS 当前的 opener、dialog、open_directory、save_file 和 sidecar 已经构成真实基础。后续优化重点是用途型 command、默认浏览器策略、路径/URL scope 收紧和大文件传输边界。
官方资料:
- Opener Plugin
- Dialog Plugin
- Shell Plugin
- Notifications Plugin
- Clipboard Plugin
- Global Shortcut Plugin
- Capabilities
- File System Plugin
下一篇把"能选文件、能写文件"推进为完整数据层:正确划分安装资源、运行配置、SQLite、点表、SCL、日志和敏感数据,并保证升级、崩溃和跨安装格式下仍能恢复。