Files
svfs/README.md
T
chakcy e0d5bad7b2 feat: 消息模块(MQTT 推送 + 统一总线),并补退出登录/顺序编排/设置分页签
一、消息模块:服务端主动推送 + 插件定向分发
* topics.py:主题方案 <prefix>/<org>/user/<user>/message、.../user/<user>/plugin/<id>、
  .../broadcast/{message,plugin/<id>}。org 是租户位(多租户上线不用改格式,broker
  按 <prefix>/<org>/# 下发 ACL 即可隔离);用户名/插件 id 经**可逆**百分号编码占
  一个层级,客户端与示例服务端共用同一套拼主题函数;
* bus.py:统一消息总线——按**主题**分类(主机消息进消息中心、插件消息不进,仪表盘
  数据那类业务载荷永远不会混进给人看的列表)、归属校验(org/user 不符即丢弃,broker
  ACL 配错也挡得住)、按 id 去重(QoS 1 重投与"推送/补齐重叠"都不会重复显示)、
  插件消息按插件缓存 50 条,等页面打开后一次性补投;
* mqtt.py + mqtt_service.py:paho 适配(可选依赖 [mqtt])+ Qt 适配(paho 的网络
  线程经排队信号把消息交给主线程的总线);重连交给 paho(指数退避),重连成功
  自动重新订阅;没装 paho / 没配 broker 都只是"没有实时推送",HTTP 补齐照常;
* worker.py:登录后**先**补齐(GET /api/messages?since=)再连 MQTT,两条路进同一
  总线;在途结果带身份快照,退出登录/换账号后丢弃;
* 界面:侧栏 ✉ 未读徽标 + 消息中心(级别/标题/时间/来源/广播标记、立即同步、
  全部已读、清空、通道状态灯);warn/error 顺手发系统托盘通知;
* 插件侧:新权限 messages(PLUGIN_API_VERSION→5),页面里 svfs.messages.subscribe();
  宿主经 window.__svfsPush 投递,早到的先排队、订阅时补发(桥调试台已接上演示);
* examples/plugin_server.py:/api/messages、/api/push(kind=message|plugin)、
  /api/broadcast;配了 SVFS_MQTT_HOST 就真发布到 broker,并把 broker 地址随登录
  响应下发(/api/login 与 /api/me 都带),客户端不用手填。

二、退出登录 = 关掉主窗口 + 回到登录界面
* 强制登录下"未登录的主窗口"本就不该存在:重新登录成功用新会话开回主窗口,关掉
  登录窗即退出客户端;退出时断开推送、清掉该账号收到的消息与总线身份(下一个人
  不该看到上一个人的消息),并收掉已打开的插件页面;
* 用户中心的"退出登录"先关掉自己再进登录门,避免模态窗口叠模态。

三、插件顺序编排
* installer.arrange():编排过的在前、未编排的按名称;管理页卡片加 ↑/↓,侧栏/图标栏/
  管理页三者一致;存 settings 的 plugin_order,只影响显示(不动包与注册表);
* "启动时打开"的默认规则改为"显示顺序里第一个启用的插件"(原来是按 id 排);
* 新增「恢复默认顺序」(编排过才出现)。

四、修复
* 收缩侧栏后页面左上角露出一块纯白方块:页面栈对 Qt 是透明的(主题只给
  QMainWindow/QDialog 设了背景),侧栏让出的那条在重绘时序里露白。给 QStackedWidget
  不透明底色,并加像素级回归用例(撤掉修复即 FAIL,实测 1237 个白像素);
* build_opener() 每次都要隐式枚举 Windows 证书库:全量测试跑几百次后以断点异常
  崩在证书枚举里(faulthandler 定位在 _load_windows_store_certs)。改成进程内只建
  一次 SSL context、启动时在主线程预建;
* 在途的消息补齐结果会喂给新会话(退出登录时用户名已空 → 拼主题抛异常,被 Qt 回调
  静默吞掉):带身份快照,不匹配即丢弃。

五、设置界面按类型分页签(插件 / 服务器 / 存储 / 网络 / 诊断)
* 每页内容在滚动区里(窗口拉小或以后再加项是滚动条,而不是把控件压扁);
* 新增「消息推送(MQTT)」一节:broker(host / host:port / mqtts://host:8883)、组织、
  端口、TLS、主题前缀、broker 账号,保存即重连;状态行显示**生效**配置与来源
  (手配 / 服务端下发)。broker 密码不进界面:默认用登录 token(已加密存储),需要
  单独的密码时由服务端下发——与"凭据不明文落盘"保持一致。

测试与验证
* 新增 tests/test_client_messages.py(主题/封皮/总线,纯层)、tests/test_client_mqtt.py
  (装配层 + 自建最小 broker 的端到端,不依赖 mosquitto)、test_plugin_server.py 的
  消息接口与"/api/push → broker → 客户端总线"闭环;WebEngine 冒烟里加了页面级
  messages 探针;
* **测试入口改为 tests/run_tests.py**:QtWebEngine 在 offscreen 下的解释器退出路径会
  崩(测试全绿但退出码 139,CI 只看退出码就会判失败),runner 按 unittest 的结果
  os._exit 绕开那段清理;测试真崩在中途时退出码照样非 0(用故意失败的用例验证过)。
  README 与 CI 都切到这个入口;
* 全量 720 条通过(6 skip),exit 0。
2026-09-17 01:06:02 +08:00

1078 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 路径写错会当场报错);
* **诊断**:诊断日志落盘开关、导出诊断报告;
* 侧栏底部的 **✉ 消息入口**(带未读数字)进「消息中心」:服务端推来的主机
消息按时间倒序列出(级别、标题、时间、来源、广播标记),顶部是 MQTT 通道
状态灯与「立即同步 / 全部已读 / 清空」——进页面即算已读,徽标不会一直挂着;
* 侧栏底部的圆形头像(登录用户首字母)点击进「用户中心」:当前登录
状态、服务器地址、本次登录时间,以及退出登录 / 重新登录;
* **退出登录 = 关掉主窗口并回到登录界面**:客户端是强制登录的,"未登录的
主窗口"本就不该存在,所以退出后不会留一个没有凭据的界面继续用。已打开的
插件页面会一并收掉(插件的键值存储按插件隔离、不区分用户,页面状态同理,
换账号重进时重新加载);重新登录成功则用新会话把主窗口开回来,用户关掉
登录窗则退出客户端。退出登录时也会尽力通知服务器作废 token;
* 插件页面底色固定为主题底色;WebEngine 在启动阶段预热(Profile 提前
创建 + 隐藏预热视图 + 共享 OpenGL 上下文)——首次打开插件不再闪白屏
或整窗闪烁。
### 可靠性与诊断
* **插件更新检查**:插件管理页「检查更新」逐个比对服务器上的快照名与
本地版本,有新版就在卡片上显示「可升级 → 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}`,纯字符串也当正文;
* **插件消息**不进消息中心:服务端发什么插件就收到什么(仪表盘数据、行情、
位号刷新这类业务载荷,补历史没有意义),宿主只负责按插件 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、连不上,都只是"没有实时推送"——界面在消息中心显示状态
(未启用 / 连接中 / 已连接 / 连接失败),补齐照常工作;
* 断线重连交给 paho(指数退避 1→30 秒),重连成功后自动重新订阅;
* 连接状态、每个通道的收发与丢弃计数都进诊断日志(含 `forbidden`/`dropped`)。
### 发布 → 安装的完整链路
发布端用现成的 `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 / 原生对话框),
开发自己的插件时用来对照验证真实桥行为;
* **Vue 开发模板**:`examples/vue-plugin-template/` —— Vue 3 + Vite 起步
模板,含 `src/svfs.js`(Promise 封装)、`src/mock-svfs.js`(浏览器 dev
模式下的宿主模拟器,`npm run dev` 不进客户端也能迭代 UI)与每类能力
一个的面板组件;`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 装回"的完整闭环)。
### 数据目录
```text
<data>/ Windows: %LOCALAPPDATA%/svfs-client;其它: ~/.svfs-client
├── registry.json 已安装插件注册表(enabled、当前快照、来源…)
├── settings.json 客户端设置(服务器地址、网络、日志开关…)
├── 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 来改进项目。