文件系统

# 文件系统

AutumnBox 插件体系提供了两套完全独立的文件系统抽象,分别面向不同的使用场景。理解它们的区别是正确编写插件的基础。

# 1. 两种文件系统

# 设备文件系统 — DeviceFileSystemService

DeviceFileSystemService 操作的是 Android 设备上的文件。它底层通过 ADB 的 sync 协议实现文件推送(push)和拉取(pull),以及目录列举(listDir)。所有操作都需要传入一个 AdbDeviceHandle 来指定目标设备。

典型场景:

  • 向设备推送 APK、配置文件、脚本
  • 从设备拉取日志、截图、数据库
  • 浏览设备目录结构

# 插件本地存储 — PluginContext.fs / usePluginFs

PluginContext.fs 提供的是 插件自己的持久化数据目录。它实现了 IFileSystem 接口,存储位置在宿主应用的数据目录下(浏览器端为 OPFS,桌面端为本地文件系统)。每个插件拥有独立的命名空间,互不干扰。

典型场景:

  • 保存插件配置(用户偏好、上次连接的设备等)
  • 缓存从设备拉取的文件
  • 存储插件运行产生的中间数据

关键区别

DeviceFileSystemService 操作远端设备,需要设备连接;usePluginFs() 操作本地存储,随时可用。两者的 API 完全不同,不要混淆。

# 2. DeviceFileSystemService — 设备文件操作

DeviceFileSystemService 是一个注册在 IoC 容器中的 Service,位于 @autumnbox/core 包。它封装了 IAdbDriver 的底层文件传输能力,提供三个核心方法。

# 获取 DeviceFileSystemService

在 React 组件中,通过 useService hook 获取实例:

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

const fileService = useService(DeviceFileSystemService);

在 Service 中,通过构造函数的 ServiceContainer 获取:

import type { ServiceContainer } from '@autumnbox/sdk/common';
import { DeviceFileSystemService } from '@autumnbox/sdk/services';

export class MyService {
  private readonly fileService: DeviceFileSystemService;

  constructor(container: ServiceContainer) {
    this.fileService = container.getService(DeviceFileSystemService);
  }
}

# push — 推送文件到设备

将本地文件推送到设备上的指定目录。

签名:

async push(
  device: AdbDeviceHandle,
  deviceDir: string,
  source: PushSource,
  signal?: AbortSignal,
): Promise<void>

参数说明:

参数 类型 说明
device AdbDeviceHandle 目标设备
deviceDir string 设备上的目标目录路径
source PushSource 要推送的文件数据
signal AbortSignal 可选,用于取消传输

PushSource 是一个联合类型,支持两种形式:

// 形式 1:本地文件路径(仅桌面端可用)
type PushSource = string;

// 形式 2:内存中的文件数据(浏览器和桌面端均可用)
type PushSource = IFileData;

interface IFileData {
  data: Blob | ArrayBuffer;
  name: string;
  size: number;
}

示例 — 从用户选择的文件推送到设备:

import { DeviceFileSystemService } from '@autumnbox/sdk/services';
import { useService, useRequiredDevice } from '@autumnbox/sdk/hooks';

function UploadButton() {
  const fileService = useService(DeviceFileSystemService);
  const device = useRequiredDevice();

  const handleUpload = async () => {
    // 让用户选择文件
    const input = document.createElement('input');
    input.type = 'file';
    input.onchange = async () => {
      const file = input.files?.[0];
      if (!file) return;

      await fileService.push(device, '/sdcard/Download/', {
        data: file,
        name: file.name,
        size: file.size,
      });
    };
    input.click();
  };

  return <Button onClick={handleUpload}>上传文件到设备</Button>;
}

# pull — 从设备拉取文件

从设备拉取文件到本地。拉取的目标通过 PullDest 联合类型指定。

签名:

async pull(
  device: AdbDeviceHandle,
  devicePath: string,
  dest: PullDest,
  signal?: AbortSignal,
): Promise<void>

PullDest 支持两种形式:

// 形式 1:拉取到本地路径(仅桌面端)
interface IPullToLocal {
  type: 'local';
  path: string;
}

// 形式 2:流式读取(浏览器和桌面端均可用)
interface IPullToReader {
  type: 'reader';
  chunkCallback: (chunk: Uint8Array) => Promise<void>;
}

type PullDest = IPullToLocal | IPullToReader;

示例 — 流式拉取文件并组装成 Blob:

const chunks: Uint8Array[] = [];

await fileService.pull(device, '/sdcard/Download/log.txt', {
  type: 'reader',
  chunkCallback: async (chunk) => {
    chunks.push(chunk);
  },
});

const blob = new Blob(chunks);
const text = await blob.text();
console.log('文件内容:', text);

示例 — 拉取后触发浏览器下载:

async function downloadFromDevice(
  fileService: DeviceFileSystemService,
  device: AdbDeviceHandle,
  remotePath: string,
  fileName: string,
) {
  const chunks: Uint8Array[] = [];

  await fileService.pull(device, remotePath, {
    type: 'reader',
    chunkCallback: async (chunk) => {
      chunks.push(chunk);
    },
  });

  const blob = new Blob(chunks);
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = fileName;
  a.click();
  URL.revokeObjectURL(url);
}

# listDir — 列出设备目录

列出设备上指定目录的内容。返回值自动过滤掉 ... 条目。

签名:

async listDir(
  device: AdbDeviceHandle,
  remotePath: string,
): Promise<readonly IAdbFileEntry[]>

每个 IAdbFileEntry 包含以下字段:

interface IAdbFileEntry {
  path: string;       // 完整路径
  name: string;       // 文件名
  size: number;       // 文件大小(字节)
  mode: number;       // Unix 权限模式
  isDirectory: boolean; // 是否为目录
  modifiedAt?: Date;  // 最后修改时间
}

示例:

const entries = await fileService.listDir(device, '/sdcard/');

for (const entry of entries) {
  if (entry.isDirectory) {
    console.log(`[目录] ${entry.name}`);
  } else {
    console.log(`[文件] ${entry.name} (${entry.size} bytes)`);
  }
}

# AbortSignal — 取消长时间传输

pushpull 都接受可选的 AbortSignal 参数,用于取消正在进行的文件传输。这在处理大文件时尤其重要。

import { useState, useRef } from 'react';

function LargeFileTransfer() {
  const fileService = useService(DeviceFileSystemService);
  const device = useRequiredDevice();
  const [transferring, setTransferring] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  const startTransfer = async (file: File) => {
    const controller = new AbortController();
    abortRef.current = controller;
    setTransferring(true);

    try {
      await fileService.push(
        device,
        '/sdcard/Download/',
        { data: file, name: file.name, size: file.size },
        controller.signal,
      );
      console.log('传输完成');
    } catch (err) {
      if (controller.signal.aborted) {
        console.log('传输已取消');
      } else {
        throw err;
      }
    } finally {
      setTransferring(false);
      abortRef.current = null;
    }
  };

  const cancelTransfer = () => {
    abortRef.current?.abort();
  };

  return (
    <Space>
      <Button
        onClick={() => {/* 触发文件选择并调用 startTransfer */}}
        loading={transferring}
      >
        上传大文件
      </Button>
      {transferring && (
        <Button danger onClick={cancelTransfer}>
          取消
        </Button>
      )}
    </Space>
  );
}

# 3. 插件本地存储

每个插件都拥有独立的数据目录,用于持久化配置、缓存和其他运行时数据。数据目录位于:

{autumnHome}/plugins/data/{pluginPackageName}/

其中 autumnHome 是 AutumnBox 的全局数据根目录,pluginPackageName 来自 package.jsonname 字段(@ 前缀被去除,/ 替换为 -)。

# usePluginFs / usePluginHome

在 React 组件中通过 hook 获取:

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

function MyComponent() {
  const fs = usePluginFs();     // IFileSystem 实例
  const home = usePluginHome(); // 插件数据目录路径字符串

  // fs 的操作路径以 home 为根
  // 例如 fs.resolve(home, 'config.json') 得到完整路径
}

pluginMain 入口函数中,直接从 PluginContext 获取:

export function pluginMain(context: PluginContext) {
  const fs = context.fs;
  const home = context.pluginHome;
}

# IFileSystem 接口

IFileSystem 是插件本地存储的完整接口定义:

interface IFileSystem {
  /** 人类可读的描述(如 "OPFS" 或 "Node.js FS") */
  readonly description: string;

  /** 返回 AutumnBox 全局数据根目录路径 */
  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[]>;

  /** 拼接路径片段,类似 path.join */
  resolve(...segments: string[]): string;

  /** 返回临时目录路径 */
  tmpdir(): string;
}

interface IFileStat {
  size: number;
  mode: number;
  isDirectory: boolean;
  modifiedAt: Date;
}

注意

readFile 返回的是 ReadableStream<Uint8Array>,不是字符串。如果需要文本内容,需要手动解码:

const stream = await fs.readFile(path);
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  chunks.push(value);
}
const text = new TextDecoder().decode(
  new Blob(chunks).arrayBuffer
    ? await new Blob(chunks).arrayBuffer().then((buf) => new Uint8Array(buf))
    : new Uint8Array()
);

更简洁的写法是使用 Response 对象:

const stream = await fs.readFile(path);
const text = await new Response(stream).text();

# 持久化配置示例

下面是一个完整的插件配置管理方案,将设置以 JSON 格式存储在插件数据目录中:

// src/services/ConfigService.ts
import type { ServiceContainer } from '@autumnbox/sdk/common';
import type { IFileSystem } from '@autumnbox/sdk/interfaces';

interface PluginConfig {
  defaultRemotePath: string;
  maxFileSize: number;
  showHiddenFiles: boolean;
  recentPaths: string[];
}

const DEFAULT_CONFIG: PluginConfig = {
  defaultRemotePath: '/sdcard/',
  maxFileSize: 100 * 1024 * 1024, // 100 MB
  showHiddenFiles: false,
  recentPaths: [],
};

export class ConfigService {
  private config: PluginConfig = { ...DEFAULT_CONFIG };
  private configPath = '';
  private fs: IFileSystem | null = null;

  constructor(_container: ServiceContainer) {
    // ServiceContainer 注入,但 fs 和 path 需要从 PluginContext 初始化
  }

  /** 插件初始化时调用,传入 PluginContext 的 fs 和 pluginHome */
  async init(fs: IFileSystem, pluginHome: string): Promise<void> {
    this.fs = fs;
    this.configPath = fs.resolve(pluginHome, 'config.json');
    await this.load();
  }

  get(): PluginConfig {
    return { ...this.config };
  }

  async update(partial: Partial<PluginConfig>): Promise<void> {
    this.config = { ...this.config, ...partial };
    await this.save();
  }

  async addRecentPath(path: string): Promise<void> {
    const paths = this.config.recentPaths.filter((p) => p !== path);
    paths.unshift(path);
    this.config.recentPaths = paths.slice(0, 10);
    await this.save();
  }

  private async load(): Promise<void> {
    if (!this.fs) return;
    try {
      if (await this.fs.exists(this.configPath)) {
        const stream = await this.fs.readFile(this.configPath);
        const text = await new Response(stream).text();
        this.config = { ...DEFAULT_CONFIG, ...JSON.parse(text) as Partial<PluginConfig> };
      }
    } catch {
      // 配置损坏,使用默认值
      this.config = { ...DEFAULT_CONFIG };
    }
  }

  private async save(): Promise<void> {
    if (!this.fs) return;
    const json = JSON.stringify(this.config, null, 2);
    await this.fs.writeFile(this.configPath, new Blob([json]));
  }
}

pluginMain 中初始化:

export function pluginMain(context: PluginContext) {
  const configService = context.getService(ConfigService);
  // 将 PluginContext 的文件系统传给 ConfigService
  configService.init(context.fs, context.pluginHome);
}

在 App 组件中使用:

function SettingsPanel() {
  const configService = useService(ConfigService);
  const [config, setConfig] = useState(configService.get());

  const toggleHidden = async () => {
    await configService.update({ showHiddenFiles: !config.showHiddenFiles });
    setConfig(configService.get());
  };

  return (
    <Switch
      checked={config.showHiddenFiles}
      onChange={toggleHidden}
      checkedChildren="显示隐藏文件"
      unCheckedChildren="隐藏"
    />
  );
}

# 4. 插件资源文件

# resources/ 目录

插件项目根目录下的 resources/ 文件夹中的所有文件都会被打包进 .atmb 压缩包。它们在构建时嵌入,运行时通过 PluginContext 懒加载。

my-plugin/
├── src/
│   └── ...
├── resources/
│   ├── lang/
│   │   ├── zh-CN.json     ← 自动加载的语言文件
│   │   └── en-US.json
│   ├── icon.png            ← 插件自定义图标
│   └── data/
│       └── default-config.json  ← 默认配置模板
└── package.json

resources/lang/ 下的语言文件会在 pluginMain 调用前自动加载到 LanguageService,不需要手动读取。其他资源需要通过下面的 API 显式获取。

# usePluginResource — React Hook

在 React 组件中,usePluginResource 是加载资源的最便捷方式。它返回一个包含 dataloadingerror 三个字段的对象,自动处理异步加载状态。

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

function PluginIcon() {
  const { data, loading, error } = usePluginResource('icon.png');

  if (loading) return <Spin />;
  if (error) return <Alert type="error" message={error.message} />;
  if (!data) return <Alert type="warning" message="资源不存在" />;

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

加载 JSON 资源并解析:

function DefaultConfigLoader() {
  const { data, loading } = usePluginResource('data/default-config.json');
  const [config, setConfig] = useState<Record<string, unknown> | null>(null);

  useEffect(() => {
    if (!data) return;
    data.text().then((text) => {
      setConfig(JSON.parse(text) as Record<string, unknown>);
    });
  }, [data]);

  if (loading) return <Spin />;
  if (!config) return null;

  return <pre>{JSON.stringify(config, null, 2)}</pre>;
}

# context.getResource — 命令式 API

在非 React 上下文中(如 Service 或 pluginMain 函数),使用 context.getResource() 直接获取资源。返回 Promise<Blob | null>,资源不存在时返回 null

export function pluginMain(context: PluginContext) {
  // 加载默认配置模板
  context.getResource('data/default-config.json').then(async (blob) => {
    if (!blob) {
      console.warn('默认配置模板缺失');
      return;
    }
    const text = await blob.text();
    const defaults = JSON.parse(text) as Record<string, unknown>;
    console.log('默认配置:', defaults);
  });
}

getResource 的路径是相对于 resources/ 目录的。传入 'icon.png' 会查找 resources/icon.png,不需要加前缀。如果资源不存在,返回 null 而不是抛出异常。

# 5. 完整示例 — 设备文件管理器

下面是一个功能完整的文件管理器 App,综合运用本文介绍的所有文件系统 API:

  • 浏览设备目录结构
  • 下载设备文件到本地
  • 上传本地文件到设备
  • 支持取消传输
// src/apps/FileManagerApp.tsx
import { useState, useRef, useCallback } from 'react';
import { Button, Table, Breadcrumb, Space, message, Progress, Upload } from 'antd';
import {
  FolderOutlined,
  FileOutlined,
  DownloadOutlined,
  UploadOutlined,
  ArrowLeftOutlined,
  StopOutlined,
} from '@ant-design/icons';
import { defineApp } from '@autumnbox/sdk';

import type { IAdbFileEntry } from '@autumnbox/sdk/interfaces';
import { DeviceFileSystemService } from '@autumnbox/sdk/services';
import { useService, useRequiredDevice } from '@autumnbox/sdk/hooks';

function FileManagerApp() {
  const fileService = useService(DeviceFileSystemService);
  const device = useRequiredDevice();

  const [currentPath, setCurrentPath] = useState('/sdcard/');
  const [entries, setEntries] = useState<readonly IAdbFileEntry[]>([]);
  const [loading, setLoading] = useState(false);
  const [transferring, setTransferring] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  // ── 加载目录 ──

  const loadDir = useCallback(
    async (path: string) => {
      setLoading(true);
      try {
        const result = await fileService.listDir(device, path);
        // 目录排在前面,文件在后面
        const sorted = [...result].sort((a, b) => {
          if (a.isDirectory !== b.isDirectory) {
            return a.isDirectory ? -1 : 1;
          }
          return a.name.localeCompare(b.name);
        });
        setEntries(sorted);
        setCurrentPath(path);
      } catch (err) {
        message.error(`无法打开目录:${String(err)}`);
      } finally {
        setLoading(false);
      }
    },
    [fileService, device],
  );

  // ── 进入子目录 ──

  const enterDir = (entry: IAdbFileEntry) => {
    if (entry.isDirectory) {
      const next = currentPath.endsWith('/')
        ? `${currentPath}${entry.name}/`
        : `${currentPath}/${entry.name}/`;
      loadDir(next);
    }
  };

  // ── 返回上级目录 ──

  const goUp = () => {
    const parts = currentPath.replace(/\/$/, '').split('/');
    if (parts.length > 1) {
      parts.pop();
      const parent = parts.join('/') + '/';
      loadDir(parent || '/');
    }
  };

  // ── 下载文件 ──

  const downloadFile = async (entry: IAdbFileEntry) => {
    const controller = new AbortController();
    abortRef.current = controller;
    setTransferring(true);

    try {
      const chunks: Uint8Array[] = [];
      await fileService.pull(
        device,
        entry.path,
        {
          type: 'reader',
          chunkCallback: async (chunk) => {
            chunks.push(chunk);
          },
        },
        controller.signal,
      );

      // 触发浏览器下载
      const blob = new Blob(chunks);
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = entry.name;
      a.click();
      URL.revokeObjectURL(url);
      message.success(`${entry.name} 下载完成`);
    } catch (err) {
      if (controller.signal.aborted) {
        message.info('下载已取消');
      } else {
        message.error(`下载失败:${String(err)}`);
      }
    } finally {
      setTransferring(false);
      abortRef.current = null;
    }
  };

  // ── 上传文件 ──

  const uploadFile = async (file: File) => {
    const controller = new AbortController();
    abortRef.current = controller;
    setTransferring(true);

    try {
      await fileService.push(
        device,
        currentPath,
        { data: file, name: file.name, size: file.size },
        controller.signal,
      );
      message.success(`${file.name} 上传完成`);
      // 刷新当前目录
      await loadDir(currentPath);
    } catch (err) {
      if (controller.signal.aborted) {
        message.info('上传已取消');
      } else {
        message.error(`上传失败:${String(err)}`);
      }
    } finally {
      setTransferring(false);
      abortRef.current = null;
    }
  };

  // ── 取消传输 ──

  const cancelTransfer = () => {
    abortRef.current?.abort();
  };

  // ── 面包屑导航 ──

  const pathParts = currentPath.split('/').filter(Boolean);
  const breadcrumbItems = [
    { title: <a onClick={() => loadDir('/')}>/</a>, key: 'root' },
    ...pathParts.map((part, i) => {
      const fullPath = '/' + pathParts.slice(0, i + 1).join('/') + '/';
      return {
        title: <a onClick={() => loadDir(fullPath)}>{part}</a>,
        key: fullPath,
      };
    }),
  ];

  // ── 文件大小格式化 ──

  const formatSize = (bytes: number): string => {
    if (bytes < 1024) return `${String(bytes)} B`;
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
    if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
    return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
  };

  // ── 表格列定义 ──

  const columns = [
    {
      title: '名称',
      dataIndex: 'name',
      key: 'name',
      render: (name: string, record: IAdbFileEntry) => (
        <Space>
          {record.isDirectory ? <FolderOutlined /> : <FileOutlined />}
          {record.isDirectory ? (
            <a onClick={() => enterDir(record)}>{name}</a>
          ) : (
            <span>{name}</span>
          )}
        </Space>
      ),
    },
    {
      title: '大小',
      dataIndex: 'size',
      key: 'size',
      width: 120,
      render: (size: number, record: IAdbFileEntry) =>
        record.isDirectory ? '-' : formatSize(size),
    },
    {
      title: '修改时间',
      dataIndex: 'modifiedAt',
      key: 'modifiedAt',
      width: 200,
      render: (date?: Date) => (date ? date.toLocaleString() : '-'),
    },
    {
      title: '操作',
      key: 'actions',
      width: 100,
      render: (_: unknown, record: IAdbFileEntry) =>
        record.isDirectory ? null : (
          <Button
            size="small"
            icon={<DownloadOutlined />}
            onClick={() => downloadFile(record)}
            disabled={transferring}
          >
            下载
          </Button>
        ),
    },
  ];

  return (
    <div style={{ padding: 16 }}>
      {/* 工具栏 */}
      <Space style={{ marginBottom: 16 }}>
        <Button icon={<ArrowLeftOutlined />} onClick={goUp}>
          上级目录
        </Button>
        <Button onClick={() => loadDir(currentPath)} loading={loading}>
          刷新
        </Button>
        <Upload
          showUploadList={false}
          beforeUpload={(file) => {
            uploadFile(file);
            return false; // 阻止 antd 默认上传行为
          }}
        >
          <Button icon={<UploadOutlined />} disabled={transferring}>
            上传文件
          </Button>
        </Upload>
        {transferring && (
          <Button danger icon={<StopOutlined />} onClick={cancelTransfer}>
            取消传输
          </Button>
        )}
      </Space>

      {/* 面包屑路径 */}
      <Breadcrumb items={breadcrumbItems} style={{ marginBottom: 12 }} />

      {/* 文件列表 */}
      <Table
        dataSource={entries as IAdbFileEntry[]}
        columns={columns}
        rowKey="path"
        loading={loading}
        pagination={{ pageSize: 50 }}
        size="small"
        onRow={(record) => ({
          onDoubleClick: () => enterDir(record),
        })}
      />
    </div>
  );
}

// ── App 定义 ──

export const FileManagerAppDef = defineApp({
  id: 'file-manager',
  name: 'app.fileManager',
  icon: 'data:image/svg+xml,...', // 替换为实际图标
  shallSelectAdbDevice: true,
  component: FileManagerApp,
  tags: ['tools', 'device'],
});

这个示例使用了 shallSelectAdbDevice: true,这意味着打开 App 时系统会先提示用户选择设备。App 内通过 useRequiredDevice() 获取的设备对象保证非空。

# 6. 最佳实践

  1. 区分两套文件系统。设备文件操作用 DeviceFileSystemService,插件本地数据用 usePluginFs()
  2. 始终传入 AbortSignal。文件传输可能很慢,用户应有能力取消操作。
  3. readFile 返回流IFileSystem.readFile() 返回 ReadableStream,用 new Response(stream).text() 快速获取文本内容。
  4. writeFile 接受 Blob。写入文本时用 new Blob([jsonString]) 包装。
  5. 资源路径是相对的getResource('icon.png') 对应 resources/icon.png,不要写绝对路径。
  6. 配置使用 JSON。插件配置推荐用 JSON 格式存储在 pluginHome 下,便于读写和调试。
  7. 处理 null 返回值getResource() 在资源不存在时返回 null,务必做空值检查。
最后更新: 4/8/2026, 2:35:44 AM