资源系统

# 资源系统

插件有两种文件访问机制:打包资源(只读,来自 .atmb 包内)和插件文件系统(可读写,宿主本地存储)。本文完整讲解资源的放置、语法糖、加载机制和使用方式。

# 概览

机制 来源 读写 用途 获取方式
打包资源 .atmb 内的 resources/ 只读 图标、语言文件、模板、静态资源 context.getResource() / usePluginResource() / PluginService
插件文件系统 {autumnHome}/plugins/data/{pluginPackageName}/ 读写 用户配置、缓存、持久化状态 context.fs / usePluginFs()

简单记忆:打包资源是随身行李,文件系统是长期住所。


# 资源放置

将需要随插件分发的文件放在项目根目录的 resources/ 下:

my-plugin/
├── resources/
│   ├── icon.png                ← 插件图标(128×128 PNG,透明背景)
│   ├── lang/                   ← 国际化语言文件(自动加载,有语法糖)
│   │   ├── zh-CN.json
│   │   └── en-US.json
│   ├── templates/              ← 自定义资源(模板、配置等)
│   │   └── report.html
│   └── bin/                    ← 嵌入的二进制文件
│       └── helper.sh
├── src/
└── package.json

构建时 autumnbox-sdk package 会将整个 resources/ 目录打包进 .atmb ZIP。路径结构原样保留。

放置规则:

  • resources/icon.png — 插件图标,用于插件列表、设置页、插件商店
  • resources/lang/*.json特殊目录,宿主自动扫描注册为翻译内容
  • 其他子目录随意组织,运行时通过路径访问

# 语言文件与语法糖

resources/lang/ 是唯一有特殊处理的目录。

# 基本格式

文件名即 locale 代码(BCP 47),内容为 key-value JSON:

// resources/lang/zh-CN.json
{
  "app.name.my-tool": "我的工具",
  "card.name.status": "状态",
  "settings.theme": "主题设置"
}

# <name> / <description> / <author> 语法糖

语言文件中有三个特殊占位 key。autumnbox-sdk package 打包时自动替换为带命名空间的 key:

// 开发者在 resources/lang/zh-CN.json 中写的
{
  "<name>": "我的工具箱",
  "<description>": "一个强大的 Android 设备工具集",
  "<author>": "张三",
  "app.name.toolkit": "工具箱"
}

打包后,.atmb 内的实际 JSON 变为:

// .atmb 包内 resources/lang/zh-CN.json 的实际内容
{
  "plugin.name.@myplugins/toolkit": "我的工具箱",
  "plugin.description.@myplugins/toolkit": "一个强大的 Android 设备工具集",
  "plugin.author.@myplugins/toolkit": "张三",
  "app.name.toolkit": "工具箱"
}
开发者写的 打包后变成 用途
"<name>" "plugin.name.{pluginPackageName}" 插件显示名称
"<description>" "plugin.description.{pluginPackageName}" 插件描述
"<author>" "plugin.author.{pluginPackageName}" 插件作者

为什么需要 namespace?

多个插件的翻译内容共享同一个 LanguageService。如果所有插件都直接用 "name" 作 key 会互相覆盖。语法糖在编译时自动加前缀,开发者写起来简洁,运行时又不冲突。

只有 <name><description><author> 这三个 key 会被替换,其他 key 原样保留。<name> 至少在一个语言文件中必须存在,否则构建会报错。

# 自动加载流程

宿主加载 .atmb 时自动处理语言文件,不需要手动注册:

加载 .atmb
  → 扫描 resources/lang/*.json
  → 解析文件名得到 locale(zh-CN.json → zh-CN)
  → 读取 JSON 内容
  → 注册到 LanguageService.load(pluginPackageName, locale, content)
  • 文件名格式:{locale}.json(BCP 47 格式,如 zh-CNen-USja-JP
  • 不支持嵌套子目录(lang/sub/xx.json 会被忽略)
  • 语言文件在 pluginMain 调用之前加载完毕,入口函数中即可使用 context.t()

详见 国际化


# 使用打包资源

宿主加载 .atmb 时,将 resources/ 下所有文件解压为 Map<path, Blob>。以下是三种访问方式:

# 方式一:context.getResource()

main.ts、Service 或任何非 React 环境中使用:

import type { PluginContext } from '@autumnbox/sdk';

export function main(context: PluginContext): void {
  // 加载单个资源,返回 Blob 或 null
  const template = await context.getResource('templates/report.html');
  if (template) {
    const html = await template.text();
    console.log('模板内容:', html);
  }

  // 加载图片
  const icon = await context.getResource('icon.png');
  if (icon) {
    const url = URL.createObjectURL(icon);
    // 使用 url...
  }
}

路径规则:参数是相对于 resources/ 的路径,不含 resources/ 前缀。例如 resources/lang/zh-CN.json 对应 lang/zh-CN.json

# 方式二:usePluginResource Hook

在 React 组件中使用,提供响应式的加载状态:

import { usePluginResource } from '@autumnbox/sdk/hooks';

const IconDisplay: React.FC = () => {
  const { data, loading, error } = usePluginResource('icon.png');

  if (loading) return <Spin size="small" />;
  if (error) return <Alert type="error" message={error.message} />;
  if (!data) return <Empty description="图标不存在" />;

  const url = URL.createObjectURL(data);
  return <img src={url} alt="图标" style={{ width: 64, height: 64 }} />;
};

返回值:

字段 类型 说明
data Blob \| null 资源内容,不存在时为 null
loading boolean 是否正在加载
error Error \| null 加载错误

usePluginResource 内部管理加载状态和清理。组件卸载时自动取消未完成的加载。

# 方式三:PluginService 资源方法

通过 IoC 容器获取 PluginService,它提供了更底层的资源访问 API。适合跨插件访问资源或需要批量操作的场景:

import { useService } from '@autumnbox/sdk/hooks';
import { PluginService } from '@autumnbox/sdk/hooks';

const MyComponent: React.FC = () => {
  const pluginService = useService(PluginService);

  // 获取原始 Blob
  const blob = pluginService.getPluginResource('autumnbox.my-plugin', 'icon.png');

  // 获取文本内容
  const html = await pluginService.getPluginResourceAsText(
    'autumnbox.my-plugin',
    'templates/report.html'
  );

  // 获取 Data URI(自动推断 MIME 类型,适合 <img src>)
  const dataUri = await pluginService.getPluginResourceAsSource(
    'autumnbox.my-plugin',
    'images/logo.svg'
  );
  // → "data:image/svg+xml;base64,..."

  // 列出目录下的资源
  const entries = pluginService.listResource('autumnbox.my-plugin', 'templates');
  // → ['report.html', 'email.html']
};

PluginService 资源方法一览:

方法 返回值 说明
getPluginResource(pkg, path) Blob \| null 获取原始 Blob(同步)
getPluginResourceAsText(pkg, path) Promise<string \| null> 获取文本内容
getPluginResourceAsSource(pkg, path) Promise<string \| null> 获取 Data URI(自动推断 MIME)
listResource(pkg, path) string[] 列出目录下的直接子项(同步)

getPluginResourceAsSource 的 MIME 推断

JSZip 解压出的 Blob 默认没有 MIME 类型。getPluginResourceAsSource 会根据文件扩展名自动修正:.svgimage/svg+xml.pngimage/png.webpimage/webp 等。这对 SVG 尤为重要——浏览器拒绝渲染没有正确 MIME 的 SVG data URI。

# 三种方式的选择

场景 推荐方式 原因
React 组件内加载当前插件资源 usePluginResource() 自动管理加载状态
main.ts / Service 内加载资源 context.getResource() 最简洁
需要 Data URI(img src) PluginService.getPluginResourceAsSource() 自动处理 MIME
跨插件读取其他插件的资源 PluginService.getPluginResource() 需要指定 pluginPackageName
列出资源目录内容 PluginService.listResource() 唯一支持目录列表的 API

# 资源的限制

  • 只读:打包资源来自 ZIP 包,不能修改。需要持久化的数据应使用插件文件系统
  • Blob 格式:所有资源以 Blob 形式返回。需要文本用 blob.text(),需要 ArrayBuffer 用 blob.arrayBuffer()
  • 全量驻留内存.atmb 解压后所有资源 Blob 保持在内存中直到插件卸载。大文件(>1MB)会增加内存占用

# 插件文件系统

每个插件拥有独立的可读写数据目录。

# 数据目录路径

{autumnHome}/plugins/data/{pluginPackageName}/
  • autumnHome 由宿主提供(Web 端通常是 OPFS,桌面端是用户数据目录)
  • 目录在插件首次加载时自动创建

# IFileSystem 接口

interface IFileSystem {
  autumnHome(): string;
  readFile(path: string): Promise<ReadableStream<Uint8Array>>;
  writeFile(path: string, data: Blob): Promise<void>;
  stat(path: string): Promise<IFileStat>;
  remove(path: string): Promise<void>;
  exists(path: string): Promise<boolean>;
  mkdir(path: string): Promise<void>;
  readdir(path: string): Promise<string[]>;
  resolve(...segments: string[]): string;
  tmpdir(): string;
}

# context.fs(非 React)

import type { PluginContext } from '@autumnbox/sdk';

export function main(context: PluginContext): void {
  const { fs, pluginHome } = context;

  // 写入配置
  const configPath = fs.resolve(pluginHome, 'config.json');
  await fs.writeFile(configPath, new Blob([JSON.stringify({ theme: 'dark' })]));

  // 读取配置
  if (await fs.exists(configPath)) {
    const stream = await fs.readFile(configPath);
    const text = await new Response(stream).text();
    const config = JSON.parse(text);
  }
}

# usePluginFs Hook(React)

import { usePluginFs } from '@autumnbox/sdk/hooks';

const ConfigPanel: React.FC = () => {
  const fs = usePluginFs();

  const saveConfig = async (config: object) => {
    await fs.writeFile('config.json', new Blob([JSON.stringify(config)]));
  };
};

# LocalFileSystemService(便捷方法)

通过 IoC 容器获取,提供 JSON/文本/Blob 的高级读写封装:

import { useService } from '@autumnbox/sdk/hooks';
import { LocalFileSystemService } from '@autumnbox/sdk/services';

const MyComponent: React.FC = () => {
  const localFs = useService(LocalFileSystemService);

  // JSON 读写
  const config = await localFs.readJSONOrNull<{ theme: string }>('/path/to/config.json');
  await localFs.writeJSON('/path/to/config.json', { theme: 'dark' }, 2);

  // 文本读取
  const text = await localFs.readText('/path/to/notes.txt');

  // Blob 读取
  const blob = await localFs.readBlob('/path/to/image.png');
};

LocalFileSystemService 操作宿主的完整文件系统,不限定在插件目录内。


# 打包资源 vs 文件系统 选择指南

场景 使用 原因
插件图标 getResource('icon.png') 图标打包在 .atmb 中
语言文件 自动(resources/lang/ 宿主自动处理
HTML 模板 getResource('templates/*.html') 模板随插件分发
用户设置 context.fs.writeFile() 需要持久化的可变数据
缓存数据 context.fs.writeFile() 可变数据
嵌入二进制 getResource('bin/tool') 二进制随插件分发
导出报告 context.fs.writeFile() 运行时生成的数据
宿主全局配置 LocalFileSystemService 需要访问宿主文件系统

# 完整示例:带缓存的资源加载

结合打包资源和文件系统,实现"首次从包内加载,后续从缓存读取":

import { usePluginResource, usePluginFs } from '@autumnbox/sdk/hooks';

const DataLoader: React.FC = () => {
  const { data: bundledData, loading } = usePluginResource('data/default-config.json');
  const fs = usePluginFs();

  const loadConfig = async () => {
    // 先尝试从缓存读取
    if (await fs.exists('cache/config.json')) {
      const stream = await fs.readFile('cache/config.json');
      return await new Response(stream).json();
    }

    // 缓存不存在,从打包资源读取并缓存
    if (!bundledData) return null;
    const config = await bundledData.json();

    await fs.mkdir('cache');
    await fs.writeFile('cache/config.json', new Blob([JSON.stringify(config)]));

    return config;
  };

  if (loading) return <Spin />;

  return <Button onClick={() => loadConfig().then(console.log)}>加载配置</Button>;
};

# 下一步