#!/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 = """ """ _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 = """\ SVFS 桥调试台
icon

SVFS 桥调试台

正在连接宿主…

宿主信息(host.info,无需权限)

加载中…

存储(storage 权限,按插件隔离)

这个插件被打开了 … 次

(操作结果显示在这里)

接口服务(net 权限,经宿主请求服务器 API,自动附带登录 token)

未请求。相对 path 会拼在客户端配置的服务器地址后。

消息(messages 权限,服务端主动推送)

服务端按插件 id 定向推送的消息;这类消息不进宿主的消息中心。

已收 0 条
未订阅。

通知 / 标题(notify / title 权限)

原生对话框(dialog 权限,宿主弹出,Promise 返回用户选择)

(用户的选择显示在这里)

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