Skip to main content

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 路径写错会当场报错);
    • 诊断:诊断日志落盘开关、导出诊断报告;
  • 侧栏底部的 ✉ 消息入口进「消息中心」:有未读时图标右上角挂一个红点,红点里是未读 条数(超过 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 把关, 没声明的能力调用直接失败:

<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},纯字符串也当正文;正文可以写成 Markdown——列表里显示剥掉记号的一行摘要,详情页按 Markdown 渲染;
  • 插件消息不进消息中心:服务端发什么插件就收到什么(仪表盘数据、行情、 位号刷新这类业务载荷,补历史没有意义),宿主只负责按插件 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、连不上,都只是"没有实时推送"——界面在消息中心显示状态 (未启用 / 连接中 / 已连接 / 连接失败),补齐照常工作;
  • 推送不可用时自动补齐:上面那几种情况下 MQTT 状态不会是"已连接",客户端 每 30 秒自动问一次 GET /api/messages,消息照样到(不用手点「立即同步」); 连上推送就停轮询——推送更及时,也省得白跑请求;
  • 新消息有提示:系统托盘通知 + 应用内横幅(在窗口顶部,任何页面都看得见, 几秒后自动消失,点「查看」进消息中心)。推送与补齐两条路都会提示,级别只影响 图标与配色;登录时一次补齐很多条就只弹一条汇总,不刷屏;
  • 断线重连交给 paho(指数退避 1→30 秒),重连成功后自动重新订阅;
  • 连接状态、每个通道的收发与丢弃计数都进诊断日志(含 forbidden/dropped)。

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

发布 → 安装的完整链路

发布端用现成的 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 / 原生对话框 / 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" 即换成自己的账号表。插件下载与上传保持 开放/沿用原有令牌机制,不受登录影响。

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 的「4.6 插件消息」。

数据目录

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

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

Metadata

Release files for sqlite-vfs 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sqlite-vfs 0.4.0
File Size Uploaded
sqlite_vfs-0.4.0.tar.gz 741.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for sqlite-vfs 0.4.0
File Interpreter ABI Platform
sqlite_vfs-0.4.0-py3-none-any.whl Python 3 none any Details

Total release size: 835.8 kB

Release files / sqlite_vfs-0.4.0.tar.gz

Download URL sqlite_vfs-0.4.0.tar.gz
Size 741.0 kB
Tags Source
SHA-256 checksum
How to use checksums
970cc30d0cd16b3bc9f02b08af37eb8fcf89523b6c17740a86cd9cc260d544a4
BLAKE2b-256 checksum
How to use checksums
1ab2d4d1dcdbf13d9a9884b30d5cab90d518d86e5f1ec3531acddfbe8ebcdd1a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.12

Release files / sqlite_vfs-0.4.0-py3-none-any.whl

Download URL sqlite_vfs-0.4.0-py3-none-any.whl
Size 94.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d2584e8d1cf5908f30aea6db6b95eaa01c62be426bde41e5ff7d226d40128a31
BLAKE2b-256 checksum
How to use checksums
379f5b32146512efc290df1a7c654f603317346d17b04684ea55dff19affe18d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.12

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 release files

0.3.0

1 release file

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page