iRightMenu Pro 插件开发指南

本指南涵盖了开发 iRightMenu Pro 插件所需的全部知识。

目录


插件结构

每个插件至少包含两个文件:

  • plugin.json - 声明元数据、菜单、设置(纯配置,无代码)
  • main.lua - 实现菜单处理函数(业务逻辑)

拆成多个文件

逻辑变复杂时可以拆开——引擎为插件目录配好了 require 搜索路径:

com.example.my-plugin/
├── plugin.json
├── main.lua
├── parser.lua          -- require("parser")
└── utils/
    └── init.lua        -- require("utils")
local parser = require("parser")
local utils  = require("utils")

搜索路径就是插件目录,等价于:

package.path = <插件目录>/?.lua;<插件目录>/?/init.lua

只能加载 Lua 模块。 package.cpath 被清空,C 扩展模块加载不了——插件运行在 沙箱里,这是刻意的限制。

拆分后每个文件都要过检查:子模块里调用 app.shell.execute 同样需要在 plugin.json 里声明 shell 权限,而主文件里看不出来。用 tools/pluginlint 可以自动查(它会跟着 require 递归扫描)。


配置文件 (plugin.json)

完整格式

{
  "manifestVersion": 1,
  "id": "com.example.my-plugin",
  "name": "我的插件",
  "version": "1.0.0",
  "minEngineVersion": "2.0.0",
  "author": "作者名",
  "description": "插件描述",
  "main": "main.lua",
  "icon": "icon.png",
  "locale": "zh-Hans",
  "category": "file",
  "type": "context",
  "permissions": ["file", "http"],

  "settings": {
    "title": "插件设置",
    "fields": [...]
  },

  "menus": [...]
}

基础字段

字段 类型 必需 说明
manifestVersion Int plugin.json 格式规范版本(当前为 1)
id String 唯一标识符(反向域名格式)
name String 显示名称(支持 i18n 占位符)
version String 语义化版本(如 “1.0.0”)
minEngineVersion String 最低 iRightMenu 版本要求
author String 作者名称
description String 插件描述
main String 主脚本文件名(默认 “main.lua”)
icon String 图标文件名(相对路径)
locale String 默认语言(如 “en”、”zh-Hans”)
homepage String 插件主页 URL(官网、源码仓库等)
category String 插件分类,用于商店展示。可选值:fileimagedevelopmentsystemother(默认)
type String 插件类型:"context"(右键菜单,默认)或 "resident"(常驻插件)
permissions [String] 权限声明(省略=全部允许,[]=仅安全 API,["*"]=全部)

权限系统

插件通过 permissions 字段声明所需 API 权限。权限分为 4 个等级:

L0 安全(Safe)— 无需声明,始终可用

权限名 API 模块 说明
log app.log 日志记录
context app.context Finder 上下文
dialog app.dialog 对话框和表单
progress app.progress 进度对话框
path app.path 路径工具
i18n app.i18n 国际化
json app.json JSON 操作
string app.string 字符串工具
date app.date 日期时间
regex app.regex 正则表达式
notification app.notification 系统通知
settings app.settings 插件设置
thread app.thread 并发/协程
uti app.uti 类型标识符
url app.url URL 解析构建
csv app.csv CSV 解析生成
xml app.xml XML 解析生成
color app.color 颜色工具

L1 标准(Standard)— 需在 permissions 中声明

权限名 API 模块 说明
file app.file 文件读写
http app.http HTTP 请求
archive app.archive 归档操作
plist app.plist Plist 读写
defaults app.defaults UserDefaults 偏好
image app.image 图片处理
crypto app.crypto 加密编码
clipboard app.clipboard 剪贴板
finder app.finder Finder 操作
system app.system 系统信息
keychain app.keychain 安全凭证存储
xattr app.xattr 扩展属性/标签
trash app.trash 废纸篓
metadata app.metadata Spotlight 元数据
pdf app.pdf PDF 操作
network app.network 网络信息
power app.power 电源/电池
screen app.screen 屏幕/外观
audio app.audio 音频控制
ocr app.ocr 文字识别
qrcode app.qrcode 二维码
wakelock app.wakelock 防休眠
watcher app.watcher 文件监听
share app.share macOS 分享
shortcuts app.shortcuts 快捷指令
timer app.timer 定时器
websocket app.websocket WebSocket 客户端
chooser app.chooser 快速选择器
dock app.dock Dock 角标/弹跳

L2 敏感(Sensitive)— 安装时显示权限警告

权限名 API 模块 说明
shell app.shell Shell 命令执行
database app.database SQLite 数据库
process app.process 进程管理
application app.application 应用程序控制
webview app.webview WebView 窗口
statusbar app.statusbar 状态栏图标
window app.window 窗口控制(需辅助功能权限)
location app.location 地理位置(首次调用弹系统授权)
bluetooth app.bluetooth 蓝牙状态与设备(首次调用弹系统授权)
hardware_id (函数级) 机器序列号、硬件 UUID、磁盘序列号等可识别本机的标识
login_item (函数级) 设置开机自启(仅 app.system.setLoginItem 需要)

最后两条不绑定整个模块,只门控个别函数。 app.system 本身是 L1, 但里面取硬件标识的几个函数要额外声明 hardware_id,改开机启动项要 login_item。 不声明就调,运行时会拿到 permissionDenied

L3 危险(Dangerous)— 安装时显示强烈安全警告

权限名 API 模块 说明
applescript app.applescript AppleScript(完整系统访问)
keyboard app.keyboard 键盘模拟(需辅助功能权限)
mouse app.mouse 鼠标模拟(需辅助功能权限)
hotkey app.hotkey 全局热键(需辅助功能权限)

权限声明示例

// 只使用安全 API(无需声明)
{"permissions": []}

// 需要文件和网络
{"permissions": ["file", "http"]}

// 需要全部权限
{"permissions": ["*"]}

// 省略 permissions 字段 = 允许全部(兼容旧插件)
{}

菜单配置

{
  "menus": [
    {
      "id": "menu_action",
      "title": "执行操作",
      "description": "菜单项描述",
      "icon": "action.png",
      "enabled": true,
      "showInStatusBar": false,
      "primaryMenu": false,
      "dynamic": false,

      "showOnFiles": true,
      "showOnFolders": false,
      "fileMatchType": "none",
      "fileMatchPatterns": [],
      "fileExecutableOnly": false,
      "minFiles": 1,
      "maxFiles": 0,

      "folderMatchType": "none",
      "folderMatchPatterns": [],
      "minFolders": 0,
      "maxFolders": 0,

      "notifyType": "none",
      "requireConfirm": false,
      "playSound": false,
      "soundPath": null
    }
  ]
}

菜单字段

字段 类型 默认值 说明
id String 必需 菜单项唯一标识,用于绑定点击回调和动态行为
title String 必需 菜单项文本(支持 i18n)
description String ”” 工具提示/描述
icon String null 菜单图标文件名
enabled Boolean true 是否启用
showInContextMenu Boolean true 在右键菜单中显示
showInStatusBar Boolean false 在状态栏菜单中显示
primaryMenu Boolean false 在主菜单中显示(非子菜单)
dynamic Boolean false 启用动态标题(见「动态标题」,只在状态栏菜单生效

showInStatusBar 让菜单项出现在 iRightMenu 自己的状态栏菜单里,无需额外权限。 若要创建插件自己独立的菜单栏图标(持续显示某个值),用 app.statusbar—— 那是 L2 sensitive 权限。

文件匹配规则

字段 类型 默认值 说明
showOnFiles Boolean true 选中文件时显示
fileMatchType String “none” 文件匹配模式
fileMatchPatterns Array [] 扩展名/正则匹配模式
fileExecutableOnly Boolean false 仅匹配可执行文件
minFiles Number 0 最少选中文件数(0=不限)
maxFiles Number 0 最多选中文件数(0=不限)

文件夹匹配规则

字段 类型 默认值 说明
showOnFolders Boolean true 选中文件夹时显示
folderMatchType String “none” 文件夹匹配模式(仅支持 “none”、”regex”)
folderMatchPatterns Array [] 正则匹配模式
minFolders Number 0 最少选中文件夹数(0=不限)
maxFolders Number 0 最多选中文件夹数(0=不限)

执行配置

字段 类型 默认值 说明
notifyType String “none” 执行后通知方式:"none"(不通知)、"notification"(系统通知)。插件默认 none,插件自行通过 app.notification API 管理反馈
requireConfirm Boolean false 执行前是否弹出确认对话框
playSound Boolean false 执行后是否播放提示音
soundPath String null 提示音文件名(如 "Glass.aiff"),为空时使用系统默认提示音

文件匹配类型

类型 说明 fileMatchPatterns
none 通用匹配,不限制文件类型 忽略
extension 按文件扩展名匹配 ["jpg", "png", "gif"]
regex 按正则表达式匹配文件名 ["^IMG_\\d+"]
executable 仅匹配可执行文件 忽略

脚本文件 (main.lua)

基本结构

-- 继承 Plugin 基类创建插件类
local MyPlugin = Plugin:extend()

-- 初始化:注册处理函数
function MyPlugin:init()
    -- 注册菜单动作处理函数
    self:registerHandler("menu_id", self.handleAction)

    app.log.info("插件已初始化")
end

-- 菜单动作处理函数
-- @param context {selectedFiles, currentDirectory}
function MyPlugin:handleAction(context)
    local files = context.selectedFiles
    local dir = context.currentDirectory

    -- 你的业务逻辑...
end

-- 动态标题(可选,只在状态栏菜单生效——见「动态标题」一节)
-- @return string 新标题;nil 表示用 plugin.json 里的静态标题
function MyPlugin:getMenuTitle(menuId, context)
    return nil
end

-- 必须返回插件类(引擎自动实例化)
return MyPlugin

context 上下文对象

传递给处理函数的 context 对象包含:

context = {
    selectedFiles = {"/path/to/file1", "/path/to/file2", ...},
    currentDirectory = "/path/to/current/dir"
}

Plugin 基类

Plugin 基类已预加载到全局环境中。

创建插件

-- 继承基类
local MyPlugin = Plugin:extend()

-- 实现 init()
function MyPlugin:init()
    -- 在这里注册处理函数
end

-- 返回插件类(必需,引擎自动实例化)
return MyPlugin

元数据方法

方法 返回值 说明
self:getId() String 插件 ID
self:getName() String 插件名称
self:getVersion() String 插件版本
self:getAuthor() String 插件作者
self:getDescription() String 插件描述
self:getMetadata() Table 完整元数据
self:getInstallPath() String 插件安装目录路径

处理函数注册

-- 注册菜单动作处理函数
self:registerHandler("menu_id", self.handleAction)

菜单项显隐用 plugin.json 的声明式字段控制(showOnFiles / fileMatchPatterns / minFiles 等),不需要注册回调。

重要:使用方法引用(self.methodName)而非闭包。 处理函数接收 (self, context) 两个参数,使用闭包会导致参数错位。

生命周期方法(可选)

function MyPlugin:onLoad()
    -- 插件环境加载完成后调用(每次启动)
end

function MyPlugin:onUnload()
    -- 插件环境即将卸载时调用
end

function MyPlugin:onInstall()
    -- 首次安装后调用(在 onLoad 之后触发)
end

function MyPlugin:onUpdate(oldVersion)
    -- 插件更新后调用(在 onLoad 之后触发)
    -- oldVersion: 更新前的版本号
    app.log.info("Updated from " .. oldVersion)
end

function MyPlugin:onUninstall()
    -- 卸载前调用(在 onUnload 之前触发,可用于清理资源)
end

调用时序:

场景 调用顺序
应用启动 onLoad
安装插件 onLoadonInstall
更新插件 旧插件 onUnload → 新插件 onLoadonUpdate(oldVersion)
卸载插件 onUninstallonUnload
禁用/启用 onUnload / onLoad

注意onInstallonUpdate 在插件环境完全就绪后才触发,可安全使用所有 API。 onUninstall 在文件删除前同步调用,适合清理 Keychain 凭证、临时文件等资源。

访问插件资源

function MyPlugin:init()
    -- 获取内置工具路径
    local tool = self:getInstallPath() .. "/bin/mytool"

    -- 使用 app.path.join 拼接路径
    local config = app.path.join(
        self:getInstallPath(),
        "config",
        "default.json"
    )

    -- 检查资源是否存在
    if app.path.exists(self:getInstallPath() .. "/resources/data.json") then
        app.log.info("资源文件存在")
    end
end

菜单匹配规则

基本匹配

{
  "menus": [{
    "id": "process",
    "title": "处理",
    "showOnFiles": true,
    "showOnFolders": false,
    "minFiles": 1
  }]
}

此菜单在以下情况显示:

  • 至少选中 1 个文件
  • 未选中文件夹

扩展名匹配

{
  "menus": [{
    "id": "resize_image",
    "title": "调整图片大小",
    "fileMatchType": "extension",
    "fileMatchPatterns": ["jpg", "jpeg", "png", "gif", "webp"],
    "showOnFiles": true,
    "showOnFolders": false
  }]
}

此菜单仅对图片文件显示。

正则匹配

{
  "menus": [{
    "id": "process_screenshots",
    "title": "处理截图",
    "fileMatchType": "regex",
    "fileMatchPatterns": ["^Screenshot.*\\.png$", "^截屏.*\\.png$"],
    "showOnFiles": true
  }]
}

动态标题

plugin.json 里声明 "dynamic": true,菜单就能每次弹出时重新生成标题——实现 getMenuTitle(menuId, context) 即可(约定方法名,不需要注册)。返回 nil 表示这一项 用 plugin.json 里的静态标题。

function MyPlugin:getMenuTitle(menuId, context)
    if menuId ~= "toggle_proxy" then
        return nil   -- 其余菜单项保持静态标题
    end
    if self:isProxyOn() then
        return app.i18n.t("menu.turn_off")
    end
    return app.i18n.t("menu.turn_on")
end

用它把信息直接显示在菜单上,用户不点也能看见——这往往比点开一个对话框更有用:

function NetworkIP:getMenuTitle(menuId, context)
    if menuId ~= "copy_local_ip" then return nil end
    local ip = app.network.localIP()
    if not ip then
        return app.i18n.t("menu.offline")
    end
    -- 菜单上直接显示「本机 IP:192.168.1.100」
    return app.i18n.t("menu.copy_local_ip_with_value", { ip = ip })
end

注意getMenuTitle每次菜单弹出时都会被调用,务必保持轻量。 不要在里面做网络请求或读大文件——那会让右键菜单卡住。上面第二个例子里 app.network.localIP() 读的是本地网络接口,是安全的;换成 app.network.externalIP()(要访问外部服务)就不行。

⚠️ 生效范围:只有状态栏菜单。

Finder 右键菜单由 FinderSync 扩展渲染,那是一个独立进程、里面没有 Lua 引擎 (它只负责画菜单,点击后把动作转交给主 App),所以那边只能显示 plugin.json 里的 静态标题。用户可开启的「覆盖系统右键」虽然由主 App 渲染、技术上跑得了 Lua, 但同样只显示静态标题——同一个手势不该因为一个为别的目的存在的开关就给出不同的 菜单。因此:

  • 静态 title 必须能独立成立——「切换代理」比「关闭代理」好,后者在右键菜单里 可能和实际状态相反
  • 别把关键信息只放在动态标题里。像「本机 IP:192.168.1.5」这种,右键菜单里 IP 值就没了,这类插件应该声明 showInContextMenu: false,只在状态栏出现

pluginlint 会拦这条:声明了 dynamic 却没开 showInStatusBar,等于永不生效,直接报错。

用代码控制菜单项显隐registerVisibility 已移除——它受同样的限制,但在 Finder 右键菜单里会被完全忽略、插件想隐藏也隐藏不掉,作者收不到任何反馈。请改用 plugin.json 的声明式字段(showOnFiles / fileMatchPatterns / minFiles 等),那些是数据库字段, 所有菜单面都生效。旧插件调用它不报错,只在日志里留一条警告。


插件设置

定义设置

{
  "settings": {
    "title": "我的插件设置",
    "fields": [
      {
        "key": "api_key",
        "type": "text",
        "label": "API 密钥",
        "default": "",
        "placeholder": "请输入你的 API 密钥"
      },
      {
        "key": "quality",
        "type": "number",
        "label": "质量",
        "default": 80
      },
      {
        "key": "auto_process",
        "type": "checkbox",
        "label": "自动处理",
        "default": true
      },
      {
        "key": "output_format",
        "type": "select",
        "label": "输出格式",
        "default": "png",
        "options": ["png", "jpg", "webp"]
      },
      {
        "key": "output_dir",
        "type": "folder",
        "label": "输出目录",
        "default": ""
      }
    ]
  }
}

字段类型

类型 说明 返回值类型
text 单行文本输入 String
number 数字输入 Number
checkbox 复选框 Boolean
select 下拉菜单 String
folder 文件夹选择器 String(路径)
file 文件选择器 String(路径)

在 Lua 中使用设置

-- 获取设置值(带默认值)
local apiKey = app.settings.get("api_key", "")
local quality = app.settings.get("quality", 80)

-- 设置值
app.settings.set("last_used", os.time())

-- 获取所有设置
local all = app.settings.getAll()

-- 删除设置
app.settings.remove("temporary_value")

设置存在 <App Group 容器>/PluginData/<插件 id>/settings.json不在插件目录里: 升级插件时插件目录会被整份替换,放在那儿的设置每次升级都会丢。

插件自己的可写目录

要落盘的其它东西——缓存、app.database 的库文件、下载的资源、运行状态——写到 app.context.dataDirectory()

local dir = app.context.dataDirectory()
app.file.write(dir .. "/cache.json", app.json.stringify(data))

local db = app.database.open(dir .. "/history.db")

app.settings 同一个目录,升级插件不受影响,卸载插件时一并清除。

不要写 app.context.pluginDirectory() 那是插件的安装目录,属于随包内容: 升级时整个被替换,写进去的会丢;改动随包分发的文件还会让签名校验失败,用户那边 表现为插件加载不出来。它只用来读随包的资源。


国际化

目录结构

com.example.my-plugin/
├── plugin.json
├── main.lua
└── locales/
    ├── en.json
    └── zh-Hans.json

在 plugin.json 中使用占位符

{
  "name": "{{plugin.name}}",
  "description": "{{plugin.description}}",
  "menus": [
    {
      "id": "action",
      "title": "{{menu.action}}"
    }
  ],
  "settings": {
    "title": "{{settings.title}}",
    "fields": [
      {
        "key": "option",
        "type": "text",
        "label": "{{settings.option}}"
      }
    ]
  }
}

语言文件

locales/en.json(平铺 dot-notation 格式)

{
  "plugin.name": "My Plugin",
  "plugin.description": "A useful plugin",
  "menu.action": "Do Something",
  "settings.title": "Settings",
  "settings.option": "Option Label",
  "messages.success": "Processed %d files successfully",
  "messages.error": "An error occurred: %s"
}

locales/zh-Hans.json

{
  "plugin.name": "我的插件",
  "plugin.description": "一个实用的插件",
  "menu.action": "执行操作",
  "settings.title": "设置",
  "settings.option": "选项标签",
  "messages.success": "成功处理了 %d 个文件",
  "messages.error": "发生错误:%s"
}

在 Lua 中使用

-- 获取翻译字符串
local title = app.i18n.t("plugin.name")

-- 带格式参数
local msg = app.i18n.t("messages.success", 5)
-- 英文:"Processed 5 files successfully"
-- 中文:"成功处理了 5 个文件"

-- 带默认值
local text = app.i18n.t("unknown.key", nil, "默认文本")

-- 检查 key 是否存在
if app.i18n.has("messages.custom") then
    -- ...
end

-- 当前语言
local locale = app.i18n.locale()  -- "en" 或 "zh-Hans"

-- 可用语言列表
local locales = app.i18n.locales()  -- {"en", "zh-Hans"}

调试技巧

日志

-- 日志级别(从低到高)
app.log.verbose("详细信息")   -- 仅插件日志
app.log.debug("调试信息")     -- 仅插件日志
app.log.info("普通信息")      -- 仅插件日志
app.log.warning("警告")       -- 插件 + 系统日志
app.log.error("错误")         -- 插件 + 系统日志

-- 获取日志文件路径
local logPath = app.log.getPath()

-- 读取最后 N 行
local content = app.log.read(100)

-- 清空日志文件
app.log.clear()

日志位置

  • 插件日志~/Library/Group Containers/group.net.ymlab.iRightMenu.pro/Logs/Plugins/{plugin_id}.log
  • 系统日志~/Library/Group Containers/group.net.ymlab.iRightMenu.pro/Logs/iRightMenu.log

实时查看日志

# 查看所有日志
tail -f ~/Library/Group\ Containers/group.net.ymlab.iRightMenu.pro/Logs/*.log

# 查看特定插件
tail -f ~/Library/Group\ Containers/group.net.ymlab.iRightMenu.pro/Logs/Plugins/com.example.myplugin.log

# 过滤错误
tail -f ~/Library/Group\ Containers/group.net.ymlab.iRightMenu.pro/Logs/*.log | grep -i error

常见问题

问题 解决方案
插件不显示 检查 plugin.json JSON 语法
菜单不出现 检查匹配规则(showOnFilesfileMatchType 等)
点击无响应 确认 registerHandler 的 ID 与菜单 ID 匹配
API 返回 nil 查看日志获取详细错误信息
设置未保存 确保使用正确的 key 名称

调试模式

开发时启用详细日志:

function MyPlugin:init()
    app.log.verbose("=== 插件初始化开始 ===")
    app.log.verbose("安装路径: " .. self:getInstallPath())
    app.log.verbose("版本: " .. self:getVersion())

    -- ... 注册处理函数 ...

    app.log.verbose("=== 插件初始化完成 ===")
end

最佳实践

1. 使用唯一的插件 ID

com.yourcompany.plugin-name

2. 优雅地处理错误

function MyPlugin:processFiles(context)
    local success, err = pcall(function()
        for _, file in ipairs(context.selectedFiles) do
            self:processFile(file)
        end
    end)

    if not success then
        app.log.error("处理失败: " .. tostring(err))
        app.dialog.alert({title = "错误", message = "文件处理失败"})
    end
end

3. 提供用户反馈

function MyPlugin:longOperation(context)
    -- 长时间操作显示进度
    local p = app.progress.create({
        title = "处理中",
        message = "准备中...",
        indeterminate = false
    })

    local files = context.selectedFiles
    for i, file in ipairs(files) do
        p:update(
            (i / #files) * 100,
            "正在处理: " .. app.path.basename(file)
        )
        self:processFile(file)
    end

    p:close()

    -- 完成时发送通知
    app.notification.show("完成", "已处理 " .. #files .. " 个文件")
end

4. 始终提供默认值

local quality = app.settings.get("quality", 80)  -- 默认值:80
local format = app.settings.get("format", "png") -- 默认值:"png"

5. 记录重要操作

function MyPlugin:deleteFiles(context)
    app.log.info("[MyPlugin] 开始删除操作")
    app.log.info("[MyPlugin] 待删除文件数: " .. #context.selectedFiles)

    for _, file in ipairs(context.selectedFiles) do
        app.log.info("[MyPlugin] 删除: " .. file)
        -- ...
    end

    app.log.info("[MyPlugin] 删除操作完成")
end

6. 支持国际化

即使最初只支持一种语言,也使用 i18n 占位符,方便将来添加翻译。

7. 验证用户输入

function MyPlugin:renameFiles(context)
    local __form = app.dialog.form({
        title = "输入新名称",
        fields = {{type = "text", id = "value", label = "输入", default = ""}}
    })
    local newName = __form and __form.value

    if not newName or newName == "" then
        return  -- 用户取消或输入为空
    end

    -- 验证文件名
    if newName:match('[/\\:*?"<>|]') then
        app.dialog.alert({title = "无效名称", message = "文件名包含非法字符"})
        return
    end

    -- 继续重命名...
end

8. 清理资源

function MyPlugin:onUnload()
    -- 关闭数据库连接
    if self.db then
        self.db:close()
        self.db = nil
    end

    -- 取消待处理的操作
    thread.stopAll()
end

下一步

开发者文档
使用帮助
使用说明 菜单编辑器指南 插件管理 设置说明 脚本菜单 常见问题
脚本开发
开发指南
插件开发
快速开始 开发指南 示例插件
API 参考
概览 API 查询 插件信息 日志 Finder 上下文 插件设置 国际化
UI 与交互
对话框 进度条 系统通知 选择器 WebView 状态栏 Dock
文件与路径
文件操作 路径工具 Finder 操作 废纸篓 扩展属性 元数据 文件监听
数据格式
JSON Plist CSV XML PDF 图片
文本与编码
字符串 正则表达式 日期时间 颜色 加密编码
系统
Shell 命令 进程管理 应用管理 系统信息 AppleScript 快捷指令
系统信息
网络信息 电源/电池 屏幕/外观 音频控制 蓝牙设备 位置服务
网络
HTTP 请求 WebSocket URL 工具
输入与剪贴板
键盘模拟 鼠标模拟 全局热键 剪贴板 窗口管理
存储
SQLite Keychain UserDefaults
媒体
文字识别 二维码
工具
归档 类型标识 分享 定时器 防休眠 并发/协程