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
2026-09-14 00:58:53 +08:00
2026-01-23 17:50:27 +08:00

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 提供,核心不依赖任何第三方框架

安装

从源码安装

# 克隆仓库
git clone <repository-url>
cd svfs

# 使用 uv 安装(推荐)
uv sync

# 或者使用 pip
pip install -e .

使用 uv

项目使用 uv 作为包管理器和构建工具,确保已安装 uv:

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

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

使用方法

命令行入口:svfs <子命令>(推荐)

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

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   # 每个子命令的完整参数

典型的发布/取回链路:

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 子命令。

打包文件夹

# 基本用法
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

解包文件系统

# 解包整个文件系统
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

校验完整性

# 元数据级校验:摘要是否被改动、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 不会导入任何第三方框架。

pip install "sqlite-vfs[fastapi]"

# 打包前端构建产物(不需要 --compress 时见下方性能说明)
vfspack ./dist static.svfs
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"
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 逐字节一致。

# 安装(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 库,这点骗不了人。

想要一份界面截图用于文档或评审(不需要显示器):

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

界面测试(offscreen 渲染,含像素级暗色检查、预览内容与库字节比对、中断语义):

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 校验。

安装与体验

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 命令存在而模块缺失。

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:

{
  "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 把关, 没声明的能力调用直接失败:

<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 即可隔离):

<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 时不用担心"消息是不是已经来过了"。

// 插件侧(清单里声明 "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 校验):

# 插件开发机:打包 + 发布到静态托管
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" 即换成自己的账号表。插件下载与上传保持 开放/沿用原有令牌机制,不受登录影响。

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 装回"的完整闭环)。

数据目录

<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:

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(原始字节), 所以相同内容天然只存一份:

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),所以同一路径在不同快照里各有一行:

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"])

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

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

完整性校验与签名

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

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 的改动"。要当防篡改凭证用,请在打包完成后立刻签名或把摘要记到库外。

增量打包

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

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

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

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

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

发布到对象存储(按 hash 取对象)

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

# 导出成「对象 + 清单」
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

产出结构:

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 会连带改到缓存,破坏"缓存对象不可变"的前提。

项目结构

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 读进内存。

开发

设置开发环境

# 安装开发依赖
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

代码示例

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 来改进项目。

S
Description
基于 sqlite 的虚拟文件系统
Readme MIT
1.3 MiB
Languages
Python 100%