Files
svfs/src/svfs_client/sample.py
T

424 lines
16 KiB
Python
Raw Normal View History

#!/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)