Files
svfs/src/svfs_client/sample.py
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

424 lines
16 KiB
Python
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.
#!/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)