一、消息模块:服务端主动推送 + 插件定向分发
* 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。
424 lines
16 KiB
Python
424 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""桥调试台插件生成器(纯标准库)。
|
||
|
||
设置 → 「安装调试台插件」一键安装:现场造一个"完整前端"插件包——manifest +
|
||
HTML/JS/CSS/图标,把桥的全部能力做成可点击的调试面板(host.info / storage /
|
||
net / notify / title / 原生对话框),插件开发时用来验证 window.svfs 的实际
|
||
行为。``build(out_db)`` 第一次生成 1.0.0;对同一个 out_db 再调会走与真实
|
||
升级完全相同的流程(派生快照 + 增量打包),正好演示"快照即版本"与回滚。
|
||
|
||
前端通过宿主注入的 ``window.svfsReady``(Promise)拿桥;在客户端外打开时
|
||
给出友好提示,不会白屏。
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import shutil
|
||
import tempfile
|
||
|
||
from sqlite_vfs.core import SQLiteVFS
|
||
from sqlite_vfs.folder_packer import FolderPacker
|
||
|
||
from . import PLUGIN_API_VERSION, installer
|
||
|
||
__all__ = ["PLUGIN_ID", "write_source", "build"]
|
||
|
||
PLUGIN_ID = "com.svfs.demo"
|
||
|
||
#: 版本 → 说明(2.0.0 有可感知的变化,用来演示升级与回滚)
|
||
CHANGELOG = {
|
||
1: "初始版本",
|
||
2: "v2:新增主题色切换、消息订阅与变更记录",
|
||
}
|
||
|
||
|
||
def _manifest(version):
|
||
return {
|
||
"id": PLUGIN_ID,
|
||
"name": "SVFS 桥调试台",
|
||
"version": f"{version}.0.0",
|
||
"api_version": PLUGIN_API_VERSION,
|
||
"entry": "index.html",
|
||
"icon": "icon.svg",
|
||
"permissions": ["storage", "notify", "title", "net", "dialog", "messages"],
|
||
"description": "演示/调试客户端桥的全部能力(host.info / storage / net / notify / title / 原生对话框)。",
|
||
}
|
||
|
||
|
||
_ICON_SVG = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||
<rect x="4" y="4" width="56" height="56" rx="14" fill="#22262f"/>
|
||
<path d="M20 34 L29 43 L45 23" stroke="#5b9dff" stroke-width="6"
|
||
fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
"""
|
||
|
||
_STYLE_CSS = """\
|
||
:root {
|
||
--bg: #16181d; --panel: #1c1f26; --border: #2b3038;
|
||
--text: #e4e7ec; --muted: #9aa3b2; --accent: #5b9dff;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0; padding: 28px; background: var(--bg); color: var(--text);
|
||
font-family: "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
|
||
}
|
||
main { max-width: 640px; margin: 0 auto; }
|
||
header { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; }
|
||
h1 { font-size: 20px; margin: 0; }
|
||
#version { color: var(--muted); margin: 2px 0 0; font-size: 13px; }
|
||
.card {
|
||
background: var(--panel); border: 1px solid var(--border);
|
||
border-radius: 8px; padding: 14px 16px; margin-bottom: 12px;
|
||
}
|
||
.card h2 { font-size: 13px; color: var(--muted); margin: 0 0 10px; font-weight: 600; }
|
||
dl { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; margin: 0; }
|
||
dt { color: var(--muted); } dd { margin: 0; font-family: Consolas, monospace; font-size: 12px; }
|
||
b { color: var(--accent); font-size: 16px; }
|
||
button {
|
||
background: #22262f; color: var(--text); border: 1px solid #3a4150;
|
||
border-radius: 6px; padding: 6px 14px; cursor: pointer; margin-right: 8px;
|
||
}
|
||
button:hover { border-color: var(--accent); color: var(--accent); }
|
||
.row { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; align-items: center; }
|
||
input, select, textarea {
|
||
background: #14171c; color: var(--text); border: 1px solid #3a4150;
|
||
border-radius: 6px; padding: 6px 8px; font-family: Consolas, monospace; font-size: 12px;
|
||
}
|
||
textarea { width: 100%; min-height: 54px; resize: vertical; }
|
||
pre {
|
||
background: #14171c; border: 1px solid var(--border); border-radius: 6px;
|
||
padding: 8px 10px; font-family: Consolas, monospace; font-size: 12px;
|
||
white-space: pre-wrap; word-break: break-all; margin: 8px 0 0; min-height: 18px;
|
||
}
|
||
.note { color: var(--muted); font-size: 12px; }
|
||
#missing { border-color: #6b552a; color: #f5b544; }
|
||
::-webkit-scrollbar {{ width: 10px; height: 10px; }}
|
||
::-webkit-scrollbar-thumb {{
|
||
background: #3a4150; border-radius: 5px; border: 2px solid var(--bg);
|
||
}}
|
||
::-webkit-scrollbar-thumb:hover {{ background: #6b7382; }}
|
||
::-webkit-scrollbar-track, ::-webkit-scrollbar-corner {{ background: transparent; }}
|
||
"""
|
||
|
||
_INDEX_TEMPLATE = """\
|
||
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>SVFS 桥调试台</title>
|
||
<link rel="stylesheet" href="style.css">
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<header>
|
||
<img src="icon.svg" width="44" height="44" alt="icon">
|
||
<div>
|
||
<h1>SVFS 桥调试台</h1>
|
||
<p id="version">正在连接宿主…</p>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="card" id="missing" hidden>
|
||
这个页面不在客户端里打开(没有检测到 svfs 桥)。
|
||
在 SVFS Client 中安装并启用本插件即可看到完整功能。
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>宿主信息(host.info,无需权限)</h2>
|
||
<dl id="host-info"><dt>加载中…</dt></dl>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>存储(storage 权限,按插件隔离)</h2>
|
||
<p>这个插件被打开了 <b id="count">…</b> 次</p>
|
||
<button id="reset">计数清零</button>
|
||
<button id="add10">再加 10 次</button>
|
||
<div class="row">
|
||
<input id="kv-key" placeholder="键,如 greeting" size="14">
|
||
<input id="kv-value" placeholder="值(写入用)" size="18">
|
||
<button id="kv-get">读取</button>
|
||
<button id="kv-set">写入</button>
|
||
<button id="kv-del">删除</button>
|
||
<button id="kv-keys">列出全部键</button>
|
||
</div>
|
||
<pre id="storage-out" class="note">(操作结果显示在这里)</pre>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>接口服务(net 权限,经宿主请求服务器 API,自动附带登录 token)</h2>
|
||
<div class="row">
|
||
<select id="net-method">
|
||
<option>GET</option><option>POST</option>
|
||
<option>PUT</option><option>DELETE</option>
|
||
</select>
|
||
<input id="net-path" value="/api/ping" size="24">
|
||
<button id="net-send">发送请求</button>
|
||
</div>
|
||
<textarea id="net-body" placeholder="POST/PUT 的 body(字符串)"></textarea>
|
||
<pre id="net-out" class="note">未请求。相对 path 会拼在客户端配置的服务器地址后。</pre>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>消息(messages 权限,服务端主动推送)</h2>
|
||
<p class="note">服务端按插件 id 定向推送的消息;这类消息不进宿主的消息中心。</p>
|
||
<div class="row">
|
||
<button id="msg-listen">开始订阅</button>
|
||
<button id="msg-clear">清空记录</button>
|
||
<span class="note">已收 <b id="msg-count">0</b> 条</span>
|
||
</div>
|
||
<pre id="msg-out" class="note">未订阅。</pre>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>通知 / 标题(notify / title 权限)</h2>
|
||
<button id="notify">发一条原生通知</button>
|
||
<button id="paint">随机换个标题颜色</button>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>原生对话框(dialog 权限,宿主弹出,Promise 返回用户选择)</h2>
|
||
<div class="row">
|
||
<button id="dlg-confirm">confirm(确定 / 取消)</button>
|
||
<button id="dlg-info">message · info</button>
|
||
<button id="dlg-warn">message · warn</button>
|
||
<button id="dlg-error">message · error</button>
|
||
</div>
|
||
<pre id="dialog-out" class="note">(用户的选择显示在这里)</pre>
|
||
<p class="note" id="changelog"></p>
|
||
</section>
|
||
</main>
|
||
<script src="app.js"></script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
_APP_JS_TEMPLATE = """\
|
||
/* 桥调试台的前端逻辑:全部能力都走宿主注入的 window.svfs(Promise)。 */
|
||
"use strict";
|
||
|
||
var $ = function (id) { return document.getElementById(id); };
|
||
var PLUGIN_VERSION = "__PLUGIN_VERSION__";
|
||
|
||
function randomColor() {
|
||
var letters = "0123456789abcdef", out = "#";
|
||
for (var i = 0; i < 6; i++) out += letters[Math.floor(Math.random() * 16)];
|
||
return out;
|
||
}
|
||
|
||
function show(id, text) {
|
||
$(id).textContent = text;
|
||
$(id).classList.remove("note");
|
||
}
|
||
|
||
async function main() {
|
||
if (!window.svfsReady) {
|
||
$("missing").hidden = false;
|
||
$("version").textContent = "插件 " + PLUGIN_VERSION + "(未连接宿主)";
|
||
return;
|
||
}
|
||
var svfs = await window.svfsReady;
|
||
|
||
var info = await svfs.host.info();
|
||
$("version").textContent = "插件 " + PLUGIN_VERSION + " · 桥 API v" + info.apiVersion;
|
||
$("host-info").innerHTML = "";
|
||
[["插件 id", info.pluginId], ["宿主", info.appName + " " + info.appVersion],
|
||
["平台", info.platform], ["登录用户", info.authUser || "(未登录)"],
|
||
["服务器", info.baseUrl || "(未配置)"]]
|
||
.forEach(function (row) {
|
||
var dt = document.createElement("dt"); dt.textContent = row[0];
|
||
var dd = document.createElement("dd"); dd.textContent = row[1];
|
||
$("host-info").append(dt, dd);
|
||
});
|
||
|
||
// ---- storage:按插件隔离的键值存储 ----
|
||
var count = 0;
|
||
try {
|
||
count = parseInt(await svfs.storage.get("visits"), 10) || 0;
|
||
count += 1;
|
||
await svfs.storage.set("visits", count);
|
||
} catch (e) { /* 没拿到 storage 权限时静默降级 */ }
|
||
$("count").textContent = String(count);
|
||
$("reset").onclick = async function () {
|
||
await svfs.storage.set("visits", 0);
|
||
$("count").textContent = "0";
|
||
svfs.ui.notify("计数已清零");
|
||
};
|
||
$("add10").onclick = async function () {
|
||
count += 10;
|
||
await svfs.storage.set("visits", count);
|
||
$("count").textContent = String(count);
|
||
};
|
||
$("kv-get").onclick = async function () {
|
||
try {
|
||
show("storage-out", "storage.get(" + JSON.stringify($("kv-key").value)
|
||
+ ") → " + JSON.stringify(await svfs.storage.get($("kv-key").value)));
|
||
} catch (e) { show("storage-out", "失败: " + e.message); }
|
||
};
|
||
$("kv-set").onclick = async function () {
|
||
try {
|
||
var raw = $("kv-value").value;
|
||
var value;
|
||
try { value = JSON.parse(raw); } catch (e) { value = raw; }
|
||
await svfs.storage.set($("kv-key").value, value);
|
||
show("storage-out", "storage.set(" + JSON.stringify($("kv-key").value)
|
||
+ ", " + JSON.stringify(value) + ") → 成功");
|
||
} catch (e) { show("storage-out", "失败: " + e.message); }
|
||
};
|
||
$("kv-del").onclick = async function () {
|
||
await svfs.storage.remove($("kv-key").value);
|
||
show("storage-out", "storage.remove(" + JSON.stringify($("kv-key").value) + ") → 成功");
|
||
};
|
||
$("kv-keys").onclick = async function () {
|
||
show("storage-out", "storage.keys() → " + JSON.stringify(await svfs.storage.keys()));
|
||
};
|
||
|
||
// ---- net:宿主代发 HTTP 请求(绕开 CORS,自动附带登录 token)----
|
||
$("net-send").onclick = async function () {
|
||
if (!svfs.net) {
|
||
show("net-out", "这个宿主没有 net 能力(需要客户端支持桥 API v2+)");
|
||
return;
|
||
}
|
||
show("net-out", "请求中…");
|
||
try {
|
||
var request = {
|
||
method: $("net-method").value,
|
||
path: $("net-path").value,
|
||
};
|
||
var body = $("net-body").value;
|
||
if (body && request.method !== "GET" && request.method !== "HEAD") {
|
||
request.body = body;
|
||
request.headers = { "Content-Type": "application/json" };
|
||
}
|
||
var res = await svfs.net.request(request);
|
||
show("net-out", "HTTP " + res.status + "\\n" + (res.bodyText || "(空响应)").slice(0, 600));
|
||
} catch (e) {
|
||
show("net-out", "请求失败: " + e.message
|
||
+ "\\n(先在「设置 → 服务器地址」里配置地址,服务端需提供对应接口)");
|
||
}
|
||
};
|
||
|
||
// ---- messages:订阅服务端推给本插件的消息(需要 messages 权限)----
|
||
var msgCount = 0;
|
||
var msgLines = [];
|
||
$("msg-clear").onclick = function () {
|
||
msgCount = 0; msgLines = [];
|
||
$("msg-count").textContent = "0";
|
||
show("msg-out", "(已清空记录)");
|
||
};
|
||
$("msg-listen").onclick = function () {
|
||
if (!svfs.messages) {
|
||
show("msg-out", "这个宿主没有 messages 能力(需要客户端支持桥 API v5+,"
|
||
+ "且清单里声明 messages 权限)");
|
||
return;
|
||
}
|
||
svfs.messages.subscribe(function (message) {
|
||
msgCount += 1;
|
||
$("msg-count").textContent = String(msgCount);
|
||
var body = message.json ? JSON.stringify(message.json) : message.text;
|
||
msgLines.unshift("[" + msgCount + "] " + body);
|
||
show("msg-out", msgLines.slice(0, 20).join("\\n"));
|
||
});
|
||
show("msg-out", "已订阅,等待服务端推送…(仪表盘数据这类业务载荷走的就是这条路)");
|
||
};
|
||
|
||
// ---- notify / title ----
|
||
$("notify").onclick = function () {
|
||
svfs.ui.notify("来自 " + info.pluginId + " 的通知(" + PLUGIN_VERSION + ")");
|
||
};
|
||
$("paint").onclick = function () {
|
||
svfs.ui.setTitle("SVFS Client · 调试台 " + randomColor());
|
||
};
|
||
|
||
// ---- 原生对话框(dialog 权限):Promise 返回用户是否点了"确定" ----
|
||
$("dlg-confirm").onclick = async function () {
|
||
var accepted = await svfs.ui.confirm({
|
||
title: "确认操作",
|
||
text: "这是一条宿主原生确认框(QMessageBox.question)。",
|
||
detail: "点「确定」返回 true,点「取消」或关闭返回 false。",
|
||
});
|
||
show("dialog-out", "ui.confirm → " + accepted);
|
||
};
|
||
$("dlg-info").onclick = async function () {
|
||
await svfs.ui.message({ title: "提示", text: "这是一条 info 级原生消息框。" });
|
||
show("dialog-out", "ui.message(info) → 已关闭");
|
||
};
|
||
$("dlg-warn").onclick = async function () {
|
||
await svfs.ui.message({ kind: "warn", title: "警告", text: "这是一条 warn 级原生消息框。" });
|
||
show("dialog-out", "ui.message(warn) → 已关闭");
|
||
};
|
||
$("dlg-error").onclick = async function () {
|
||
await svfs.ui.message({ kind: "error", title: "错误", text: "这是一条 error 级原生消息框。" });
|
||
show("dialog-out", "ui.message(error) → 已关闭");
|
||
};
|
||
|
||
$("changelog").textContent = "__CHANGELOG__";
|
||
}
|
||
|
||
main();
|
||
"""
|
||
|
||
|
||
def write_source(dest_dir, version=1):
|
||
"""把示例插件源码树写到一个目录(升级对比/调试时也用得上)。"""
|
||
os.makedirs(dest_dir, exist_ok=True)
|
||
files = {
|
||
"plugin.json": json.dumps(_manifest(version), ensure_ascii=False, indent=2),
|
||
"index.html": _INDEX_TEMPLATE,
|
||
"style.css": _STYLE_CSS,
|
||
"icon.svg": _ICON_SVG,
|
||
"app.js": (
|
||
_APP_JS_TEMPLATE.replace("__PLUGIN_VERSION__", f"{version}.0.0")
|
||
.replace("__CHANGELOG__", CHANGELOG.get(version, ""))
|
||
),
|
||
}
|
||
for name, content in files.items():
|
||
with open(os.path.join(dest_dir, name), "w", encoding="utf-8", newline="\n") as f:
|
||
f.write(content)
|
||
return dest_dir
|
||
|
||
|
||
def build(out_db, *, progress=None):
|
||
"""生成示例插件包;out_db 已存在时按真实升级流程追加快照。
|
||
|
||
版本自动决定:包不存在生成 1.0.0;已存在则升级到 2.0.0(再次生成则在
|
||
2.0.0 上追加 -2 快照……)。worker 的 ``install_sample`` 操作每次都调
|
||
这个(测试与体验用;界面入口已移除)。
|
||
|
||
返回 (out_db, 版本号字符串)。
|
||
"""
|
||
version = 2 if os.path.exists(out_db) else 1
|
||
workdir = tempfile.mkdtemp(prefix="svfs-sample-")
|
||
# out_db 所在目录可能还不存在(首次生成时),建出来
|
||
os.makedirs(os.path.dirname(os.path.abspath(out_db)), exist_ok=True)
|
||
try:
|
||
write_source(workdir, version=version)
|
||
if version == 1:
|
||
vfs = SQLiteVFS(out_db, compress=True)
|
||
try:
|
||
vfs.create_snapshot(f"{version}.0.0")
|
||
vfs.add_vfs_metadata("svfs-plugin:com.svfs.demo")
|
||
packer = FolderPacker(vfs)
|
||
packer.pack_folder(workdir, incremental=True, rehash=True,
|
||
progress=progress)
|
||
vfs.recount()
|
||
finally:
|
||
vfs.close()
|
||
return out_db, f"{version}.0.0"
|
||
# 已有包:走与 installer._upgrade 相同的"派生快照 + 增量"流程
|
||
vfs = SQLiteVFS(out_db, compress=True)
|
||
try:
|
||
parent_id = vfs.get_stats()["snapshot_id"]
|
||
name = installer._unique_snapshot_name(vfs, f"{version}.0.0")
|
||
vfs.create_snapshot(name, parent_id=parent_id, copy_entries=True)
|
||
vfs.add_vfs_metadata("svfs-plugin:com.svfs.demo")
|
||
packer = FolderPacker(vfs)
|
||
packer.pack_folder(workdir, incremental=True, rehash=True,
|
||
progress=progress)
|
||
vfs.recount()
|
||
finally:
|
||
vfs.close()
|
||
return out_db, name
|
||
finally:
|
||
shutil.rmtree(workdir, ignore_errors=True)
|