Skip to content

文件能力与沙箱 ​

@heybox/hb-sdk 的文件能力由 hbSDK.files 提供,分两类目标:

  • 沙箱(sandbox):每个小程序独享、由宿主提供的隔离文件空间,小程序可自由读写,不需要用户逐个授权;
  • 外部文件:用户通过系统选择器显式授权给当前调试/运行会话的文件或目录,权限范围最小化并且是会话级的。

两类目标都通过同一组 FileHandle / DirectoryHandle 代理访问;区别在于授权来源和允许的操作(例如删除只对沙箱对象开放)。

适用平台与客户端基线 ​

Hostfiles.sandboxpickFiles() / pickDirectory()files.saveFile()network.download()
新 PC(Windows/macOS)支持支持支持支持
Android支持支持已适配客户端支持支持
iOS支持支持支持支持
OHOS合同已声明合同已声明合同已声明合同已声明
Web不支持不支持不支持不支持
Browser Dev Host不支持不支持不支持不支持

客户端基线(来自公开 Reference 的平台门槛):

  • Android / iOS:1.3.396+;
  • OHOS:平台基线 1.3.390;
  • Windows / macOS:平台基线 1.0.0;
  • files.saveFile() 与 Android 精确文件写入需要客户端适配,不能仅凭 Files/Download 的版本基线判断。

具体可用性由宿主在启动时通过 system.describe 宣告的 operation 集逐项协商,Runtime 与页面代码不按操作系统分支判断能力。缺失 handler、协商超时或 operation 不完整时,对应能力保持关闭,并返回 METHOD_FORBIDDEN;旧 Runtime 不认识新 method 时返回 METHOD_NOT_FOUND。所有宿主都不提供 throwing stub、Blob 或内存假实现。

权限与用户授权 ​

  • 沙箱读写需要在 package.json#heybox.permissions 中声明 filesystem;声明后即可使用沙箱能力。
  • 外部文件必须由用户通过系统选择器授权,声明 filesystem 不代表可以静默访问任意路径。
  • network.download() 同时需要 network 与 filesystem:前者允许访问小程序指定的外部地址,后者允许写入受控目标;真正开始 I/O 前两者都必须有效。
  • 外部授权只在当前 Runtime session 内有效,会话结束即失效。

完整权限目录与声明方式见权限声明。

沙箱系统 ​

hbSDK.files.sandbox 是小程序私有沙箱的逻辑根,属于 PathRoot:它不返回真实路径,只在读取代理时才映射到宿主的受控存储。

  • 隔离:沙箱按小程序隔离,不同小程序、不同版本之间不共享文件;小程序拿不到宿主的真实文件系统路径。
  • 逻辑引用 ≠ 直接句柄:sandbox.file() / sandbox.directory() 派生的是逻辑引用,目录枚举(list())结果也是逻辑引用,它们不持续占用配额;picker 与 saveFile() 返回的是外部 direct handle,会计入配额。
  • 会话级:外部授权与句柄都绑定当前 Runtime session,会话结束后需要重新授权。
  • 不暴露实现:宿主文件错误只向小程序暴露稳定的错误码与规范安全文案,原始 message、data、native path 和凭据不会进入小程序。

路径规则 ​

file() 与 directory() 只接受以 / 分隔的规范相对路径,并拒绝:

  • 空字符串、绝对路径;
  • 空 segment(连续 /);
  • . 与 ..;
  • 反斜杠;
  • 尾分隔符;
  • NUL 与控制字符。

路径始终相对于沙箱根解析;需要写到子目录时,父目录必须先存在(见下)。

常用操作 ​

在沙箱里读写 ​

ts
import hbSDK from '@heybox/hb-sdk'

// 先确保父目录存在(recursive 默认为 false)
await hbSDK.files.sandbox.directory('reports').create({ recursive: true })

const file = hbSDK.files.sandbox.file('reports/latest.json')

// writeText()/writeBytes() 可以在父目录存在时隐式创建目标文件,
// 但不会自动创建父目录
await file.writeText(JSON.stringify({ ok: true }))

console.log(await file.exists()) // true
console.log(await file.readText())
const stat = await file.stat() // size 为字节数

FileHandle 与 DirectoryHandle 的创建与删除语义:

  • create() 的 recursive、exclusive 默认都是 false:默认是"确保存在",不截断已有内容;exclusive: true 在目标已存在时失败。
  • exists() 在祖先或目标缺失、或 kind 不匹配时返回 false;授权撤销、配额与 I/O 错误仍会抛出。
  • stat() 返回 FileStat:size 为字节数,modifiedAt / createdAt 为 Unix epoch 毫秒。
  • file.remove() / directory.remove() 只删除沙箱对象。
  • 非空目录默认不能删除并抛出 DIRECTORY_NOT_EMPTY;确认要删除整棵目录树时显式调用 directory.remove({ recursive: true })。
  • directory.list() 默认最多返回 1000 项,显式 limit 也不能超过 1000;超限抛出 DIRECTORY_LIST_LIMIT_EXCEEDED。

让用户选择外部文件或目录 ​

pickFiles()、pickDirectory()、saveFile() 必须直接从按钮点击等可信用户操作中调用;picker 被取消会抛出 FILE_PICKER_CANCELLED,不会返回空数组或 undefined。选择器等待不设固定超时,不受 SDK 普通请求超时配置影响;用户确认、取消或 SDK / Runtime 会话结束后才结束操作。应提前根据握手状态启用按钮,点击后立即调用,不要先等待其他异步任务。

ts
async function readUserFile() {
  // 默认单文件、只读;需要写入时显式请求 readwrite
  const [picked] = await hbSDK.files.pickFiles({ accept: ['.json'] })
  if (!picked) return
  return picked.readText()
}

async function writeIntoUserDirectory() {
  const dir = await hbSDK.files.pickDirectory({ mode: 'readwrite' })
  const target = dir.file('output.txt')
  await target.writeText('hello')
}
  • pickFiles() / pickDirectory() 默认返回 mode: 'read' 的授权;只有显式传入 multiple: true 时,pickFiles() 才允许宿主返回多个文件(实际返回数量由宿主 picker 决定)。
  • 需要修改文件、在所选目录中创建文件,或把外部授权用作下载目标时,必须在 picker options 中显式请求 mode: 'readwrite'。
  • 外部授权调用 remove() 会抛出 FILE_ACCESS_DENIED。

保存到用户指定位置 ​

ts
async function saveReport(text: string) {
  // saveFile() 只接受 suggestedName,不接受 accept
  const file = await hbSDK.files.saveFile({ suggestedName: 'report.json' })
  await file.writeText(text)
}

saveFile() 请求宿主返回一个精确 File 授权,不会同时创建或保留 Directory 授权。新 PC 当前先让用户选择保存目录,再以 suggestedName 独占创建空文件;同名文件抛出 FILE_ALREADY_EXISTS,不会静默覆盖。扩展名过滤只属于 pickFiles({ accept: [...] })。

Android 已适配客户端支持精确 SAF 文件的 readwrite 选择与 saveFile(),以系统实际授予的写权限为准。writeText() / writeBytes() 可写入,但 Provider 写入失败时文件可能为空或部分写入,不保证保留原内容;这类精确 SAF 文件不支持 network.download() 的原子替换,导出请使用文件代理的写入方法。

下载到沙箱或外部目录 ​

ts
const exportDirectory = hbSDK.files.sandbox.directory('exports')
await exportDirectory.create({ recursive: true })

const file = await hbSDK.network.download({
  url: 'https://cdn.example.com/report.json',
  to: exportDirectory,
  suggestedName: 'report.json',
  overwrite: true,
  maxBytes: 8 * 1024 * 1024,
  onProgress({ loaded, total, lengthComputable }) {
    console.log(lengthComputable ? `${loaded}/${total}` : loaded)
  },
})

network.download() 的下载边界:

  • 固定使用 GET,不接受 method、body、上传或 Range 字段。
  • File 目标必须已存在且为 readwrite,成功后返回同一个 File 代理;Directory 目标必须已存在且为 readwrite,成功后返回新的子 File 代理。
  • Directory 目标默认不覆盖同名文件,只有显式 overwrite: true 才允许原子替换。
  • Directory 目标的子文件名按安全的 Content-Disposition filename* / filename、suggestedName、URL basename、download.bin 依次选择。
  • timeout 是网络空闲超时,不是整个下载的总时长;maxBytes 限制单次下载字节数。取消或失败不会改动原目标。

限制与配额 ​

  • 单个 Runtime session 最多保留 1024 个 picker / saveFile() 返回的 direct handle;整批授权超过剩余额度时抛出 FILE_QUOTA_EXCEEDED,不会保留部分结果。沙箱派生项与目录枚举结果使用逻辑引用,不持续占用该额度。
  • directory.list() 单次最多 1000 项,见上。
  • 非空目录需要显式 recursive: true 才能删除。

错误码 ​

错误对象与通用处理方式见错误处理;文件能力相关的错误码如下:

错误码触发条件
METHOD_FORBIDDEN当前宿主未实现该 operation(含 Web 与 Browser Dev Host 对文件能力的降级)
METHOD_NOT_FOUND旧 Runtime 收到它不认识的新 method
FILE_ACCESS_DENIED对外部授权调用 remove();或对只读目标请求 readwrite
DIRECTORY_NOT_EMPTY删除非空目录但未显式传 recursive: true
DIRECTORY_LIST_LIMIT_EXCEEDEDdirectory.list() 结果超过 1000 项上限
FILE_QUOTA_EXCEEDED外部 direct handle 超过 1024 个配额
FILE_PICKER_CANCELLED用户取消了系统选择器
FILE_ALREADY_EXISTSsaveFile() 的目标同名文件已存在(不会静默覆盖)

降级与安全边界 ​

  • Web 与 Browser Dev Host 不提供文件能力:调用时返回 METHOD_FORBIDDEN,也不会用内存或 Blob 伪造实现。
  • Android 较早客户端可能拒绝精确 SAF 文件的 readwrite 或不提供 saveFile();以宿主宣告的 operation 集和系统授权为准,处理 METHOD_FORBIDDEN 与 FILE_ACCESS_DENIED。
  • 鸿蒙手机和平板的目录选择还受系统能力限制,详见 pickDirectory();不支持时可改用文件选择或保存,不会取得父目录权限。
  • 小程序永远拿不到真实文件系统路径;外部访问范围由用户选择与当前会话的授权句柄限定。

上传受控文件 ​

network.upload() 同时需要 network 与 filesystem,源 File 只需读权限。PC、Android、iOS、OHOS 在 Host 实现并宣告真实上传 primitive 后可用;旧 Host、Web 和 Browser Dev Host 不提供替代实现。移动端 Files/Download 的 1.3.396+ 基线不能证明上传已经可用,上传须单独按 capability 协商。OHOS 使用 API 12 流式网络接口,目前沿用 Host 的 HTTPS 限制。

文件来自 sandbox、pickFiles() 或已授权目录;不接受浏览器 File、Blob、本机路径或 JS 字节数组。读取与上传留在 Host,完整内容读写的 8 MiB 上限不限制本能力;调用方可用正整数 maxBytes 限制文件内容大小,Host 也可施加自身限制。

ts
// file 是已经存在的 FileHandle;signedPutUrl 由开发者后端提供。
const controller = new AbortController()
const response = await hbSDK.network.upload({
  url: signedPutUrl,
  from: file,
  format: 'raw', // 默认 PUT;也可显式传 method: 'POST'
  headers: { 'Content-Type': 'application/octet-stream' },
  maxBytes: 100 * 1024 * 1024,
  signal: controller.signal,
  onProgress({ loaded, total, lengthComputable }) {
    console.log(loaded, lengthComputable ? total : '未知总大小')
  },
})
console.log(response.status, response.data)
ts
const response = await hbSDK.network.upload<{ id: string }>({
  url: developerUploadUrl,
  from: file,
  format: 'multipart', // 固定 POST,单个文件
  fieldName: 'file',
  fields: { category: 'save' }, // 只接受字符串,不支持重复字段名
  fileName: 'save.bin', // 可选,默认使用 file.name
  contentType: 'application/octet-stream', // 可选,文件 part 的 MIME type
})
console.log(response.data.id)

multipart 的文件字段不能与 fields 重名;整体 Content-Type 和 boundary 由 Host 生成,调用方不能手写。字段、文件名和 MIME type 等元数据合计不得超过 2 MiB(按 JSON 的 UTF-8 字节数计算,不含文件内容)。raw 不接受 multipart 专属字段。params、headers 和 validateStatus 延续 request 的约定,默认只接受 2xx;非 2xx 的响应可从 HbMiniProgramNetworkError.response 读取。响应数据的泛型不执行 schema 校验。

上传地址在生产环境必须使用 HTTPS。HTTP 仅限受支持的本地调试会话中显式允许的数字回环或局域网 origin,不能用于生产上传。

format 必填;multipart 的 contentType 默认 application/octet-stream。timeout 必须是有限正数(毫秒),省略时采用 Host 策略;maxBytes 必须是正的安全整数,省略不代表 Host 没有容量限制。

进度只统计交给传输层的文件内容字节;空文件的 total 可为 0,计算百分比前应确认 total 大于 0。100% 后仍须等待 HTTP 响应。上传没有 SDK 普通请求的固定总时长限制,timeout 是网络空闲超时;上传绑定当前 Runtime 会话,关闭或刷新不会转成后台任务。

上传期间调用方应保持源文件不变。Host 不复制快照,只在能够检测到大小或文件状态变化时失败。首版不自动重试、不分片、不续传,也不访问官方域名或跟随重定向。取消、超时及网络失败无法保证服务端没有保存数据;业务去重与幂等由服务端协议负责。

上传错误使用标准 SDK 错误对象:UPLOAD_CANCELLED 表示取消,UPLOAD_TIMEOUT 表示空闲超时,UPLOAD_NETWORK_FAILED 表示传输失败,FILE_CHANGED 表示检测到源文件变化,FILE_TOO_LARGE 表示超过大小限制。处理 HTTP 状态错误与这些传输错误时,应区分重试是否可能造成重复写入。