Metadata-Version: 2.5
Name: sqlite-vfs
Version: 0.4.0
Summary: sqlite virtual file system
License-File: LICENSE
Requires-Python: >=3.9
Provides-Extra: client
Requires-Dist: pyside6>=6.6; extra == 'client'
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
Requires-Dist: python-multipart>=0.0.6; extra == 'fastapi'
Requires-Dist: starlette>=0.27; extra == 'fastapi'
Requires-Dist: uvicorn>=0.23; extra == 'fastapi'
Provides-Extra: gui
Requires-Dist: pyside6>=6.6; extra == 'gui'
Provides-Extra: mqtt
Requires-Dist: paho-mqtt>=2.0; extra == 'mqtt'
Provides-Extra: studio
Requires-Dist: pyside6-essentials>=6.6; extra == 'studio'
Description-Content-Type: text/markdown

# SQLite Virtual File System (SVFS)

一个基于 SQLite 的虚拟文件系统实现，可以将本地文件夹打包成单个 SQLite 数据库文件，并支持从数据库中解包还原文件。

## 特性
- 高效存储: 使用 SQLite 数据库存储文件和目录结构
- 压缩支持: 可选压缩，默认 zlib（标准库 C 实现），压不动的内容自动回退为原样存储
- 内容寻址去重: 相同内容只存一份，多个路径/多个版本共享同一份数据
- 快照与版本历史: 一个库里存同一棵树的历史版本，可以按版本导出、按版本校验
- 完整性校验: 每个文件带 sha256，快照级摘要 + HMAC-SHA256 签名，支持只读校验
- 增量打包: 只写入变化的条目，未变的文件不重写、不重压、不重复存储
- 可发布到对象存储: 导出成「按 sha256 命名的对象 + 一份小清单」，普通静态托管 / CDN 即可分发，
  客户端按清单取回并逐个校验（不需要任何编译扩展）
- 插件化客户端: `svfs-client`（PySide6 + WebView）把 .svfs 当插件分发——完整前端直接
  从包内加载进 WebView，多插件互相隔离，升级即新快照、可随时回滚
- 完整元数据: 保留文件权限、创建时间、修改时间等元数据
- 目录结构保持: 完整的目录层级结构保持
- 简单易用: 四个命令行工具（打包 / 解包 / 校验 / 迁移），零第三方依赖
- 跨平台: 支持 Windows、Linux、macOS
- 可选集成: FastAPI 静态文件服务作为 extras 提供，核心不依赖任何第三方框架

## 安装
### 从源码安装
```bash
# 克隆仓库
git clone <repository-url>
cd svfs

# 使用 uv 安装（推荐）
uv sync

# 或者使用 pip
pip install -e .
```
### 使用 uv
项目使用 uv 作为包管理器和构建工具，确保已安装 uv：

```bash
# 安装 uv
curl -LsSf https://astral.sh/uv/install.sh | sh

# 在项目目录中安装依赖
uv sync
```

## 使用方法

### 命令行入口：`svfs <子命令>`（推荐）

所有功能收拢在一个入口下，git 风格的子命令；退出码统一为 0 成功 / 1 失败 / 2 用法错误：

```bash
svfs pack     <源文件夹> <输出.svfs>      # 打包（--compress 压缩；--incremental 增量；
                                          #  --snapshot NAME 打成新快照）
svfs unpack   <包> <目标目录>             # 解包（--snapshot ID 选版本；--path 只导出一个路径）
svfs info     <包>                        # 概要：统计、去重/压缩收益、快照列表（--json）
svfs verify   <包>                        # 校验（--check-content 逐文件重算哈希；--key 验签）
svfs sign     <包> --key <密钥>           # 签名（--key-id 起标识；--snapshot ID 选版本）
svfs migrate  <旧包>                      # v1/v2 → v3（默认先留 .bak 备份）
svfs publish  <包> <输出目录>             # 发布成对象 + manifest.json
svfs fetch    <manifest> <目标目录>       # 按清单取回（对象按 sha256 校验）

svfs --help        # 总览
svfs pack --help   # 每个子命令的完整参数
```

典型的发布/取回链路：

```bash
svfs pack ./dist app.svfs --compress
svfs sign app.svfs --key "$SVFS_KEY" --key-id release-1
svfs verify app.svfs --key "$SVFS_KEY" --check-content
svfs publish app.svfs public/
aws s3 sync public/ s3://你的桶/ --cache-control "public, max-age=31536000, immutable"
svfs fetch https://cdn.example.com/app/manifest.json ./restored
```

> 兼容性说明：早期的六个独立命令 `vfspack` / `vfsunpack` / `vfsverify` / `vfsmigrate` /
> `vfspublish` / `vfsfetch` 全部保留为别名——它们与 `svfs` 的同名子命令跑的是
> **同一段实现**，输出与退出码一致，已有脚本不必迁移。区别只有一点：
> `vfsverify --sign` 的功能在 `svfs` 里拆成了独立的 `svfs sign` 子命令。

### 打包文件夹
```bash
# 基本用法
vfspack <源文件夹路径> <输出数据库路径>

# 启用压缩（默认 zlib，推荐）
vfspack ./my_folder ./output.db --compress

# 指定压缩算法（zlib 默认 / huffman 仅用于兼容早期版本打的包）
vfspack ./my_folder ./output.db --compress --compress-algo huffman

# 指定文件系统名称
vfspack ./my_folder ./output.db --name "MyFileSystem"

# 排除特定文件模式（glob 语义，作用于相对路径与文件名）
vfspack ./my_folder ./output.db --exclude "*.tmp" "*.log"

# 增量打包：只写变化的条目（默认按 mtime+size 判定）
vfspack ./my_folder ./output.db --compress --incremental

# 增量 + 按 sha256 逐文件比对（更可靠，代价是要把文件都读一遍）
vfspack ./my_folder ./output.db --compress --incremental --rehash

# 打成新快照：保留历史版本，只写入相对上一版的变化
vfspack ./my_folder ./output.db --snapshot v2
```
### 解包文件系统
```bash
# 解包整个文件系统
vfsunpack <数据库路径> <目标文件夹路径>

# 仅列出根目录内容
vfsunpack ./output.db ./output --list-root

# 解包特定文件或目录
vfsunpack ./output.db ./output --path "docs/readme.txt"
vfsunpack ./output.db ./output --path "images/"

# 忽略时间戳错误（仅导出文件内容）
vfsunpack ./output.db ./output --ignore-timestamps

# 列出所有快照
vfsunpack ./output.db ./output --list-snapshots

# 导出指定快照（默认取最新快照）
vfsunpack ./output.db ./output --snapshot 1
```
### 校验完整性
```bash
# 元数据级校验：摘要是否被改动、blob 是否缺失、大小是否一致
vfsverify ./output.db

# 权威校验：逐文件重算 sha256（代价是读一遍全部内容）
vfsverify ./output.db --check-content

# 列出快照与各自摘要
vfsverify ./output.db --list-snapshots

# 签名与验签（HMAC-SHA256，对称密钥）
vfsverify ./output.db --sign --key my-secret --key-id release-1
vfsverify ./output.db --key my-secret --key-id release-1

# 输出 JSON，便于接到 CI 或外部签名工具
vfsverify ./output.db --check-content --json
```

## 可选集成：FastAPI 静态文件服务

把 `.svfs` 直接当作 FastAPI / Starlette 的静态文件目录来用，
适合"把前端构建产物打包成一个文件分发"的场景（单文件部署、资源包随镜像走）。

FastAPI 属于**可选依赖**：核心功能只用标准库，`import sqlite_vfs` 不会导入任何第三方框架。

```bash
pip install "sqlite-vfs[fastapi]"

# 打包前端构建产物（不需要 --compress 时见下方性能说明）
vfspack ./dist static.svfs
```

```python
from fastapi import FastAPI
from sqlite_vfs.contrib.fastapi_static import SVFSStaticFiles

app = FastAPI()
statics = SVFSStaticFiles("static.svfs")   # 默认 html=True, spa=True, readonly=True
app.mount("/", statics, name="static")

@app.on_event("shutdown")
def _close():
    statics.close()
```

行为说明：

- **SPA 回退**：找不到且不像静态资源（末段路径无扩展名、且客户端接受 HTML）时返回 `index.html`，
  前端路由（`/settings`、`/user/42`）可直接刷新。需要严格 404 就传 `spa=False`。
- **目录索引**：`/sub` 会 307 重定向到 `/sub/`，然后返回 `sub/index.html`；
  与 `StaticFiles` 一致，索引文件名固定为 `index.html`。传 `html=False` 关闭。
- **缓存**：每个响应带 `ETag` 与 `Last-Modified`，命中 `If-None-Match` / `If-Modified-Since`
  时返回 `304`（此时**不会**读取文件内容）。需要强缓存可传 `max_age=3600`。
- **Range**：支持单段 `Range`（视频/断点续传），返回 `206`；多段请求按 RFC 允许的方式退回整份内容。
  越界返回 `416`，语法错误则忽略该头。
- **只读打开**：默认以 `mode=ro` 打开数据库，因此资源包放在只读挂载/容器镜像层里也能用，
  且服务过程永远不会改动资源包（有测试用 md5 校验这一点）。
- **安全**：路径只在数据库内做精确匹配，不做磁盘路径拼接，`..` 之类无法越出资源包。

### 要不要用 `--compress`？

**要，默认就该开。** 用默认的 zlib 算法时，压缩几乎不花钱（实测 733 KB 前端产物，
Python 3.13，`TestClient` 单线程串行）：

| 方案 | 数据库体积 | 打包耗时 | 服务 600KB 资源 | 304 条件请求 | 顺序 100 次请求 |
|---|---|---|---|---|---|
| 不压缩 | 768 KB | 152 ms | 6.3 ms | 5.7 ms | 497 ms |
| **`--compress`（zlib，默认）** | **88 KB** | 121 ms | **6.9 ms** | 5.7 ms | **431 ms** |
| `--compress --compress-algo huffman` | 368 KB | 885 ms | 587 ms | 4.3 ms | 1411 ms |

zlib 把体积压到 1/8.7，而**读写延迟基本不变**——解压是 C 实现的 300～600 MB/s，
630 KB 只要 2 ms，比省下来的 I/O 还划算；打包反而更快，因为要写的字节更少。

`huffman` 是纯 Python 实现的历史算法（没有 LZ77，压不动"重复"），现在只为读得懂
早期版本打的包而保留：同一份数据它体积是 zlib 的 4 倍，解压慢 85 倍。新包不要用它。

- `304` 路径不需要读内容也不需要解压，配合浏览器缓存时几乎零成本；
- 小于 64 字节的文件不压缩（压了反而更大），库内会记成 `none`。

## 迁移旧格式

格式演进：

| schema | 内容存放 | 说明 |
|---|---|---|
| v1 | `files.content` + `files.compressed`（布尔） | 最早的格式，压缩用 zlib |
| v2 | `files.content` + `freq_blob` + `compress_algo` | 引入可选压缩算法 |
| **v3** | `blobs`（内容寻址）+ `files.content_hash` | 当前格式：去重、快照、完整性校验 |

当前版本打开 v1 / v2 的库会**直接报错并给出迁移命令**，不会静默失败：

```
错误: old.db 是旧格式的虚拟文件系统，当前版本（schema 3）需要 blobs + snapshots 结构。
请先迁移并保留备份：vfsmigrate "old.db"
```

```bash
svfs migrate ./old.db              # 原地迁移，先复制一份 ./old.db.bak
svfs migrate ./old.db --output ./new.db   # 输出到新文件，原库一个字节都不动
```

迁移会读旧库、在旁边的临时文件上重建、校验通过后原子替换，所以中途失败不会留下半成品。
迁移过程顺带按 sha256 去重：历史库里同一份内容存了多次会被合并（`svfs migrate` 会报告省下多少字节）。
迁移完可以用 `svfs verify --check-content` 复核每一个文件的内容。

## 桌面工具：SVFS Studio

`svfs-studio` 是配套的可视化工具（PySide6，暗色界面），把一个 `.svfs` 摊开来看：
文件树与内容预览、版本快照与两版差异、内容去重与压缩的实际收益、完整性校验与签名，
以及它作为一个 SQLite 数据库的内部结构（文件头、页、各表占用）和一个**只读** SQL 控制台。

它做的一切都调用本库的公开 API（`FolderPacker`、`migration.migrate`、`publish.export_objects`…），
没有第二份实现，所以界面上得到的结果与 CLI 逐字节一致。

```bash
# 安装（GUI 依赖在可选 extra 里，核心库与 CLI 依然零第三方依赖）
uv sync --extra gui
# 或：pip install "sqlite-vfs[gui]"

# 启动
uv run svfs-studio
# 或：python -m svfs_studio
```

工具里能做的不只是"看"：打包（真实逐文件进度，因为 `pack_folder` 现在支持进度回调）、
**包内编辑**、解包、迁移旧格式、回收未引用内容、发布成对象存储布局、从清单拉取、
与本地目录逐文件对比——都跑在后台线程上，界面不卡。

**包内编辑**（「文件」页的编辑那一排，或右键菜单）：

* **添加文件 / 添加文件夹**：加进当前快照；文件夹整棵进去。同名路径会先问一句，
  选"替换"就覆盖（内容仍按 sha256 去重，重复字节不会多占空间）；
* **新建目录**：在选中的目录下建（没选中就是根目录）；
* **重命名**：改名或移动文件/目录（目录连同子项一起改）；
* **删除**：删掉文件或整个子树。库里是内容寻址 + 引用计数，所以删路径不等于删内容——
  只有一条引用都不剩时，那份内容才会被回收（「存储」页能看到可回收多少）；
* **编辑内容**：选中文本文件后点「编辑内容」（或直接改完按 Ctrl+S），就地改完写回包内。
  按 UTF-8/GBK 嗅探编码并以原编码保存；上限 1 MB——编辑器装不下的文件不给编辑，
  因为"只看到一半就保存"等于截断。二进制文件与超限文件请导出改好后用「添加文件」替换。

每次编辑结束都会调一次 `recount()`：刷新快照的统计与摘要，保证改完立刻能校验通过。
代价是**之前做的签名会失效**（签名只覆盖摘要），界面上会明确提示这件事——
这是"摘要跟着内容走"与"签名绑定摘要"这套设计的必然结果，不是 bug。

几个值得先试的地方：

* 没有现成的包？点「打个示例包看看」，会在临时目录里造一棵含两份相同内容的小目录树并打包；
* 仓库自带的 `appcation.svfs` 是 schema v1，打开它会看到「旧格式」横幅和「去迁移」按钮，
  正好演示从旧格式升到 v3 的完整流程；
* 「存储」页把去重与压缩分开算：`logical_size → unique_bytes → stored_bytes` 三步各省了多少一目了然；
* 「内部」页可以直接 `SELECT`——包就是个普通 SQLite 库，这点骗不了人。

想要一份界面截图用于文档或评审（不需要显示器）：

```bash
python -m svfs_studio --shots ./shots ./某个包.svfs   # 每个标签页各存一张 PNG
```

界面测试（offscreen 渲染，含像素级暗色检查、预览内容与库字节比对、中断语义）：

```bash
python -m unittest discover -s tests -p "test_studio_ui.py" -v
```

## 插件化客户端：svfs-client

`svfs-client` 是基于同一套 SVFS 格式的**插件化桌面客户端**（PySide6 + QWebEngineView）：
插件以 `.svfs` 单文件分发，客户端安装插件、把插件的 Web 界面直接从包内加载进
WebView——不落盘、不起端口、不需要解包。

核心模型：

* **一个插件 = 一个完整的 .svfs 包**：完整前端（HTML/JS/CSS/图标）+ 根目录一份
  `plugin.json` 清单。插件自带全部资源，不依赖宿主或其它插件的任何文件；
* **多插件并存、互相隔离**：每个插件一个包文件、一个独立 origin
  （`svfs://<插件id>/`——浏览器按 origin 隔离 localStorage / IndexedDB / cookie）、
  一份独立的键值存储。协议层拒绝跨插件请求，导航守卫禁止跳到别的插件或把插件页
  变成远程内容容器；
* **快照即版本**：一个插件一个 .svfs，每次升级往里打一个新快照——没变的文件共享
  blob（升级通常只增加变化部分的字节），旧版本全部保留，**回滚 = 切换当前快照**，
  包文件一个字节都不用动；
* **完整性与签名贯穿始终**：安装时跑 `verify()`（可选逐文件重算 sha256），
  从远程安装时每个对象按 sha256 校验。

### 安装与体验

```bash
uv sync --extra gui          # = [client] + [studio]；WebEngine 需要完整版 PySide6
uv run svfs-client           # 或 python -m svfs_client
# 想指定数据目录：uv run svfs-client --data ./my-client-data
```

三个 extra 的区别只在 Qt 装多少：`client` = 完整 PySide6（WebEngine 在
PySide6-Essentials 里**没有**）、`studio` = PySide6-Essentials（Studio 只用
QtCore/QtGui/QtWidgets）、`gui` = 两者都装（旧写法保留，等价于 client + studio）。
wheel 里三个包（sqlite_vfs / svfs_studio / svfs_client）一起分发——两个桌面
应用的 console script 都声明在同一个发行版里，只打核心包会让 `svfs-client`
命令存在而模块缺失。

```bash
pip install "sqlite-vfs[client]"   # 只装插件客户端（含 PySide6）
pip install "sqlite-vfs[mqtt]"     # 再加消息模块的 MQTT 推送通道（paho-mqtt）
pip install sqlite-vfs             # 只要核心库 + CLI（零第三方依赖）
```

启动后先经过一段启动动画和登录界面（强制登录，详见下文「登录与认证」；
搭配本仓库的示例服务端时，默认账号是 admin / admin123）。侧栏列出已安装的
插件；插件管理（安装/升级/回滚/卸载）在「设置 → 插件管理」里。

### plugin.json 规范

包根（当前快照根）必须有一份 `plugin.json`：

```json
{
  "id": "com.example.clock",
  "name": "时钟",
  "version": "1.2.0",
  "api_version": 3,
  "entry": "index.html",
  "icon": "icon.png",
  "permissions": ["storage", "notify", "title", "net"],
  "description": "一个时钟插件"
}
```

* `id`：反域名形式（小写字母/数字/连字符），全局唯一，同时是 `svfs://` 的 host；
* `api_version`：插件**要求的最低宿主桥版本**——宿主接受 `api_version ≤ 自身
  PLUGIN_API_VERSION` 的插件（桥能力只增不破，升级客户端不会废掉旧插件；
  要求更高版本的插件会得到「请升级客户端」提示）。取值参考：v2 = net 与
  host.info 的 baseUrl；v3 = host.info 的 authUser、宿主给同源 net 请求自动
  附加登录 token；v4 = dialog 权限与 `ui.confirm`/`ui.message` 原生对话框；
  v5 = messages 权限与服务端推送（`svfs.messages.subscribe`）。
  没用新能力就写 2（甚至 1）即可，兼容面更大；
* `entry`：SPA 入口。路径 404 且末段没有扩展名时回退到它（每个插件都是完整前端）；
* `permissions`：声明这个插件要用哪些桥能力（storage / notify / title / net /
  dialog / **messages**），安装时向用户展示。messages = 接收服务端推给本插件的
  消息（见下文「消息模块」）；
* `net_hosts`（可选，**net 目标白名单**）：声明后，该插件经桥发的请求只能指向
  列表里的主机，其它一律被宿主拒绝并给出明确错误。支持精确主机名与 `*.` 后缀
  匹配（`"*.corp.example"` 含 `corp.example` 本身）。不写 = 不限制（老插件行为
  不变）。有 net 权限的插件本质上能把数据发到任何地方，给内网/企业插件的清单
  加上这一项是划算的。写了它就必须同时声明 `net` 权限（安装时校验会拦住写错的
  清单）。

### 服务器地址（base_url）

「插件管理 → 服务器」里配置一次服务端地址（`http(s)://`），它同时承担两件事：

1. **插件下载**：发布端把每个插件的 publish 产物同步到服务器的
   `<base_url>/<插件id>/` 子目录下；客户端安装时只填**插件 id**（或相对路径），
   自动解析成 `<base_url>/<id>/manifest.json`。填完整 URL 也可以。
2. **接口服务**：声明了 `net` 权限的插件经桥访问这个服务器的 API——
   宿主代为发 HTTP 请求，**不经过浏览器的 CORS**，服务端不需要任何跨域配置。

配置存在 `<data>/settings.json`，改动即时生效（已打开的插件页面下次调用就能拿到）。

### 登录与认证（强制登录）

客户端启动时先播放一段启动动画（呼吸 logo + 状态文字，点击可跳过），随后进入
登录界面——**必须登录成功才能使用客户端**。登录窗用同一套暗色主题，无头一次
填写过服务器地址的话会自动预填，下次勾着「记住登录」登录后，启动动画期间就会
静默校验 token（`GET /api/me`），有效则直接进主窗口，过期/网络不通则回到登录窗
并提示原因。

* 登录请求：`POST <base_url>/api/login`（JSON `{"username","password"}`）→
  `{"ok":true,"token","username"}`。服务端会话在内存里，重启服务端即全部失效；
* 凭据持久化：勾选「记住登录」才存 token，**且不落在 `settings.json` 里**——
  token 是能直接冒充用户的东西，改由 `credentials.py` 按可用后端加密保存：
  Windows 用 DPAPI（密文只有当前用户在本机解得开），其它平台用系统凭据库
  （可选依赖 `keyring`），两者都没有才退化成明文并**在设置界面如实提示**
  （「设置 → 登录凭据」显示当前存储方式）。老版本存在 `settings.json` 里的
  `auth_token` 会在启动时自动迁移并清空原字段。不勾「记住登录」则 token 只在
  本次运行内有效（服务器地址与用户名仍会记住）；
* 凭据绑定服务器：令牌只交还给**签发它的那个 base_url**，在设置里换了服务器
  地址后旧令牌不会被当成新服务器的凭据发出去；
* 插件无感知：宿主桥给**发往服务器（与 base_url 同源）** 的 net 请求自动附加
  `Authorization: Bearer <token>`，不会发给第三方地址，也不覆盖插件自带的
  Authorization 头；
* 会话失效有反馈：如果服务器不再认这个 token（服务端重启、会话过期），宿主
  注入的凭据被 401 拒绝时会**主动提示**——状态灯显示「登录已失效」（黄，而不是
  误报「离线」），横幅给出「重新登录…」一键入口，用户中心与侧栏头像同步显示
  失效状态。提示只发一次，重新登录成功后自动恢复；
* `host.info` 新增 `authUser` 字段（当前登录用户名，未登录为空串）；
* 退出登录（用户中心或服务器卡片）：清掉本地凭据 → 关闭主窗口 → 回到登录
  界面（重新登录成功用新会话开回主窗口；关掉登录窗即退出客户端）——强制登录
  下不存在"能用的未登录状态"。退出时同时断开消息推送并清掉该账号收到的消息
  （下一个登录的人不该看到上一个人的消息）；
* 登录成功后接上消息模块：**先同步**（HTTP 补齐离线期间的消息），再连 MQTT
  接收实时推送；两者都进统一总线按 id 去重；
* 「插件管理 → 服务器」卡片显示登录状态，可「重新登录…」或「退出登录」。

### 界面：侧栏 / 设置 / 用户中心

* 启动后**直接显示插件内容**（可配置）：优先用设置里配置的启动插件，
  未配置则按默认规则（上次打开的 > 显示顺序里第一个启用的插件），都没有
  才停在插件管理页；
* **插件顺序可编排**：插件管理页每张卡片右下角有 ↑/↓，上移/下移一位即改
  显示顺序（侧栏、图标栏、管理页三者一致）；顺序存在 `settings.json` 的
  `plugin_order` 里，只影响显示——不动包、不动注册表、不请求服务器。没排到
  的插件（新装的）按名称跟在后面，"恢复默认顺序"一键清掉编排（那之后默认
  按名称排）。启动直显的默认规则也读这个顺序，把常用插件排到最前面就默认
  打开它；
* 侧栏顶部「«」把侧栏收成**图标栏**（宽度动画过渡，不再是瞬跳也不再让插件
  不可达——图标带悬停提示，点击即打开），再点「»」展开；选择会记住，
  下次启动保持；
* 侧栏搜索框**模糊匹配**插件名与 id（包含匹配优先，其次首字母缩写：
  「csd」能搜到 `com.svfs.demo`、中文名直接搜汉字）；无结果时给出提示，
  清空即恢复全部；
* 侧栏底部「设置」——设置项按类型分成五个页签（每页内容超出时可滚动，不会被
  压扁），每页里保存按钮只作用于自己那一组：
  * **插件**：插件管理入口（安装本地 .svfs、从服务器安装、升级/回滚/卸载）、
    桥调试台（一键装调试插件）、启动时打开的插件、关窗口是否最小化到托盘；
  * **服务器**：服务器地址（与服务器卡片同步生效）、登录凭据的存储方式、
    消息推送（MQTT）的 broker/组织/前缀/账号（保存即重连）；
  * **存储**：数据目录（插件路径，写指针文件，重启生效）、对象缓存路径
    （留空 = 系统默认，安装时即时生效）与缓存清理；
  * **网络**：插件 net 请求的超时与重试次数、代理、额外 CA 证书（内网自签
    HTTPS 与公司代理都在这里配，保存即生效，CA 路径写错会当场报错）；
  * **诊断**：诊断日志落盘开关、导出诊断报告；
* 侧栏底部的 **✉ 消息入口**进「消息中心」：**有未读时图标右上角挂一个红点，红点里是未读
  条数**（超过 9 显示 `9+`）；列表**每页 20 条、最新的一批是第一页**，底部有分页器
  （第几页 / 共几条 / 上一页 / 下一页）：每张卡片左边一条**级别色条**（信息蓝 / 警告黄 /
  错误红），标题行右上是短时间（今天给 `09:15`、昨天写「昨天」、跨年才带年份），
  下面一行是正文摘要（按宽度省略，点开看全文）；未读的卡片描主题色边、标题加粗、
  挂红底「未读」徽标，**点开某条就把那一条标为已读**；
* **筛选**：上方按**状态分页**（全部 / 未读 / 已读，标签上带条数）与**日期**（全部日期 /
  今天 / 最近 7 天 / 最近 30 天 / 自定义范围）过滤，筛选没结果时给一句提示与「清除筛选」；
* **多选删除**：每张卡片左侧有勾选框，「全选」只勾当前筛选下看得见的那些，右上角
  「删除选中 (N)」删掉勾中的消息（没有"清空"按钮了，要全删就全选后删除）。
  已读状态与删除记录**按身份（服务器+组织+用户名）存在本地**（`messages.json`）：
  退出登录、重启客户端都不会让删掉的消息复活、也不会把读过的变回未读。删除本身只
  动本地这一份——服务端那边不动，所以**换一台机器登录还会看到它**；要做到到处都删掉
  得服务端提供删除接口；
* **点开消息进详情页**：正文按 **Markdown 渲染**（Qt 自带渲染能力，不引第三方
  依赖），可一键切「原文」对照服务端实际发来的内容，正文里的链接交给系统浏览器；
* 侧栏底部的圆形头像（登录用户首字母）点击进「用户中心」：当前登录
  状态、服务器地址、本次登录时间，以及退出登录 / 重新登录；
* **退出登录 = 关掉主窗口并回到登录界面**：客户端是强制登录的，"未登录的
  主窗口"本就不该存在，所以退出后不会留一个没有凭据的界面继续用。已打开的
  插件页面会一并收掉（插件的键值存储按插件隔离、不区分用户，页面状态同理，
  换账号重进时重新加载）；重新登录成功则用新会话把主窗口开回来，用户关掉
  登录窗则退出客户端。退出登录时也会尽力通知服务器作废 token；
* 插件页面底色固定为主题底色；WebEngine 在启动阶段预热（Profile 提前
  创建 + 隐藏预热视图 + 共享 OpenGL 上下文）——首次打开插件不再闪白屏
  或整窗闪烁。

### 任务中心

宿主的后台工作在侧栏底部的 **⏱ 任务入口**里一览无余（有活儿在跑时图标右上角挂一个
主题色小圆标，里面是条数）：

* **什么都记**：安装 / 远程安装 / 升级 / 回滚 / 卸载 / 启停 / 校验 / 检查更新 /
  同步消息 / 探测服务器 / 检查客户端更新——登记收在 `TaskCenter.submit` **一处**
  （op → 分类与标题的映射表在 `task_center.py`），不会出现"某个按钮忘了记账"。
  后台轮询类（同步消息、探测服务器…）标记为"不进历史"，跑的时候看得见，不会把
  历史刷满；
* **看得见状态**：每条任务有 `排队中 / 运行中 / 已完成 / 失败 / 已取消 / 已中断`
  六态，带进度（有总数的画进度条，没总数的走不确定态）、耗时、所属方（宿主还是
  哪个插件）、结果摘要或失败原因；页面按状态分页（条数写在页签上）、每页 20 条、
  可取消 / 重试 / 打开所属插件，顶部「清除已结束」收拾历史；
* **排队而不是拒绝**：变更类操作（安装/卸载/回滚…）同时只跑一个，忙时后来者
  **排队**（以前是弹一句"正忙，请稍后再试"），队列上限 20；状态栏那个进度条现在
  显示的就是"当前任务"（完整列表在任务中心）；
* **取消是协作式的**：取消只在**进度点**生效（不会打断已经进入提交阶段的数据库
  操作），所以界面上会有个"正在取消…"的中间态。以前这套取消机制代码里就有，
  但**一次都没被接上**——现在排队中的任务可以直接撤下来、在跑的走协作式取消；
* **重试用原参数**再提交一次（新任务用 `retry_of` 指回原任务）；重启后从历史里
  读出来的任务没有原参数，重试按钮就是灰的（不骗人）；
* **历史落盘**：`<data>/tasks.json`——**整机一份，不按身份分**（安装/升级是"这台
  机器上的事实"，和 registry 一个口径），每条任务记着提交时的登录名 `actor`。
  上限 200 条，写入原子；上次进程退出时还在跑的任务，下次启动会标成**已中断**
  （进程都没了，它们不可能完成，别让界面挂着一排永远不动的进度条）。

### 托盘与单实例

* **关窗口 = 最小化到托盘**（默认；「设置 → 插件 → 窗口与托盘」可关）：窗口收进
  托盘后**推送、轮询、插件全都照常跑**，消息照收照弹；想退出用托盘右键菜单的
  「退出」，或把上面那个勾去掉（点关闭按钮就退出）。第一次收进托盘会弹一句说明，
  动态里也会写一句，免得以为关不掉；
* **托盘菜单三项**：显示主窗口 / 消息中心 / 退出；**左键或双击托盘图标**同样把
  窗口拉回前台。托盘只在系统支持时才有（无桌面会话、被组策略禁用时自动降级：
  通知退回应用内横幅，关闭就是退出）；
* **单实例**：同一个数据目录只允许一个客户端在跑（`<data>/web` 是 Chromium 的
  profile 目录，两个进程一起用会在 WebEngine 层打架）。再起一个时**第二个进程
  会把 `--open` 等意图捎给已在运行的那个**（拉出窗口并打开指定插件）然后自己退出，
  退出码 0。锁按数据目录区分，所以 `--data <另一个目录>` 起第二个实例是允许的；
  `--help` 不碰锁，随到随返回。

### 可靠性与诊断

* **插件更新检查**：插件管理页「检查更新」逐个比对服务器上的快照名与
  本地版本，有新版就在卡片上显示「可升级 → x.y.z」徽章与一键升级按钮
  （走远程安装链路，升级后徽章自动消失）；
* **客户端自更新检查**：启动时（以及改服务器地址后）顺带看一眼服务器上的
  `<base_url>/client/manifest.json`（静态文件，与插件清单同一套托管方式，
  内容 `{"version","url","notes"}`）；比本地新就在状态栏提示并在横幅给出
  「下载新版本…」（交给系统浏览器打开 `url`）。没放这个文件 = 静态 404，
  静默跳过，不当错误；
* **服务器连通状态**：服务器卡片上有一个状态灯（灰=未配置 / 绿=在线并
  显示延迟 / 黄=在线但登录已失效 / 红=离线），启动时探测一次并每 60 秒复查；
* **离线宽限**：启动时若服务器不可达（网络错误，而非凭据被拒），记住的
  登录凭据仍然放行——带「离线模式」提示进入，本地插件照常用，接口类
  功能恢复连接后自然可用；
* **网络可配置**：插件 net 请求的超时（默认 30 秒）与重试次数（默认 1，**只
  对幂等方法** GET/HEAD/OPTIONS 生效，遇 502/503/504 或连接层错误按指数退避
  重试——自动重发 POST 可能把"下单"变成两单，所以不重试）；代理与额外 CA
  证书在设置里配，loopback 目标永远直连；
* **渲染崩溃恢复**：插件页面的渲染进程崩溃时显示可一键重载的占位页，
  而不是永久白屏；
* **诊断日志**：工作线程输出、插件页面控制台（`console.log` 等）、桥的
  net 失败（含重试与凭据被拒）、未捕获异常、服务器探测失败统一记入内存
  环形缓冲（最近 600 条），**同时滚动写入 `<data>/logs/client.log`**
  （1 MB × 3 份，可在设置里关掉）——崩溃/重启之后还能回看现场，而内存那份
  恰好会随崩溃一起消失；「设置 → 导出诊断报告…」生成含环境信息与日志的
  文本文件（报告里带日志文件路径）。

### 插件前端拿到的桥：`window.svfs`

宿主自动注入（插件作者**不需要**引用 qwebchannel.js），能力按 `permissions` 把关，
没声明的能力调用直接失败：

```html
<script>
const info = await window.svfs.host.info();
//   → {pluginId, appName, appVersion, apiVersion, platform, baseUrl, authUser}

await window.svfs.storage.set('visits', 42);   // 需要 "storage" 权限
const visits = await window.svfs.storage.get('visits');   // 42（按插件隔离）
await window.svfs.storage.remove('visits');
const keys  = await window.svfs.storage.keys();

// 需要 "net" 权限：宿主代发 HTTP 请求（绕开 CORS，响应上限 20 MB）
const res = await window.svfs.net.request({ method: 'GET', path: '/api/ping' });
//   相对 path 拼在 base_url 后；也可以 { url: 'https://api.example.com/…' }
//   → { ok, status, headers, bodyText, bodyBase64 }，4xx/5xx 也是正常返回
//   超时/重试来自客户端设置；清单里写了 net_hosts 就只有白名单主机能通
await window.svfs.net.request({ method: 'POST', path: '/api/save',
                                headers: {'Content-Type': 'application/json'},
                                body: JSON.stringify({a: 1}) });

await window.svfs.ui.notify('打包完成');        // 需要 "notify" 权限
// 宿主发系统托盘通知；没有托盘（容器/被策略禁用）时退回状态栏提示
await window.svfs.ui.setTitle('时钟 · 12:30');  // 需要 "title" 权限

// 需要 "dialog" 权限（v4+）：宿主**原生对话框**，Promise 返回用户是否点了"确定"
const ok = await window.svfs.ui.confirm({ title: '删除', text: '确定删除这条记录？' });
await window.svfs.ui.message({ kind: 'warn', title: '警告', text: '余额不足' });

// 需要 "messages" 权限（v5+）：订阅服务端推给**本插件**的消息
const off = (await window.svfsReady).messages.subscribe(function (message) {
  // message.text 是原文；能解析成 JSON 时另有 message.json（仪表盘数据这类）
  render(message.json || message.text);
});
// off() 取消订阅
</script>
```

`host.info` 不需要权限——它只包含插件自己的元数据、宿主版本号与配置的服务器
地址，不含用户数据。

### 消息模块：服务端主动推送（MQTT）

服务端要能随时把消息推给某个用户（或某个插件），走的是**统一消息总线**：
所有通道的消息先进总线，由总线按主题分类、去重、再分发。两条通道：

| 通道 | 时机 | 说明 |
|---|---|---|
| MQTT 推送 | 实时 | 服务端主动推；broker 地址由登录响应下发（也可在设置里手配） |
| HTTP 补齐 | **登录成功后先做一次**，也可点「立即同步」 | `GET /api/messages?since=…` 把离线期间攒下的消息补回来 |

两条路的消息都喂进同一个总线、按 id 去重，所以"推送"与"补齐"重叠、QoS 1
重投都不会重复显示。

**主题方案**（组织 `org` 是租户位，从第一版就占好——多租户上线不用改格式，
broker 侧按 `<prefix>/<org>/#` 下发 ACL 即可隔离）：

```text
<prefix>/<org>/user/<user>/message              主机消息 → 消息中心
<prefix>/<org>/user/<user>/plugin/<plugin_id>   插件消息 → 只给该插件
<prefix>/<org>/broadcast/message                组织内广播 → 消息中心
<prefix>/<org>/broadcast/plugin/<plugin_id>     组织内广播 → 只给该插件
```

* 登录后客户端只订阅两条（最小权限，不是整个组织）：`<prefix>/<org>/user/<user>/#`
  与 `<prefix>/<org>/broadcast/#`；
* 用户名/插件 id 占一个主题层级，经**可逆**的百分号编码（`topic_segment`），
  所以 "a b" 与 "a_b" 不会串线；服务端用同一个函数（`svfs_client.topics`）
  拼主题，两端规则一致；
* 即使 broker 的 ACL 配错、把别人的消息推过来，总线还会按当前组织/用户再挡
  一道（`forbidden` 计数 + 诊断日志）。

**两类消息泾渭分明**（分类只看主题，不看载荷）：

* **主机消息**进消息中心（侧栏底部的 ✉，带未读徽标）：载荷是消息封皮
  `{id, title, body, level, time, source}`，纯字符串也当正文；**正文可以写成
  Markdown**——列表里显示剥掉记号的一行摘要，详情页按 Markdown 渲染；
* **插件消息**不进消息中心：服务端发什么插件就收到什么（仪表盘数据、行情、
  位号刷新这类业务载荷，补历史没有意义），宿主只负责按插件 id 投递。

插件页面是懒加载的，所以**消息可能比页面先到**：总线按插件缓存最近 50 条
（超了丢最旧的并计数），页面打开后一次性补投；页面侧还会再排一次队
（宿主在 DocumentCreation 阶段就立好投递入口），插件订阅时补发——所以
插件写 `subscribe` 时不用担心"消息是不是已经来过了"。

```js
// 插件侧（清单里声明 "messages" 权限才会存在）
const svfs = await window.svfsReady;
svfs.messages.subscribe((message) => {
  console.log(message.text, message.json);   // 原文 + 尽量解析出的 JSON
});
```

**配置与降级**：

* broker 地址优先用登录响应下发的 `mqtt: {host, port, tls, prefix, org}`
  （部署信息不该让每个用户手填）。**也可以在「设置 → 消息推送（MQTT）」里直接
  配**：broker（接受 `host` / `host:1883` / `mqtts://host:8883`，URL 形式里的
  scheme 与端口优先）、端口、TLS、主题前缀、组织标识、broker 账号——保存即重连，
  不用等下次登录；那一节还会显示**生效**的配置与来源（手配 / 服务端下发）；
* broker 密码不走设置界面：缺省用登录 token（已加密存储、不落明文）；需要单独的
  broker 密码时由服务端在登录响应里下发（或写 `settings.json` 的
  `mqtt_password`）。落盘的键：`mqtt_host` / `mqtt_port` / `mqtt_tls` /
  `mqtt_prefix` / `mqtt_username` / `mqtt_password` / `org`；
* MQTT 需要可选依赖：`pip install "sqlite-vfs[mqtt]"`（`paho-mqtt`）。没装、
  没配 broker、连不上，都只是"没有实时推送"——界面在消息中心显示状态
  （未启用 / 连接中 / 已连接 / 连接失败），补齐照常工作；
* **推送不可用时自动补齐**：上面那几种情况下 MQTT 状态不会是"已连接"，客户端
  每 30 秒自动问一次 `GET /api/messages`，消息照样到（不用手点「立即同步」）；
  连上推送就停轮询——推送更及时，也省得白跑请求；
* **新消息有提示**：系统托盘通知 + 应用内横幅（在窗口顶部，任何页面都看得见，
  几秒后自动消失，点「查看」进消息中心）。推送与补齐两条路都会提示，级别只影响
  图标与配色；登录时一次补齐很多条就只弹一条汇总，不刷屏；
* 断线重连交给 paho（指数退避 1→30 秒），重连成功后自动重新订阅；
* 连接状态、每个通道的收发与丢弃计数都进诊断日志（含 `forbidden`/`dropped`）。

生产落地（Docker 起 broker、认证/ACL/TLS 怎么选、服务端发布代码、离线补齐接口的契约）
见 [`docs/mqtt-guide.md`](docs/mqtt-guide.md)；可直接跑的 broker 配置在 `deploy/mqtt/`。

### 发布 → 安装的完整链路

发布端用现成的 `svfs publish`，客户端用 manifest 安装（对象逐个 sha256 校验）：

```bash
# 插件开发机：打包 + 发布到静态托管
svfs pack ./my-plugin dist.svfs --compress
svfs publish dist.svfs ./public
aws s3 sync ./public s3://你的桶/plugin/com.example.clock/ \
    --cache-control "public, max-age=31536000, immutable"

# 客户端「从服务器安装…」里填插件 id 即可：
#   com.example.clock
# （publish 产物已按约定同步到 <base_url>/com.example.clock/ 下；
#   同一个插件发新版本后，客户端再装一次就完成增量升级）
```

服务端如果还提供接口服务，加一个普通 HTTP API 即可——插件的 `net` 请求就是
普通的服务端调用，无需为 `svfs://` origin 做任何跨域配置。

### 插件开发辅助：桥调试台与 Vue 模板

* **桥调试台**：客户端「设置 → 桥调试台 → 安装调试插件」一键装一个调试
  插件（`com.svfs.demo`），把 `window.svfs` 的每类能力做成可点击面板
  （host.info / storage 键值 / net 自定义请求 / notify / title / 原生对话框 /
  messages 插件消息），开发自己的插件时用来对照验证真实桥行为——消息面板
  就是"插件收服务端业务数据"的现成示例（订阅、按字段渲染、取消订阅）；
* **Vue 开发模板**：`examples/vue-plugin-template/` —— Vue 3 + Vite 起步
  模板，含 `src/svfs.js`（Promise 封装）、`src/mock-svfs.js`（浏览器 dev
  模式下的宿主模拟器，`npm run dev` 不进客户端也能迭代 UI）与每类能力
  一个的面板组件（含 `MessagesPanel.vue`：服务端推送的插件消息怎么收、
  怎么渲染）；`npm run build` 后 `svfs pack ./dist plugin.svfs` 即可
  安装（详见模板内 README）。

### 服务端最小示例（FastAPI）

`examples/plugin_server.py` 是一个单文件服务端，同时覆盖插件下载与接口服务：
`POST /plugins/<id>` 收 .svfs 包并就地 publish（服务端用同一套清单规则校验），
静态吐出各插件的 `manifest.json + blobs/` 供客户端下载，`GET /api/ping` 等
API 供插件经 `net` 调用；可选 `SVFS_UPLOAD_TOKEN` 环境变量保护上传。

`/api/*` 需要登录（`GET /api/ping`、`POST /api/echo` 校验 Bearer token），
登录体系三个接口：

| 接口 | 说明 |
|---|---|
| `POST /api/login` | `{"username","password"}` → `{"ok",token,"username","login_time"}`，密码错 401 |
| `GET /api/me` | 带 Bearer token → `{"ok","username","login_time"}`，用于启动时静默校验 |
| `POST /api/logout` | 作废当前 token |

消息相关（见上文「消息模块」）：

| 接口 | 说明 |
|---|---|
| `GET /api/messages?since=` | 客户端登录后/「立即同步」时补齐消息（只返回该登录用户的） |
| `POST /api/push` | 服务端主动推：`kind=message` 进消息中心 + 待收列表；`kind=plugin` 只走插件主题（`plugin` 字段指定目标插件，`data` 原样发给插件） |
| `POST /api/broadcast` | 组织广播（主题里带 `broadcast`） |

配了 broker（`SVFS_MQTT_HOST`，可选 `SVFS_MQTT_PORT` / `SVFS_MQTT_TLS` /
`SVFS_ORG`）时，推送会真的发布到 broker，且 broker 地址随登录响应下发，
客户端自动连上；没配就只写待收列表（响应里如实说明 `mqtt=skipped`），
客户端仍能靠登录后的同步看到消息——这条降级路径让演示不依赖 broker。

另外提供可选的 `/client/manifest.json`（客户端自更新检查）：设置环境变量
`SVFS_CLIENT_VERSION=9.9.9 SVFS_CLIENT_URL=https://…` 即可看到「客户端有新版本」
提示，不设置时返回 404（客户端静默跳过）。生产上这个文件直接静态托管即可，
内容就是 `{"version", "url", "notes"}`。

账号来源：默认内置演示账号 `admin / admin123`；设置环境变量
`SVFS_USERS="user:pass,user2:pass2"` 即换成自己的账号表。插件下载与上传保持
开放/沿用原有令牌机制，不受登录影响。

```bash
uv sync --extra fastapi                # fastapi/starlette + uvicorn + python-multipart
uv run python examples/plugin_server.py    # → http://127.0.0.1:8000
                                        # 启动时会打印登录账号提示

# 客户端「服务器」卡片填 http://127.0.0.1:8000，登录窗输 admin / admin123；
# 「从服务器安装」填插件 id（或 curl 上传一个包试试，上传不需要登录 token）：
curl -F "file=@dist.svfs" http://127.0.0.1:8000/plugins/com.example.my-plugin

# 直接调 /api/* 的外部脚本要先拿 token：
token=$(curl -s -X POST http://127.0.0.1:8000/api/login \
        -H "Content-Type: application/json" \
        -d '{"username":"admin","password":"admin123"}' | jq -r .token)
curl -H "Authorization: Bearer $token" http://127.0.0.1:8000/api/ping
```

它的行为有测试背书（`tests/test_plugin_server.py`，含"上传 → 服务端 publish →
真实 HTTP → 客户端 install_from_url 装回"的完整闭环）。

开 `SVFS_DEMO_PLUGIN=com.svfs.demo` 还会在 lifespan 里起一个线程，每
`SVFS_DEMO_INTERVAL`（默认 2）秒往该插件推一块仪表盘数据（带 `seq`），
配合调试台或 Vue 模板的消息面板就能看到插件通道在跳——跑法见
[`docs/mqtt-guide.md`](docs/mqtt-guide.md) 的「4.6 插件消息」。

### 数据目录

```text
<data>/                        Windows: %LOCALAPPDATA%/svfs-client；其它: ~/.svfs-client
├── registry.json              已安装插件注册表（enabled、当前快照、来源…）
├── settings.json              客户端设置（服务器地址、网络、日志开关…）
├── messages.json              消息存档（消息 + 已读状态 + 删除记录 + 补齐游标，按身份分）
├── tasks.json                 任务存档（任务中心的历史；整机一份，每条记 actor）
├── credentials.dat            登录令牌（DPAPI/keyring 加密；见 credentials.py）
├── logs/client.log            诊断日志（1 MB × 3 份滚动，可关）
├── plugins/<id>/plugin.svfs   每个插件一个包文件（快照即版本）
├── storage/<id>.json          桥 storage 的键值存储（按插件隔离）
└── web/                       WebView 的持久化目录（localStorage 等）
```

`settings.json` 里**没有**登录令牌：老版本的 `auth_token` 字段会在启动时迁移到
`credentials.dat`（并按可用的加密后端保存），迁移后原字段留空不再使用。

### 实现分层（与 svfs-studio 同一套约定）

`manifest` / `paths` / `registry` / `installer` / `sample` / `auth` /
`credentials` / `diagnostics` / `netutil` / `search` / `topics` / `messages` /
`bus` / `mqtt` 是**纯标准库/无 Qt 依赖**（不 import Qt，可在无 PySide6 的环境
单独测试；`mqtt` 的 paho 也是可选依赖，缺了只报 `missing` 状态）；
`scheme` / `bridge` / `webview` / `worker` / `mqtt_service` / `messages_page` /
`login`（启动动画与登录窗）/ `shell` / `app` 属于 Qt 层。
界面复用 svfs-studio 的主题与基础部件，视觉与 Studio 一致。

测试（逻辑层纯标准库；界面层 offscreen，含一次真实的 WebEngine 冒烟——加载
示例插件并验证桥注入、storage 往返与消息订阅；环境起不来 WebEngine 时自动跳过）。
**入口是 `tests/run_tests.py` 而不是直接 `python -m unittest`**：QtWebEngine 在
offscreen 下的解释器退出路径会崩（测试全绿、退出码却是 139，CI 会判失败），
runner 拿到 unittest 的结果后用 `os._exit` 退出，绕开那段清理；测试真正崩在
中途时退出码照样非 0：

```bash
uv run python tests/run_tests.py                  # 全部（716 条）
uv run python tests/run_tests.py -v               # 逐条打印

# 只跑某一层（同样建议走 runner，参数透传给 unittest discover）
uv run python tests/run_tests.py -p "test_client_logic.py"      # 清单/路径/注册表/安装器/顺序编排（纯层）
uv run python tests/run_tests.py -p "test_client_auth.py"       # 登录认证/网络策略（纯层）
uv run python tests/run_tests.py -p "test_client_credentials.py" # 凭据存储（纯层）
uv run python tests/run_tests.py -p "test_client_messages.py"   # 消息主题/封皮/总线（纯层）
uv run python tests/run_tests.py -p "test_client_mqtt.py"       # MQTT 通道（含自建 broker 端到端）
uv run python tests/run_tests.py -p "test_client_login_ui.py"   # 登录窗/启动动画/桥注入
uv run python tests/run_tests.py -p "test_client_ui.py"         # 主窗口/协议层/WebEngine 冒烟
```


## 数据能力

### 内容寻址与去重

文件内容不放在 `files` 表里，而是存进 `blobs` 表，主键是 `sha256(原始字节)`，
所以**相同内容天然只存一份**：

```python
vfs.add_file("copy1/logo.png", "copy1/logo.png")
vfs.add_file("copy2/logo.png", "copy2/logo.png")
# blobs 表里只有一行，两个路径的 content_hash 相同
```

去重是按内容算的，与路径无关，所以这些场景都省空间：同一个文件出现在多个目录、
多个快照共享没变过的文件、重新打包一棵没变的树（内容命中的 blob 连压缩都跳过）。

代价是**引用计数语义**：删掉一个路径不会立刻删掉内容，只有没有任何快照再引用它时
才会被回收（`gc_blobs()`，在 `remove()` / `clear_snapshot()` / `delete_snapshot()`
里自动执行）。

### 快照与版本历史

一个库里可以装同一棵树的历史版本。`files` 表的唯一约束是
`UNIQUE(snapshot_id, path)`，所以同一路径在不同快照里各有一行：

```python
vfs = SQLiteVFS("history.svfs")
vfs.add_vfs_metadata("my-app")

# 第一版
vfs.create_snapshot("v1")          # 或直接写入，会自动建默认快照
vfs.add_file("dist/app.js", "app.js")
vfs.recount()

# 第二版：从 v1 派生（只复制元数据行，内容 blob 共享，所以几乎不占空间）
vfs.create_snapshot("v2", parent_id=1, copy_entries=True)
vfs.remove("app.js")
vfs.add_file("dist/app-v2.js", "app.js")
vfs.recount()

vfs.use_snapshot(1)                # 切回第一版
vfs.read_file("app.js")            # 拿到的是第一版的内容

for snapshot in vfs.list_snapshots():
    print(snapshot["id"], snapshot["name"], snapshot["digest"])
```

配合增量打包就是一个完整的版本流程：

```bash
vfspack ./dist app.svfs --compress                 # 第一版
vfspack ./dist app.svfs --compress --snapshot v2   # 新版本，只写入变化的部分
vfsunpack app.svfs ./restore --snapshot 1          # 按版本导出
```

### 完整性校验与签名

每个文件都带 `sha256`，每个快照都有一份摘要（对"按 path 排序后的全部条目"做 sha256，
字段包括路径、类型、大小、时间戳、权限、内容哈希）：

```python
report = vfs.verify()                      # 元数据级：便宜
report["ok"], report["errors"]

report = vfs.verify(check_content=True)    # 权威级：逐文件重算 sha256
report["content_checked"]

vfs.sign("my-secret", key_id="release-1")  # HMAC-SHA256
vfs.verify_signature("my-secret")["ok"]
```

**两个层级必须分清**：

* `verify()` 只看元数据。同大小的内容被替换时它**发现不了**（因为 `file_size` 没变）。
* `verify(check_content=True)` 会重算每个文件的 sha256，能发现任何内容改动，代价是读一遍全部数据。

`vfsverify` 的退出码可以直接用于 CI：校验失败或验签失败都返回非 0。

签名用标准库的 HMAC-SHA256，是**对称**的——验证方必须持有同一个密钥。
需要非对称信任链（谁都能验、只有持有私钥的人能签）时，用 `--json` 把摘要交给
GPG / cosign 之类的外部工具签名。

另外要注意摘要的语义：**走 API 的写入会在 `recount()` 时刷新摘要**，所以摘要防的是
"绕过 API 的改动"。要当防篡改凭证用，请在打包完成后立刻签名或把摘要记到库外。

### 增量打包

```bash
vfspack ./dist app.svfs --compress --incremental
```

只写入变化的条目，磁盘上已消失的路径会被删除，并汇报四个计数：

```
增量结果: 新增 1，更新 1，未变 1，删除 1
```

变更判定默认用 `mtime + size`：不读文件内容，所以很快，代价是**理论上会漏掉
"同一秒内、同样大小"的修改**（这类场景请用 `--rehash`，它按 sha256 逐个文件比对）。

未变的文件不会被重写、重压或重新存储；内容相同的新路径会直接复用已有 blob。

### 发布到对象存储（按 hash 取对象）

`.svfs` 是一个文件，但**不必**把它当"远程文件系统"用。更简单也更快的一条路：
既然库里的内容本来就按 sha256 寻址，就把每个 blob 直接导出成一个以 hash 命名的对象，
再把目录结构导出成一份小清单。

```bash
# 导出成「对象 + 清单」
vfspublish ./app.svfs ./public

# 同步到对象存储 / 静态托管（任选其一）
aws s3 sync ./public s3://my-bucket/app --cache-control "public, max-age=31536000, immutable"
rclone copy ./public remote:bucket/app

# 客户端取回（对象默认从清单同级目录取，也可以指向 CDN）
vfsfetch https://cdn.example.com/app/manifest.json ./dist
vfsfetch https://cdn.example.com/app/manifest.json ./dist --path sub/ --workers 16
```

产出结构：

```text
public/
├── blobs/cd/cd8f...e21            # 对象名 = 内容的 sha256，内容是原始（解压后）字节
├── blobs/7a/7a41...09c
└── manifest.json                  # 路径 → {hash, size, mime, mtime, mode}，含空目录
```

这条路带来的性质：

* **可端到端校验**：对象名就是内容的 sha256，客户端逐个核对，CDN 或中间人替换不掉内容
  （把某个对象改成同样长度的别的内容，取回时会被拒绝——有测试覆盖）。
* **缓存可以永久**：内容不可变，对象 URL 天然适合 `max-age=31536000, immutable`。
* **去重跨路径、跨版本生效**：相同内容只有一个对象，多个版本共享没变过的文件。
* **不需要编译扩展**：普通静态托管 / CDN 就能用。
* **清单可复现**：里面不放"生成时间"（用快照的创建时间），所以同一快照导出两次得到完全一样的清单，
  可以 diff、可以签名。

实测对比（1108 KB 的包、61 个文件、真实 HTTP 服务器）：

| 操作 | 按 hash 取对象 | 远程 VFS 按页读（APSW） |
|---|---|---|
| 列目录 | **1 次请求 / 13 KB**（清单，gzip 后 0.34 KB） | 5 次请求 / 20 KB |
| 取一个 0.8 KB 文件 | **1 次请求 / 0.8 KB**（精确等于文件大小） | 9 次请求 / 36 KB |
| 取一个 1 MB 文件 | 1 次请求 / 1 MB | 265 次请求 / 1060 KB（不做预读，约 13 秒 @50ms RTT） |

对"发布一个资源包"这个场景，对象方案在请求数和字节数上都好一个数量级，而且不引入编译依赖。

**远程 VFS（按页读 SQLite）真正的用武之地是另一种需求**：一个很大、结构复杂的数据库，
客户端只随机查其中很少一部分。那种情况下"按页取"才有意义，代价是你得自己实现预读与缓存
（实测不做预读时读 1 MB 要 265 次往返；预读 256 KB 后降到 5 次，字节数几乎不变）。
另外 CPython 的 `sqlite3` 没有注册自定义 VFS 的 API，走这条路必须引入 APSW 之类的编译扩展。

已知边界：

* 清单大小与**文件数**成正比（每文件约 150～200 字节；gzip 后约 5 字节/文件，
  任何 CDN 都会自动对 JSON 压缩）。10 万文件的清单 gzip 后约 0.5 MB——一次性成本，但值得知道。
* `vfsfetch` 取的是**整个文件**。如果你要"取一个 2 GB 文件的中间一段"，
  那属于远程 VFS 的领域，不是这条路的。
* 清单是公开信息（路径、大小、时间戳）。要发布敏感内容，请把对象目录放在需要鉴权的位置。
* `--link` 用硬链接落盘省空间，但**不还原时间戳与权限**：硬链接与缓存对象共用 inode，
  `utime`/`chmod` 会连带改到缓存，破坏"缓存对象不可变"的前提。

## 项目结构
```text
sqlite_vfs/
├── pyproject.toml             # 项目配置、可选依赖（extras）与命令入口
├── src/sqlite_vfs/
│   ├── __init__.py
│   ├── core.py                # 核心 SQLiteVFS 类（存储层 / 快照 / 完整性）
│   ├── compression.py         # 压缩算法分发（zlib / huffman / none）
│   ├── huffman.py             # 哈夫曼实现（仅为读早期版本打的包而保留）
│   ├── migration.py           # v1 / v2 → v3 格式迁移
│   ├── mime.py                # Content-Type 覆盖表（插件与发布工具共用）
│   ├── publish.py             # 按 hash 发布对象 + 清单，以及取回客户端
│   ├── folder_packer.py       # 打包逻辑（全量 / 增量 / 新快照）
│   ├── folder_unpacker.py     # 解包逻辑（支持指定快照）
│   ├── contrib/
│   │   ├── __init__.py
│   │   └── fastapi_static.py  # 可选：FastAPI 静态文件服务插件
│   └── cli/
│       ├── packer.py          # vfspack
│       ├── unpacker.py        # vfsunpack
│       ├── verify.py          # vfsverify（校验 / 签名 / 列快照）
│       ├── migrate.py         # vfsmigrate
│       ├── publish.py         # vfspublish（导出对象 + 清单）
│       └── fetch.py           # vfsfetch（按清单取回并校验）
├── src/svfs_studio/           # 桌面可视化工具（可选 extra: gui）
│   ├── format.py              # [无 Qt] 大小/时间/权限/文本嗅探/hexdump 的显示格式化
│   ├── analysis.py            # [无 Qt] 快照差异、去重与占用汇总、SQLite 内部结构解析
│   ├── worker.py              # 所有库调用都跑在一个专用线程里；进度回调、中断、日志
│   ├── theme.py               # 暗色调色板 + QSS
│   ├── sample.py              # 示例包（与 README 里的演示同一棵树）
│   ├── views/                 # 七个标签页：概览/文件/快照/存储/完整性/内部/工具箱
│   └── widgets/               # 外壳（主窗口）、卡片、自绘图表
├── src/svfs_client/           # 插件化桌面客户端（可选 extra: gui，需要完整版 PySide6）
│   ├── manifest.py            # [无 Qt] plugin.json 清单的读取与校验
│   ├── paths.py               # [无 Qt] 数据目录布局（注册表 / 设置 / 包 / 键值存储 / WebView）
│   ├── settings.py            # [无 Qt] 客户端设置（base_url 的读写、规范化与地址解析）
│   ├── registry.py            # [无 Qt] 已安装插件注册表（JSON 原子写）
│   ├── installer.py           # [无 Qt] 安装 / 升级 / 回滚 / 卸载（复用核心库 API）
│   ├── sample.py              # [无 Qt] 示例插件生成器（完整前端 + 桥能力演示）
│   ├── scheme.py              # svfs:// 协议：从包内直接服务文件，host=插件id 实现隔离
│   ├── bridge.py              # 受限 QWebChannel 桥（storage / net / notify / title，按权限把关）
│   ├── webview.py             # PluginView + 导航守卫 + window.svfs 注入
│   ├── worker.py              # 后台线程服务（request-id 分发、进度、独占 op）
│   └── shell.py               # 主窗口：侧栏 + 插件管理页 + 每插件一个常驻 WebView
├── tests/                     # 单元与集成测试（纯标准库 unittest）
└── README.md
```
## 核心组件
### SQLiteVFS 类
主要功能类，提供以下功能：

- 创建和管理 SQLite 虚拟文件系统数据库
- 添加/删除/移动文件和目录，读写文件内容
- 内容寻址存储与去重（`get_blob_info` 可查看某个文件对应哪份 blob）
- 快照管理：`create_snapshot` / `use_snapshot` / `list_snapshots` / `delete_snapshot`
- 完整性：`compute_digest` / `verify` / `sign` / `verify_signature`
- 导出文件或整个目录树到本地文件系统
- 压缩和解压缩文件内容
- 可以只读打开（`readonly=True`），适合把资源包放在只读介质上服务

### FolderPacker 类
文件夹打包工具：

- 遍历本地文件夹结构
- 将文件和目录信息存储到 SQLite 数据库
- 支持文件排除模式（glob 语义，按目录剪枝）
- 支持全量与增量打包，以及"打成新快照"的版本流程

### FolderUnpacker 类
文件夹解包工具：

- 从 SQLite 数据库恢复文件系统（可指定快照）
- 验证数据库完整性
- 支持部分导出和完整导出

## 数据库结构
### filesystem_metadata 表
整包信息：名称、创建时间、格式版本（`schema_version`）。

### snapshots 表
每个版本的记录：名称、创建时间、父快照（`parent_id`）、文件数、总大小、摘要（`digest`）。

### blobs 表
内容寻址存储，**相同内容只存一份**：

- hash: `sha256(原始字节)` 的十六进制，主键
- size: 原始（未压缩）字节数
- stored_size: 实际占用字节数（含 huffman 频率表）
- content: 压缩后的数据（未压缩时就是原始字节）
- freq_blob: 哈夫曼频率表（1024 字节 = 256 个 uint32 小端），仅 huffman 算法有值
- compress_algo: 压缩算法，'none' / 'zlib'（默认）/ 'huffman'

### signatures 表
快照摘要的签名记录：snapshot_id、algorithm（hmac-sha256）、key_id、signature、signed_time。

### files 表
存储所有目录项（含目录本身），唯一约束是 `(snapshot_id, path)`：

- snapshot_id: 属于哪个快照
- path / name / parent_path: 路径与层级
- is_directory: 是否为目录
- file_size: 原始文件大小
- created_time / modified_time: 时间戳
- permissions: 文件权限
- content_hash: 指向 `blobs.hash`；目录为 NULL

文件内容不在这个表里——这样的拆分让"同一份内容被多个路径/多个快照引用"成为可能，
也让 `get_file_info` / `exists` / `list_directory` 这些元数据查询不再把 BLOB 读进内存。

## 开发
### 设置开发环境
```bash
# 安装开发依赖
uv sync --dev

# 运行测试（测试是纯标准库 unittest，没有引入 pytest；入口见 tests/run_tests.py
# 的说明——QtWebEngine 的解释器退出路径会崩，直接 discover 会拿到非零退出码）
uv run python tests/run_tests.py

# 只跑界面测试（offscreen，不需要显示器；未装含 GUI 的 extra 时会整体跳过）
uv sync --extra gui
uv run python tests/run_tests.py -p "test_studio_ui.py" -v

# 构建包
uv build
```
### 代码示例
```python
from sqlite_vfs.core import SQLiteVFS

# 创建文件系统（open 时已自动连接，不必再调 connect()）
vfs = SQLiteVFS("my_files.db", compress=True)

# 获取文件系统统计信息
stats = vfs.get_stats()
print(f"文件系统: {stats['name']}")
print(f"文件数量: {stats['total_files']}")

# 列出目录内容
for item in vfs.list_directory("/"):
    print(f"{'DIR' if item['is_directory'] else 'FILE'}: {item['name']}")

vfs.close()
```
## 性能说明
- 压缩默认使用 zlib（标准库，C 实现），压缩级别为 6（平衡压缩比和速度）
- 大于等于 64 字节的文件才尝试压缩；压不动的（随机数据）自动回退为原样存储
- 每个响应命中 `ETag` / `Last-Modified` 条件请求时返回 304，不需要读内容也不需要解压
- 文件索引优化，支持快速路径查找
- 各算法的实测对比见上文「要不要用 `--compress`？」
- `verify()` 是元数据级、很便宜；`verify(check_content=True)` 要读一遍全部内容

> 读取大文件时，`read_file` 会整块读入内存再切片，因此超大媒体文件（几百 MB 级以上）
> 不适合放进资源包；`Range` 请求虽然语义正确，但当前实现是"整份解压后再切片"，
> 成本与整份读取相同。

## 注意事项
1. 文件路径统一使用正斜杠（/）存储，与操作系统无关
2. 时间戳按**本机本地时间**存储（`datetime.fromtimestamp`），不是 UTC；跨时区搬运后时间含义会变
3. 文件权限使用 Unix 权限位表示
4. 数据库文件可以在不同平台间迁移（当前格式为 schema 3，旧库用 `vfsmigrate` 升级）
5. 大文件处理建议启用压缩以减少数据库大小
6. 内容是**按 sha256 去重共享**的：删掉一个路径后，只有再无任何快照引用时内容才会被回收
7. 快照摘要会被走 API 的写入刷新，所以它防的是"绕过 API 的改动"；当防篡改凭证用请配合签名

## 许可证
MIT License

## 贡献
欢迎提交 Issue 和 Pull Request 来改进项目。

