文件

文件 API 面向「用户把文件交给插件」这一类场景:读取剪贴板里的文件、还原拖入文件的路径、按需扫描文件信息、按偏移读取文件内容,以及在用户明确同意后批量重命名或写入。所有参数都是绝对路径,相对路径不会被受理。

文件能力分成三层权限:file:read 管元信息(file.scan、file.exists、file.reveal),file:read-content 管文件内容(file.read),file:write 管写入(file.grant、file.rename、file.write)。写在 manifest.json 的 permissions 里。

file:read 与 file:read-content 是两项权限,不是一项。理由见权限与能力矩阵:file:read 自 1 级起就发给插件了,若把「读字节」并进同一个名字,已发布插件会静默获得读任意文件内容的能力。

写入的底线只有一条:重命名与写入只会碰本会话已登记进授权集合的路径。这个集合由宿主维护,不随清单上的权限字符串放大;剪贴板文件和文件对话框选中的路径由宿主自动登记,用户拖进插件自己拖放区的文件则需要插件显式 grant。读取内容受同一层护栏约束(比 scan 更严,scan 只看权限声明)。

插件怎么拿到剪贴板里的文件

这条链路的完整形态是「用户复制文件 → 呼出客户端 → 宿主列出候选 → 插件拿到路径」。先声明识别能力,两种写法等效,任选其一:

{
  "clipboardRules": [
    {
      "name": "批量重命名",
      "pattern": "剪贴板中的文件或文件夹",
      "example": "D:\\photos\\IMG_0001.jpg",
      "action": "打开批量重命名",
      "matchType": "file"
    }
  ]
}
{
  "features": [
    {
      "name": "批量重命名",
      "description": "重命名剪贴板中的文件",
      "code": "rename",
      "cmds": [{ "type": "file" }]
    }
  ]
}

用户在系统文件管理器里复制文件后按下呼出快捷键,宿主读到剪贴板里的绝对路径,把声明了 matchType: "file" 或 cmds: [{ "type": "file" }] 的插件列为「快捷识别」候选。用户点击候选卡片(或在剪贴板持有文件时按插件名打开插件)后,插件收到的进入动作是:

enterAction = {
  code: 'clipboard',                                               // 剪贴板识别入口为 clipboard;带 code 的指令入口用插件声明的编码
  type: 'file',
  payload: 'D:\\photos\\IMG_0001.jpg\nD:\\photos\\IMG_0002.jpg',    // 换行分隔的绝对路径
  files: ['D:\\photos\\IMG_0001.jpg', 'D:\\photos\\IMG_0002.jpg']  // 同一批路径的数组形式
}
const removeEnter = api && api.onPluginEnter
  ? api.onPluginEnter((action) => {
      if (action.type !== 'file') return
      const paths = action.files ?? action.payload.split('\n').filter(Boolean)
      load(paths)
    })
  : null

payload 与 files 是同一批路径的两种形式,只为照顾只读字符串的插件。独立窗口里的进入动作会被规范化,不保证仍带 files,需要在任何窗口都拿到文件列表的插件应改为调用 api.readClipboardFiles()。

readClipboardFiles

读取系统剪贴板中文件(或文件夹)的绝对路径。需要 clipboard:read。剪贴板里没有文件时返回空数组;只返回此刻真实存在的路径,并且已经去重。

api.readClipboardFiles(): Promise<string[]>
const files = api && api.readClipboardFiles
  ? await api.readClipboardFiles()
  : []

if (files.length) {
  preview.value = files
}

Windows 上有一个实现细节值得留意:系统只通过 CF_HDROP 暴露复制的多文件列表,Electron 读不到,宿主改为向 PowerShell 请求一次 FileDropList,大约耗时 0.5 秒,并在宿主内部记忆 1 秒。因此不要在循环或定时器里调用它,一个用户动作调用一次即可。macOS 读粘贴板的文件列表,Linux 读 text/uri-list。

getPathForFile

把拖拽或粘贴进来的 File 还原成绝对本地路径(Electron 32 移除了 File.path)。不需要权限。解析不出路径时返回空字符串——例如这次拖拽来自浏览器页面内部——所以调用后必须判断返回值。

api.getPathForFile(file: File): string
const paths = Array.from(event.dataTransfer.files)
  .map((file) => api.getPathForFile(file))
  .filter(Boolean)

拖进插件自己拖放区的文件不会被自动登记:必须先用 api.getPathForFile(file) 拿到绝对路径,再调用 api.file.grant([path]),之后才允许重命名它、读取它的内容或写入。对已经登记过的路径重复 grant 是无害的空操作。插件自己拼出来的路径、或者从其他途径拿到的路径,同样要先 grant。

file.scan

展开一批绝对路径,返回每个条目的元信息。需要 file:read。返回值是一个信封对象,条目在 entries 里:

api.file.scan(paths: string[], options?: PluginFileScanOptions): Promise<PluginFileScanResult>
type PluginFileScanOptions = {
  recursive?: boolean          // 是否继续往下钻子目录;默认 false(但目录本身一律展开一层)
  includeDirectories?: boolean // 把展开出来的子目录也作为条目返回;默认 false
  includeHidden?: boolean      // 是否包含以点开头的隐藏项;默认 true
  match?: string               // 简易 glob:* 不跨目录,** 跨目录,? 单字符
  limit?: number               // 默认 20000,最大 50000;撞上限时 truncated 为 true
}

type PluginFileEntry = {
  path: string        // 绝对路径
  directory: string   // 父目录绝对路径
  name: string        // 含扩展名的文件名
  stem: string        // 不带扩展名的主名;无扩展名时等于 name
  extension: string   // 扩展名含点;目录与无扩展名文件为空串
  isDirectory: boolean
  size: number        // 字节;目录为 0
  modifiedAt: number  // 毫秒时间戳
  createdAt: number   // 毫秒时间戳;取不到时与 modifiedAt 相同
  hidden: boolean     // 以点开头的隐藏项
  exists: boolean     // 这条路径当时是否存在
}

type PluginFileScanResult = {
  ok: boolean
  entries: PluginFileEntry[]
  errors: { path: string, code: string, message?: string }[]
  truncated: boolean
  code?: string
}
  • 传入目录路径就会展开它的下一级子项,不管 recursive 是什么;recursive: true 才会继续往下钻,递归深度上限 8 层。
  • 被扫描的根目录自己不会作为条目出现;includeDirectories 只影响展开出来的子目录。默认只回文件。
  • 路径不存在不抛错:该路径进 errors,code 是 ENOENT,其余路径照常返回。个别路径读不动(例如 EACCES)同样只影响它自己。
  • modifiedAt / createdAt 都是毫秒时间戳;目录的 size 恒为 0,extension 为空串。
  • match 是按条目名匹配的简易 glob:* 不跨路径分隔符,** 跨路径分隔符,? 匹配一个字符。
  • limit 默认 20000,硬上限 50000;撞上上限时 truncated 为 true,条目按遍历顺序返回。
  • 缺失的路径不会进 entries,所以正常返回的条目 exists 基本都是 true;要区分「路径不存在」请看 errors。
const result = await api.file.scan(['D:/notes'], {
  recursive: true,
  match: '*.md',
  limit: 500
})

if (!result.ok) {
  api.toast(`扫描失败:${result.code}`)
} else {
  const files = result.entries.filter((entry) => !entry.isDirectory)
  const missing = result.errors.filter((error) => error.code === 'ENOENT')

  if (result.truncated) {
    api.toast('结果被截断,请缩小范围或指定 match')
  }
}

file.exists

判断这些路径现在是否存在。需要 file:read。exists 数组与输入按下标一一对应,适合先检查「这个名字是不是已经被占了」。返回的同样是信封对象:

api.file.exists(paths: string[]): Promise<{ ok: boolean, exists: boolean[], code?: string }>

未声明 file:read 时不抛错,返回 { ok: false, code: 'NOT_SUPPORTED', exists: [...] },其中 exists 仍是与输入等长的 false 数组,不会给 []。宿主回传的长度对不上时,渲染层退化成全部 false。

const { ok, exists } = await api.file.exists([targetPath])

if (ok && exists[0]) {
  suggested.value = `副本-${suggested.value}`
}

file.reveal

打开系统文件管理器并选中该路径。需要 file:read。路径本身已经不在了就打开它所在的目录;传入相对路径,或者连所在目录都不存在时返回 false。

api.file.reveal(path: string): Promise<boolean>
const revealed = await api.file.reveal(outputPath)

file.grant

把绝对路径登记进本会话的已授权集合——也就是 rename、write 与 read 会接受的集合。需要 file:write。只接受绝对路径,其余路径会以 reason: 'invalid' 出现在 rejected 里。

宿主只自动登记三类路径:从剪贴板读到的文件(api.readClipboardFiles() 读到的,以及 type: 'file' 进入动作带过来的)、用户在 showOpenDialog / showSaveDialog 中选中的路径,以及用户粘贴到启动器搜索框的文件。用户拖进插件自己拖放区的文件不在其中,那类文件必须由插件自己调用 grant。对已经登记过的路径重复 grant 是无害的空操作,可以放心把整批候选路径直接交进来。这个集合只活在本次应用会话里,永不持久化,重启客户端即清空。

api.file.grant(paths: string[]): Promise<{
  ok: boolean
  granted: string[]
  rejected: Array<{ path: string, reason: string }>
}>
type PluginFileGrantResult = {
  ok: boolean
  granted: string[]
  rejected: Array<{ path: string, reason: string }>
}

未声明 file:write 时返回 { ok: false, granted: [], rejected: [...] },rejected 里是逐项的 reason: 'permission-denied'。部分路径被拒不影响其余路径登记成功。

const result = await api.file.grant(paths)

if (!result.ok) {
  api.toast('没有 file:write 权限,路径没能登记')
} else if (result.rejected.length) {
  api.toast(`有 ${result.rejected.length} 个路径无法处理`)
}

file.rename

批量重命名。需要 file:write。这是唯一会改动用户磁盘的插件 API,所以它刻意做得很克制:只会动本会话授权集合里的源路径。

api.file.rename(request: PluginFileRenameRequest): Promise<PluginFileRenameResult>
type PluginFileRenameRequest = {
  items: Array<{ from: string, to: string }>
  dryRun?: boolean                                // 只预检不落盘
  allowOverwrite?: boolean                        // onConflict: 'overwrite' 的别名
  onConflict?: 'error' | 'skip' | 'overwrite'      // 默认 error:目标存在判该项失败
  continueOnError?: boolean                        // 单项失败是否继续,默认 true
}

type PluginFileRenameResultItem = {
  from: string
  to: string
  ok: boolean
  code?: string
  message?: string
}

type PluginFileRenameResult = {
  ok: boolean                                    // 全部成功才 true
  dryRun: boolean
  results: PluginFileRenameResultItem[]          // 与请求顺序一一对应
  succeeded: number
  failed: number
  items: PluginFileRenameItem[]                  // 宿主更细的诊断口径,见下
  applied: Array<{ from: string, to: string }>   // 真正落地的映射
  code?: string                                  // 第一条失败项的错误码
}

type PluginFileRenameItem = {
  from: string
  to: string
  status: 'planned' | 'applied' | 'skipped' | 'failed'   // planned = dryRun 预检通过
  reason?: string
  error?: string
}

allowOverwrite: true 是 onConflict: 'overwrite' 的别名;onConflict 默认 'error'。results 与 items 并存:results 是「与请求一一对应、看 ok 就够」的简洁口径,items 是宿主更细的诊断口径(status + reason)。

推荐流程:先用 dryRun: true 调一次,把 status: 'planned' 会改动的结果、以及 skipped / failed 的条目展示给用户,确认后再真实调用一次。dryRun 时 applied 是空数组。

单项失败不会中断整批(continueOnError 默认 true):每个 item 都有自己的结果,失败项与成功项在同一次调用里并存。ok 只有在全部成功时才为 true;顶层 code 取第一条失败项的错误码。

status 的含义:

status 含义
planned 试运行中:这一步可以执行。
applied 已经真正执行。
skipped 按冲突策略主动跳过。
failed 没能执行,原因见 reason / error。

failed 与 skipped 条目的 reason 取值:

reason 含义
permission-denied 插件没有声明 file:write。
invalid 空路径、相对路径,或者把目录移动到它自己里面。
missing 源路径不存在。
not-granted 源路径不在本会话的已授权集合里。
cross-directory 目标目录既不是源文件自己的目录,也不是已授权的目录。
same-path from 与 to 相同。
target-exists 目标已存在,且冲突策略不允许覆盖。
target-directory 目标是目录,拒绝覆盖。
failed 操作系统拒绝了这次操作,原始信息在 error 里。

results[].code 与顶层 code 取同一套错误码:

code 含义
ENOT_ALLOWED 未授权,或跨目录移动。
ENOENT 源路径不存在。
EEXIST 目标已存在。
EACCES / EPERM 权限不足。
EBUSY 文件被其他进程占用。
EINVAL 名称非法。
ENAMETOOLONG 名称或路径超长。
EROFS 只读文件系统。
ENOSPC 空间不足。
NOT_SUPPORTED 宿主没实现该能力,或插件没声明权限。
INTERNAL 未预期的错误。

一次调用最多 5000 个条目,更长的批次会被直接拒绝并报错。

applied 是真正落地成功的重命名,按执行顺序排列——把它反过来再调一次 rename(from 与 to 对调)就是撤销。

const items = paths.map((path) => ({ from: path, to: `${path}.bak` }))

const preview = await api.file.rename({ items, dryRun: true })
const blocked = preview.items.filter((item) => item.status !== 'planned')

if (blocked.length === 0) {
  const result = await api.file.rename({ items })

  if (!result.ok) {
    // 顶层 code 是第一条失败项的错误码;逐项结果在 result.results 里。
    api.toast(`有 ${result.failed} 项失败:${result.code}`)
  }

  const undo = result.applied.map(({ from, to }) => ({ from: to, to: from }))
  // 需要撤回时:await api.file.rename({ items: undo })
}

file.read

按偏移读取文件内容。需要 file:read-content——不是 file:read。

type PluginFileReadResult = {
  ok: boolean
  data?: ArrayBuffer    // 本次读到的字节;读到一个空块且 eof 为 true 表示已到末尾
  size?: number         // 文件总大小,便于算进度
  eof?: boolean         // 本次是否已读到文件末尾
  code?: 'invalid' | 'not-granted' | 'ENOENT' | 'EISDIR' | 'EACCES' | 'EBUSY' | 'failed'
  message?: string
}

api.file.read(request: {
  path: string      // 目标绝对路径;必须已在本会话的授权范围内
  offset?: number   // 起始字节偏移,默认 0
  length?: number   // 本次最多读多少字节,默认到文件末尾(单次上限 64MB)
}): Promise<PluginFileReadResult>

它解决的是「插件手里只有路径、却拿不到文件字节」这个问题。分块循环是它的主要用法——size 与 eof 就是为此准备的,不必先 scan 一次:

let offset = 0
for (;;) {
  const chunk = await api.file.read({ path, offset, length: 4 * 1024 * 1024 })

  if (!chunk.ok) {
    api.toast(`读取失败:${chunk.message || chunk.code}`)
    break
  }
  if (chunk.data.byteLength) await send(chunk.data)   // 边读边发,内存里只留这一块
  offset += chunk.data.byteLength
  if (chunk.eof) break
}

要点:

  • offset 已到或超过文件末尾时返回 ok: true + 空 data + eof: true,不是错误。循环写法因此不必自己比较 size。
  • 单次上限 64MB(length 超过会被收窄到这个值)。比写入的 512MB 小是有意的:读回来的字节要在渲染进程里再驻留一份。
  • 只受理本会话已授权集合里的路径,与 file.write 同一层护栏。没 grant 过的路径回 not-granted——这条比 scan 严,因为交出去的是文件内容本身。
  • 拖入插件自身拖放区的文件要先 api.getPathForFile(file) 拿路径,再 api.file.grant([path])。
  • 读目录回 EISDIR;文件不存在回 ENOENT;没有系统权限回 EACCES。
const target = await api.showOpenDialog({ title: '选择要发送的文件' })
if (target.length) {
  // 对话框选中的路径由宿主自动登记,可以直接读。
  const head = await api.file.read({ path: target[0], length: 1024 })
  if (head.ok) console.log('文件大小', head.size)
}

file.write

把二进制内容写入磁盘,返回真实落盘路径。需要 file:write。

type PluginFileWriteResult = {
  ok: boolean
  path?: string      // 真实落盘路径,成功时才有;可直接喂给 api.file.reveal
  code?: 'invalid' | 'not-granted' | 'EFBIG' | 'failed'
  message?: string
}

api.file.write(request: {
  path: string                              // 目标绝对路径
  name?: string                             // 文件名,仅作参考
  data: ArrayBuffer | ArrayBufferView       // 二进制内容
  mimeType?: string                         // MIME 类型,仅作参考
  offset?: number                           // 写入起点(字节);不传即「从 0 整份覆盖」
  truncate?: boolean                        // 写完截断到「offset + 本次长度」
}): Promise<PluginFileWriteResult>

它解决的是「保存之后拿不到路径」这个问题:走浏览器下载流程时功能可用,但插件不知道文件落在哪, 「在资源管理器中定位」就用不了。有了它就能拿到真实路径。

护栏与 rename 一致:只受理本会话已登记进授权集合的路径。最自然的用法是先 api.showSaveDialog() 让用户选位置(宿主会自动登记),再写:

const target = await api.showSaveDialog({
  title: '保存录屏文件',
  defaultPath: 'recording.webm',
  filters: [{ name: 'WebM 视频', extensions: ['webm'] }]
})
if (!target) return   // 用户取消

const written = await api.file.write({
  path: target,
  name: 'recording.webm',
  data: await blob.arrayBuffer(),
  mimeType: blob.type
})

if (written.ok) {
  api.toast(`已保存到 ${written.path}`)
  await api.file.reveal(written.path)   // 一键定位
}

续写(offset / truncate)

传 offset 就从该位置落笔,不截断,用于「边收边落盘」的大文件传输——内存恒定,断线时已落盘的部分还在:

// 收到第 N 片就写第 N 片,不必把整份文件攒在内存里。
let received = 0
for await (const piece of incoming) {
  const written = await api.file.write({ path: target, data: piece, offset: received })
  if (!written.ok) throw new Error(written.message)
  received += piece.byteLength
}
// 收尾时按实际长度截断,清掉上一次遗留的多余尾巴(断点续传重传变短的场景)。
await api.file.write({ path: target, data: new ArrayBuffer(0), offset: received, truncate: true })

语义要点:

传参 行为
不传 offset 从 0 整份覆盖,并截断到本次写入的长度(与旧版一致,不留上一次的尾巴)
传 offset: 0 同上:从 0 覆盖并截断
传 offset > 0 从该偏移落笔,不截断;目标不存在时新建(前面的空洞按 0 填充)
传 truncate: true 写完把文件截断到 offset + 本次写入长度

父目录不存在时宿主会自动补建,所以往「已授权目录 + 新的子路径」写不需要先建目录。

失败码:

code 含义
invalid 路径为空 / 不是绝对路径,data 不是二进制,或 offset 是负数
not-granted 路径不在本会话的已授权集合里(插件自己拼的路径会走到这里)
EFBIG 内容超过单次上限(512MB)
failed 操作系统拒绝了写入,原始信息在 message 里