ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Macro 桌面端 iOS 文件导出插件权限体系详解:file-export 五大命令与最小权限配置

Macro 桌面端 iOS 文件导出插件权限体系详解:file-export 五大命令与最小权限配置
  • 后端
  • 前端
  • 协同办公
  • 即时通讯
  • 企业应用
  • CRM
  • AI Agent
  • 人工智能

【免费下载链接】macro

Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.

项目地址:https://gitcode.com/GitHub_Trending/macro3/macro
点击查看免费下载

本文以 Macro(统一团队工作空间:邮件、聊天、文档、任务、Agent、通话与 CRM)iOS 客户端中的tauri-plugin-file-export插件为对象,完整梳理其自动生成的权限清单(permission reference),并结合 Rust 注册层、Swift 原生实现与前端调用链,说明每个allow-*/deny-*标识符背后的真实能力边界、Capability 配置方式以及排障思路。读完后你将能够独立完成该插件在capabilities/*.json中的授权收敛、理解 5 个命令的调用时序与配额限制,并判断何时应退回 Web Share 方案。

一、这份权限清单是什么

关联文档 reference.md 是 Tauri 构建流程自动产出的权限参考表,它把插件暴露给前端的每一个命令都拆成一对标识符:允许(allow)与拒绝(deny)。表中的描述统一使用 “without any pre-configured scope”,意味着这些权限只控制命令是否可被调用,不携带任何参数级的作用域(scope)约束。

这类文件的来源非常明确:插件的 build.rs 在构建期声明命令列表,Tauri 据此生成权限元数据:

fn main() { tauri_plugin::Builder::new(&[ "available", "beginExport", "appendExport", "finishExport", "discardExport", ]) .ios_path("ios") .build(); }

.ios_path("ios")指向 Swift Package 所在目录,使得同一份命令列表同时服务于 Rust 侧的插件注册与 iOS 侧的原生桥接。生成结果分布在两处:

  • permissions/autogenerated/commands/*.toml:每个命令一个文件,内含allow-<cmd>与deny-<cmd>两条内联权限定义;
  • permissions/autogenerated/reference.md:即本文所依据的汇总表。

对应的 JSON Schema 位于 schema.json,所有*.toml通过"$schema" = "../../schemas/schema.json"引用它,从而在编辑时获得补全与校验。这些文件均标注# Automatically generated - DO NOT EDIT!,任何手工改动都会在下次构建时被覆盖,正确做法是修改build.rs或 Capability 配置。

二、五个命令的完整权限表

以下是关联文档中权限表的完整内容整理,并补充了每个命令在原文档之外的实际语义(依据 FileExportPlugin.swift):

标识符原文档描述实际作用
file-export:allow-appendExportEnables the appendExport command without any pre-configured scope.允许向前端调用appendExport,追加 base64 分片
file-export:deny-appendExportDenies the appendExport command without any pre-configured scope.显式拒绝appendExport
file-export:allow-availableEnables the available command without any pre-configured scope.允许探测原生桥是否可用
file-export:deny-availableDenies the available command without any pre-configured scope.显式拒绝能力探测
file-export:allow-beginExportEnables the beginExport command without any pre-configured scope.允许创建暂存导出会话并获取 token
file-export:deny-beginExportDenies the beginExport command without any pre-configured scope.显式拒绝创建导出会话
file-export:allow-discardExportEnables the discardExport command without any pre-configured scope.允许主动丢弃暂存文件
file-export:deny-discardExportDenies the discardExport command without any pre-configured scope.显式拒绝丢弃操作
file-export:allow-finishExportEnables the finishExport command without any pre-configured scope.允许结束写入并唤起保存/分享面板
file-export:deny-finishExportDenies the finishExport command without any pre-configured scope.显式拒绝结束导出

每个.toml的结构都是对称的,以 beginExport.toml 为例:

# Automatically generated - DO NOT EDIT! "$schema" = "../../schemas/schema.json" [[permission]] identifier = "allow-beginExport" description = "Enables the beginExport command without any pre-configured scope." commands.allow = ["beginExport"] [[permission]] identifier = "deny-beginExport" description = "Denies the beginExport command without any pre-configured scope." commands.deny = ["beginExport"]

注意deny并非“默认状态”,而是在 Capability 中显式声明时用于覆盖 allow。按 Tauri 权限模型,同一个命令同时出现在 allow 与 deny 中时以 deny 为准,这为在宽泛授权(例如引入某个权限集)之后做精细化收口提供了手段。

三、在 Capability 中启用插件

权限标识符只有被 Capability 引用才会生效。Macro 的 iOS Capability 位于 ios.json,其中完整列出了 5 个 allow 权限:

{ "identifier": "ios-capability", "platforms": ["iOS"], "windows": ["main"], "permissions": [ "auth:allow-authenticate", "file-export:allow-available", "file-export:allow-beginExport", "file-export:allow-appendExport", "file-export:allow-finishExport", "file-export:allow-discardExport", "edit-menu:allow-set-native-menu-suppressed", "pasteboard:allow-read-pasteboard-text", "network-status:allow-watch-status" ] }

几个容易踩坑的配置要点:

  1. platforms必须是["iOS"]。插件的 Rust 注册被#[cfg(target_os = "ios")]包裹,即便在 macOS 桌面构建中引入该 Capability,也不会注册出任何命令。
  2. windows指定为main,与 Tauri 主窗口标签一致;若多窗口应用另开窗口并需要导出能力,需要补充对应窗口标签。
  3. available的授权不可省略。它用于运行期能力探测,缺少该权限会让前端误判为“老版本二进制”,从而静默退化到 Web Share(这部分逻辑见下文第六节)。
  4. Capability 文件与插件目录分离(src-tauri/capabilitiesvstauri/file_export_plugin/permissions),前者是应用级授权决策,后者是插件级能力声明,两者职责不可混淆。

四、Rust 侧注册:插件如何接入应用

插件的 Rust 入口非常薄,仅负责在 iOS 上注册 Swift 桥,见 lib.rs:

//! Native iOS save and share adapters for already-authorized file bytes. #![deny(missing_docs)] use tauri::Runtime; use tauri::plugin::{Builder, TauriPlugin}; #[cfg(target_os = "ios")] tauri::ios_plugin_binding!(init_plugin_file_export); /// Registers the iOS file export bridge. pub fn init<R: Runtime>() -> TauriPlugin<R> { let builder = Builder::new("file-export"); #[cfg(target_os = "ios")] let builder = builder.setup(|_app, api| { api.register_ios_plugin(init_plugin_file_export)?; Ok(()) }); builder.build() }

要点解析:

  • Builder::new("file-export")中的字符串就是权限标识符前缀。它必须与capabilities/ios.json里的file-export:前缀完全一致,否则所有授权都会失效。
  • #![deny(missing_docs)]强制公共项必须有文档,这也是init上存在 doc comment 的原因。
  • 在src-tauri中,插件以tauri_plugin_file_export::init()形式注册,且被限定在 iOS 分支,见 apps/web/tauri/src-tauri/src/lib.rs;依赖声明在 src-tauri/Cargo.toml。工作区成员列表也在 apps/web/tauri/Cargo.toml 中登记。

因为命令实现全部在 Swift 侧,Rust 侧没有#[tauri::command],所以权限表与实际实现之间只有一层 Swift@objc方法名映射:命令名beginExport对应@objc public func beginExport(_ invoke: Invoke),逐字对应,大小写敏感。

五、Swift 实现:每个权限背后的真实约束

FileExportPlugin.swift 是整个功能的核心。它把磁盘操作与 UI 操作严格隔离:

// Disk operations and transfer state stay on one queue, away from UIKit. private let worker = DispatchQueue(label: "com.macro.file-export", qos: .userInitiated) private let directory = FileManager.default.temporaryDirectory .appendingPathComponent("macro-file-exports", isDirectory: true)

所有暂存状态(staged、presentingTokens)只在worker队列访问,UI 呈现只在主队列进行,这种“单队列串行 + 主线程独占 chooser”的设计避免了并发写同一文件与重复弹窗。

5.1available:能力探测

@objc public func available(_ invoke: Invoke) { invoke.resolve(true) }

在 iOS 14 以下,initPlugin()会返回UnavailableFileExportPlugin,其available解析为false。因此allow-available不只是一个可有可无的探测权限,它是版本兼容开关。

5.2beginExport:创建暂存会话

参数结构仅包含name与size:

private struct BeginExport: Decodable { let name: String let size: Int64 }

前端会额外传mimeType,但 Swift 侧Decodable忽略未知字段,因此 MIME 类型目前不参与落盘命名。该命令执行的关键校验(均在worker队列):

self.cleanupStaleFiles() guard args.size >= 0, args.size <= 500 * 1024 * 1024, self.staged.count < 4 else { invoke.reject("File export exceeds the 500 MB or active-transfer limit") return } let name = (args.name as NSString).lastPathComponent guard !name.isEmpty, name != ".", name != "..", !name.contains("\0") else { invoke.reject("Invalid filename") return }

约束归纳:

约束项取值失败提示
单文件大小0 ~ 500 MBFile export exceeds the 500 MB or active-transfer limit
并发暂存会话少于 4 个同上
文件名非空、非./..、不含\0Invalid filename
路径穿越仅取lastPathComponent无需额外提示,直接剥离目录

成功后创建UUID目录、以Data()预创建空文件,并返回{ token }。先创建空文件是为了后续FileHandle(forWritingTo:)能直接定位写入点。

5.3appendExport:分片写入

guard args.data.utf8.count <= 350_000, let data = Data(base64Encoded: args.data), data.count <= 256 * 1024, export.written + Int64(data.count) <= export.size else { invoke.reject("Invalid file chunk") return }

三层防护值得注意:base64 编码后的字符串长度上限 350,000(对应 256 KiB 原文约 349,528 字符,留有余量);解码后原始字节上限 256 KiB;累计写入量不得超过beginExport声明的size。最后一条同时充当溢出保护与尺寸一致性校验——只有累计写入量恰好等于声明尺寸,finishExport才会放行。

写入完成后更新export.written与updatedAt,后者服务于 5 分钟空闲过期清理。

5.4finishExport:唤起原生面板

guard args.action == "save" || args.action == "share", let export = self.staged[args.token], export.written == export.size else { invoke.reject("Incomplete or invalid file export") return }

action只接受save与share两个值(前端类型NativeFileAction另含copy,但 iOS 分支不实现copy)。随后在主队列完成一系列就绪性检查:是否存在 presenter、current是否已被占用、presenter 是否可见且不在转场中。任一不满足都会删除暂存目录并拒绝,错误信息分别为:

  • A file chooser cannot be opened right now(无current槽位或找不到前台 key window)
  • The app is not ready to present a file chooser(视图未挂载、正在 dismiss/present)

save使用UIDocumentPickerViewController(forExporting:asCopy: true);share使用UIActivityViewController,并为 iPad 配置居中 popover:

popover.sourceView = presenter.view popover.sourceRect = CGRect(x: presenter.view.bounds.midX, y: presenter.view.bounds.midY, width: 1, height: 1) popover.permittedArrowDirections = []

取消路径有三条,分别对应文档选择器取消、选择器回调空数组、以及下滑手势 dismiss(UIAdaptivePresentationControllerDelegate),最终都汇聚到complete(token:canceled:),保证每条路径只结算一次。

5.5discardExport:主动回收

discardExport幂等:若 token 已不存在,仅返回成功;若该 token 正对应已弹出的面板,则先 dismiss 再以canceled: true结算。这条命令是前端finally块清理的依托,也是取消场景的兜底。

5.6 过期与配额回收策略

let transferCutoff = Date().addingTimeInterval(-5 * 60) for (token, export) in staged where export.updatedAt < transferCutoff { staged.removeValue(forKey: token) try? FileManager.default.removeItem(at: export.url.deletingLastPathComponent()) }

cleanupStaleFiles()在每次beginExport时执行,分两步:

  1. 5 分钟空闲过期:未推进的暂存会话被清除,并先于计数判断执行——这样 WebView 重载导 致的孤儿 token 不会永久占满 4 个槽位。
  2. 24 小时目录过期:磁盘上未被staged或presentingTokens引用的目录,修改时间早于 24 小时前才删除。成功分享的文件因此保留 24 小时,供分享扩展异步读取。这也是complete中对share成功分支只从presentingTokens移除、不删目录的原因。

六、前端调用链与权限的对应关系

前端封装位于 nativeFileExport.ts,分片常量与原生上限严格对齐:

const EXPORT_CHUNK_BYTES = 256 * 1024;

完整时序(每个invoke都对应一个权限标识符):

步骤invoke 目标所需权限
1plugin:file-export|availableallow-available
2plugin:file-export|beginExportallow-beginExport
3plugin:file-export|appendExport(循环 N 次)allow-appendExport
4plugin:file-export|finishExportallow-finishExport
5plugin:file-export|discardExport(finally与 abort)allow-discardExport

关键实现细节:

  • 取消监听:仅对file-export注册 abort 监听,触发即调用discardExport,让原生面板立即关闭:

    const discard = () => fireAndForget( invoke(`plugin:${plugin}|discardExport`, { token }), 'file-export-cancel' ); if (plugin === 'file-export') signal?.addEventListener('abort', discard, { once: true });
  • 主线程友好的编码方式:btoa要求 Latin-1 字符串,因此逐字节String.fromCharCode拼接后再编码,避免String.fromCharCode(...bytes)在大分片下爆栈。

  • 清理不掩盖结果:finally中的discardExport使用.catch吞掉错误并console.error,防止清理失败覆盖导出结果或原始异常。

  • 可观测性:Telemetry.span('file.native.export')下挂file.native.transfer与file.native.chooser两个子 span,并记录file.outcome(canceled/handed_off)。

能力探测封装在 iosFiles.ts:

export async function hasIosFileExport(): Promise<boolean> { try { return await invoke<boolean>('plugin:file-export|available'); } catch { return false; } }

catch 分支的意义是:已安装的老版本 App 二进制里根本没有该插件,available会以 “Plugin file-export not found” 失败,此时返回 false,UI 自动退回 Web Share。任何移除该 catch 或deny-available的改动都会破坏这一兼容契约。

上层 UI 逻辑在 createFileDownload.ts 中按平台分流:

} else if (nativeIos) { ({ canceled } = await exportIosFile(ready, action, { signal: request.signal, onProgress: (fraction) => { /* 更新进度 */ }, })); } else if (action === 'share' || isPlatform('ios')) { await navigator.share({ files: [ready] }); }

可以看到判定链是android → nativeIos → Web Share →兜底;nativeIos在每次start()时重新探测,因此同一台设备在升级到带插件的二进制后无需重启逻辑即可切换路径。

七、测试与验证

关联文档未涉及验证手段,仓库提供了两层可执行检查:

    • Rust 注册层:build.rs与init()的编译与注册。文档化的命令为(在仓库根目录执行):

      nix develop .#tauri-linux --command cargo test \ --manifest-path apps/web/tauri/Cargo.toml -p tauri-plugin-file-export

      需注意主机环境只能覆盖 Rust 侧注册,Swift 行为必须在 macOS 上构建 iOS App 后人工验证 save / share / 取消 / 前后台切换 / iPad popover。

    • 前端行为层:

    iosFiles.test.ts 覆盖了两个易回归点:
    • 老二进制场景:invoke抛错时hasIosFileExport()返回false,且只调用一次invoke;
    • 分片正确性:600,000 字节数据被切成 3 片(256 KiB + 256 KiB + 剩余),解码拼接后与原字节数组完全相等,并以{ token, action: 'save' }调用finishExport;
    • 取消路径:abort 后返回{ canceled: true },最后一条命令为discardExport。

    createFileDownload.test.ts 则 mock 了exportIosFile与hasIosFileExport,验证平台分流与 UI 状态机。

八、授权收敛与常见故障排查

8.1 最小授权建议

如果只需“另存为”而不需要分享,可只保留:

"permissions": [ "file-export:allow-available", "file-export:allow-beginExport", "file-export:allow-appendExport", "file-export:allow-finishExport", "file-export:allow-discardExport" ]

share与save共用finishExport,无法通过权限区分,若需按场景限制,只能在业务层限制传入的action。切勿只授予beginExport而遗漏discardExport:取消路径与finally清理都会失败,暂存目录只能依赖 5 分钟 / 24 小时两级过期兜底。

8.2 故障对照表

现象可能原因定位方式
前端静默走 Web Share,从未弹原生面板缺少allow-available、插件未在#[cfg(target_os = "ios")]分支注册、或运行在 iOS 14 以下检查capabilities/ios.json与 src-tauri/src/lib.rs;available应返回true
File export exceeds the 500 MB or active-transfer limit文件超 500 MB,或已有 4 个未完成暂存确认finally中discardExport是否被调用
Invalid filename文件名为空、.、..或含\0检查传入name的来源
Invalid file chunk分片超 256 KiB、base64 串超 350,000 字符、或累计超出声明 size核对EXPORT_CHUNK_BYTES与size是否等于blob.size
Incomplete or invalid file exportwritten != size,或action不是save/share分片写入是否被中断;action是否误传copy
A file chooser cannot be opened right now已有面板在展示,或找不到前台 key window避免并发导出;确认 App 处于foregroundActive
The app is not ready to present a file chooserpresenter 未挂载或正在转场稍后重试,勿在页面切换动画期间触发

8.3 发布层面的约束

原生改动需要新的 App Store 二进制才能到达设备。该桥接是纯增量设计——旧版 App 没有插件时,available失败并保留 Web Share;新版 App 有插件时走原生面板——因此 Web 侧可以先行发布,原生插件随后通过受控发布流程上线。在原生二进制尚未覆盖全部用户前,不要移除hasIosFileExport的 catch 兼容分支。

九、小结

file-export插件的权限体系看似只是 10 行标识符的自动生成表格,实际上串起了三层设计:build.rs声明命令面、Capability 决定授权面、Swift 实现决定能力面。理解allow-available的兼容探测语义、appendExport的 256 KiB 分片与 500 MB/4 会话配额、以及cleanupStaleFiles的 5 分钟与 24 小时两级回收,才能真正安全地调整 reference.md 所描述的权限清单,而不是只做表面的 JSON 增删。

  • 后端
  • 前端
  • 协同办公
  • 即时通讯
  • 企业应用
  • CRM
  • AI Agent
  • 人工智能

【免费下载链接】macro

Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.

项目地址:https://gitcode.com/GitHub_Trending/macro3/macro
点击查看免费下载

相关推荐

上一篇:omiGlass 视觉语言模型评测数据解析:img_53 四模型描述对比与图像理解管线源码剖析
下一篇:Velero(Ark)`schedule delete` 命令详解:删除定时备份计划的最佳实践与实现原理

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进