Appearance
文件能力与沙箱
@heybox/hb-sdk 的文件能力由 hbSDK.files 提供,分两类目标:
- 沙箱(sandbox):每个小程序独享、由宿主提供的隔离文件空间,小程序可自由读写,不需要用户逐个授权;
- 外部文件:用户通过系统选择器显式授权给当前调试/运行会话的文件或目录,权限范围最小化并且是会话级的。
两类目标都通过同一组 FileHandle / DirectoryHandle 代理访问;区别在于授权来源和允许的操作(例如删除只对沙箱对象开放)。
适用平台与客户端基线
| Host | files.sandbox | pickFiles() / 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_EXCEEDED | directory.list() 结果超过 1000 项上限 |
FILE_QUOTA_EXCEEDED | 外部 direct handle 超过 1024 个配额 |
FILE_PICKER_CANCELLED | 用户取消了系统选择器 |
FILE_ALREADY_EXISTS | saveFile() 的目标同名文件已存在(不会静默覆盖) |
降级与安全边界
- 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 状态错误与这些传输错误时,应区分重试是否可能造成重复写入。