feat: 插件化桌面客户端 svfs-client(含凭据/会话/网络/日志的脚手架补齐)

一个插件 = 一个完整的 .svfs 包:客户端把插件前端经 svfs://<插件id>/ 直接加载
进 WebView(不落盘、不起端口、不解包),每个插件一个独立 origin(浏览器按
origin 隔离 localStorage)与独立键值存储;快照即版本——升级 = 派生快照的增量
打包,回滚 = 把注册表指针切回旧快照,包文件一个字节不用动。svfs_client 复用
svfs_studio 的主题与基础部件,故两个包一并入库。

按评估结论补齐的 P0 三块:

* 会话:桥在"宿主注入的 token 被 401 拒绝"时通知壳层,提示一次并给出一键
  「重新登录…」;服务器状态灯把「在线但登录失效」(黄)与「离线」(红)分开,
  不再把凭据失效误报成网络不可达;
* 凭据:新增 credentials.py——token 不再明文进 settings.json。Windows 走 DPAPI
  (密文只有当前用户在本机解得开),其它平台走系统凭据库(可选依赖 keyring),
  都没有才明文兜底并在「设置 → 登录凭据」如实提示;令牌与签发它的 base_url
  绑定(换服务器不会把旧令牌发出去);老字段 auth_token 启动时自动迁移;
* 强登录链路保持:启动动画 → 静默校验 → 登录窗 → 主窗口,不可达时离线宽限。

P1/P2:net 超时与重试(只对幂等方法,避免重发 POST 变两单)可配、代理与
自签 CA 可配(loopback 永远直连);诊断日志滚动落盘(1MB×3,可关)——崩溃后
还能回看现场;客户端自更新检查(静态 <base_url>/client/manifest.json,缺失
即静默);插件清单可选 net_hosts 白名单;notify 改走系统托盘(无托盘降级状态栏);
设置界面新增网络 / 登录凭据 / 诊断日志三块。

examples/:示例服务端(FastAPI,含登录、上传即 publish、客户端版本接口)、
Vue 3 插件模板(带浏览器版宿主模拟器)、svfs-plugins-root/ 为模板配套的演示
插件实体。测试:tests/test_client_*.py(纯层 + Qt 层 + 一次真实 WebEngine 冒烟)、
tests/test_studio_*.py、tests/test_plugin_server.py。
This commit is contained in:
2026-09-16 17:15:48 +08:00
parent 06ec7c38bc
commit f62704ca0e
85 changed files with 23462 additions and 0 deletions
@@ -0,0 +1 @@
dl[data-v-d22dfe76]{display:grid;grid-template-columns:auto 1fr;gap:4px 16px;margin:0}dt[data-v-d22dfe76]{color:var(--muted)}dd[data-v-d22dfe76]{margin:0;font-family:Consolas,monospace;font-size:12px}:root{--bg: #16181d;--panel: #1c1f26;--border: #2b3038;--text: #e4e7ec;--muted: #9aa3b2;--accent: #5b9dff}*{box-sizing:border-box}body{margin:0;padding:24px;background:var(--bg);color:var(--text);font-family:Segoe UI,Microsoft YaHei,system-ui,sans-serif}main{max-width:680px;margin:0 auto}h1{font-size:20px;margin:0 0 14px}nav{display:flex;gap:8px;margin-bottom:16px}nav button{background:var(--panel);color:var(--muted);border:1px solid var(--border);border-radius:6px;padding:6px 14px;cursor:pointer}nav button.active{color:var(--accent);border-color:var(--accent)}.panel{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:12px}.panel h2{font-size:13px;color:var(--muted);margin:0 0 10px}button.act{background:#22262f;color:var(--text);border:1px solid #3a4150;border-radius:6px;padding:6px 14px;cursor:pointer;margin-right:8px}button.act:hover{border-color:var(--accent);color:var(--accent)}input,textarea,select{background:#14171c;color:var(--text);border:1px solid #3a4150;border-radius:6px;padding:6px 8px;font-size:13px}pre{background:#14171c;border:1px solid var(--border);border-radius:6px;padding:8px 10px;font-size:12px;white-space:pre-wrap;word-break:break-all;min-height:18px}.muted{color:var(--muted);font-size:12px}
@@ -0,0 +1,9 @@
{
"id": "com.example.vue-demo",
"name": "Vue 模板插件",
"version": "0.1.0",
"api_version": 4,
"entry": "index.html",
"permissions": ["storage", "notify", "title", "net", "dialog"],
"description": "用 Vue 3 + Vite 开发的 SVFS 插件模板,演示全部桥能力"
}
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vue 模板插件</title>
<script type="module" crossorigin src="./assets/index-Cbfj95Ib.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CAnw4XNC.css">
</head>
<body>
<!-- 挂载点:样式由各组件自行负责(插件页面没有全局样式约束) -->
<div id="app"></div>
</body>
</html>
@@ -0,0 +1,53 @@
{
"blob_dir": "blobs",
"entries": {
"assets": {
"dir": true
},
"assets/index-CAnw4XNC.css": {
"hash": "1252ac1b43e4d94d0a608eed0c76449112bee5f79613b66e6ec8c21b981a4dae",
"mime": "text/css",
"mode": 438,
"mtime": 1789521653,
"size": 1485
},
"assets/index-Cbfj95Ib.js": {
"hash": "770e86ec29471f0553cb3bef36063ebcc6fcf2fc7902ccd5e5d16665a91425ad",
"mime": "text/javascript",
"mode": 438,
"mtime": 1789521653,
"size": 72828
},
"index.html": {
"hash": "8030640addc4c64069ee0d5302b4884b262c0e3069d92db99940edc626538292",
"mime": "text/html",
"mode": 438,
"mtime": 1789521653,
"size": 501
},
"plugin.json": {
"hash": "43fa3c1fed2269b7afa1872274a002ef52e9e4c97eb6c44eecb575928d8e7710",
"mime": "application/json",
"mode": 438,
"mtime": 1789467293,
"size": 287
}
},
"format": "svfs-objects",
"name": "packed_filesystem",
"shard": 2,
"snapshot": {
"created_time": "2026-09-16T09:33:37.202155",
"digest": "b3bd3c62b80df3f97922abca255c16035c6a546d65a7f36b29b4857670179729",
"id": 1,
"name": "packed_filesystem"
},
"stats": {
"directories": 1,
"files": 4,
"objects": 4,
"objects_stored_size": 30200,
"total_size": 75101
},
"version": 1
}
@@ -0,0 +1,15 @@
{
"id": "com.svfs.demo",
"name": "SVFS 演示插件",
"version": "3.0.0",
"api_version": 3,
"entry": "index.html",
"icon": "icon.svg",
"permissions": [
"storage",
"notify",
"title",
"net"
],
"description": "演示插件客户端桥能力的示例(host.info / storage / notify / title / net)。"
}
@@ -0,0 +1,15 @@
{
"id": "com.svfs.demo",
"name": "SVFS 演示插件",
"version": "3.0.0",
"api_version": 2,
"entry": "index.html",
"icon": "icon.svg",
"permissions": [
"storage",
"notify",
"title",
"net"
],
"description": "演示插件客户端桥能力的示例(host.info / storage / notify / title / net)。"
}
@@ -0,0 +1,61 @@
/* 演示插件的前端逻辑:全部能力都走宿主注入的 window.svfs(Promise)。 */
"use strict";
var $ = function (id) { return document.getElementById(id); };
var PLUGIN_VERSION = "1.0.0";
function randomColor() {
var letters = "0123456789abcdef", out = "#";
for (var i = 0; i < 6; i++) out += letters[Math.floor(Math.random() * 16)];
return out;
}
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.source || "-"]]
.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);
};
$("notify").onclick = function () {
svfs.ui.notify("来自 " + info.pluginId + " 的通知(" + PLUGIN_VERSION + ")");
};
$("paint").onclick = function () {
svfs.ui.setTitle("SVFS Client · 演示插件 " + randomColor());
};
$("changelog").textContent = "初始版本";
}
main();
@@ -0,0 +1,79 @@
/* 演示插件的前端逻辑:全部能力都走宿主注入的 window.svfs(Promise)。 */
"use strict";
var $ = function (id) { return document.getElementById(id); };
var PLUGIN_VERSION = "2.0.0";
function randomColor() {
var letters = "0123456789abcdef", out = "#";
for (var i = 0; i < 6; i++) out += letters[Math.floor(Math.random() * 16)];
return out;
}
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.source || "-"]]
.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);
};
$("notify").onclick = function () {
svfs.ui.notify("来自 " + info.pluginId + " 的通知(" + PLUGIN_VERSION + ")");
};
$("paint").onclick = function () {
svfs.ui.setTitle("SVFS Client · 演示插件 " + randomColor());
};
// net:宿主代发 HTTP 请求(不经过浏览器的 CORS)。相对 path 拼在
// 客户端配置的服务器地址(base_url)后;也可以给 {url: "https://…"}。
$("ping").onclick = async function () {
if (!svfs.net) {
$("net-out").textContent = "这个宿主没有 net 能力(需要客户端支持桥 API v2)";
return;
}
$("net-out").textContent = "请求中…";
try {
var res = await svfs.net.request({ method: "GET", path: "/api/ping" });
$("net-out").textContent = "HTTP " + res.status + "\n"
+ (res.bodyText || "(空响应)").slice(0, 300);
} catch (e) {
$("net-out").textContent = "请求失败: " + e.message
+ "\n(先在「插件管理 → 服务器」里配置地址,服务端需提供 /api/ping)";
}
};
$("changelog").textContent = "v2:新增主题色切换与变更记录";
}
main();
@@ -0,0 +1,79 @@
/* 演示插件的前端逻辑:全部能力都走宿主注入的 window.svfs(Promise)。 */
"use strict";
var $ = function (id) { return document.getElementById(id); };
var PLUGIN_VERSION = "3.0.0";
function randomColor() {
var letters = "0123456789abcdef", out = "#";
for (var i = 0; i < 6; i++) out += letters[Math.floor(Math.random() * 16)];
return out;
}
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.source || "-"]]
.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);
};
$("notify").onclick = function () {
svfs.ui.notify("来自 " + info.pluginId + " 的通知(" + PLUGIN_VERSION + ")");
};
$("paint").onclick = function () {
svfs.ui.setTitle("SVFS Client · 演示插件 " + randomColor());
};
// net:宿主代发 HTTP 请求(不经过浏览器的 CORS)。相对 path 拼在
// 客户端配置的服务器地址(base_url)后;也可以给 {url: "https://…"}。
$("ping").onclick = async function () {
if (!svfs.net) {
$("net-out").textContent = "这个宿主没有 net 能力(需要客户端支持桥 API v2)";
return;
}
$("net-out").textContent = "请求中…";
try {
var res = await svfs.net.request({ method: "GET", path: "/api/ping" });
$("net-out").textContent = "HTTP " + res.status + "\n"
+ (res.bodyText || "(空响应)").slice(0, 300);
} catch (e) {
$("net-out").textContent = "请求失败: " + e.message
+ "\n(先在「插件管理 → 服务器」里配置地址,服务端需提供 /api/ping)";
}
};
$("changelog").textContent = "v2:新增主题色切换与变更记录";
}
main();
@@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 272 B

@@ -0,0 +1,14 @@
{
"id": "com.svfs.demo",
"name": "SVFS 演示插件",
"version": "1.0.0",
"api_version": 1,
"entry": "index.html",
"icon": "icon.svg",
"permissions": [
"storage",
"notify",
"title"
],
"description": "演示插件客户端桥能力的示例(host.info / storage / notify / title)。"
}
@@ -0,0 +1,45 @@
<!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>宿主信息</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>
</section>
<section class="card">
<h2>通知与标题(notify / title 权限)</h2>
<button id="notify">发一条原生通知</button>
<button id="paint">随机换个标题颜色</button>
<p class="note" id="changelog"></p>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,28 @@
: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); }
.note { color: var(--muted); font-size: 12px; }
#missing { border-color: #6b552a; color: #f5b544; }
@@ -0,0 +1,15 @@
{
"id": "com.svfs.demo",
"name": "SVFS 演示插件",
"version": "2.0.0",
"api_version": 2,
"entry": "index.html",
"icon": "icon.svg",
"permissions": [
"storage",
"notify",
"title",
"net"
],
"description": "演示插件客户端桥能力的示例(host.info / storage / notify / title / net)。"
}
@@ -0,0 +1,51 @@
<!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>宿主信息</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>
</section>
<section class="card">
<h2>接口服务(net 权限,经宿主请求服务器 API)</h2>
<button id="ping">请求 /api/ping</button>
<pre id="net-out" class="note">未请求。相对 path 会拼在客户端配置的服务器地址后。</pre>
</section>
<section class="card">
<h2>通知与标题(notify / title 权限)</h2>
<button id="notify">发一条原生通知</button>
<button id="paint">随机换个标题颜色</button>
<p class="note" id="changelog"></p>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,57 @@
{
"blob_dir": "blobs",
"entries": {
"app.js": {
"hash": "7bee0271377e51324261839349c6f59d460d3f0cf4a3d556c2d30aa089e3b8d7",
"mime": "text/javascript",
"mode": 438,
"mtime": 1789458308,
"size": 2955
},
"icon.svg": {
"hash": "a86a4e282266dfc54b728435fc022a9338a41345dc54fb06ca7abd3148f5f1db",
"mime": "image/svg+xml",
"mode": 438,
"mtime": 1789404516,
"size": 272
},
"index.html": {
"hash": "e87bc4fd4a0c7a3dddc7919a724a45ec84dd9c99d550d5d36fd53a6047c8a701",
"mime": "text/html",
"mode": 438,
"mtime": 1789406426,
"size": 1564
},
"plugin.json": {
"hash": "4b8c8b29f0fa76262277aa1bb90eafc1dc9951355f4c6182276ab30c6fde54bd",
"mime": "application/json",
"mode": 438,
"mtime": 1789458402,
"size": 334
},
"style.css": {
"hash": "d4aaec893ca84ba86e7b365ed55d18ac5f902f4090051d59b5d6779b0b657df8",
"mime": "text/css",
"mode": 438,
"mtime": 1789404516,
"size": 1294
}
},
"format": "svfs-objects",
"name": "packed_filesystem",
"shard": 2,
"snapshot": {
"created_time": "2026-09-15T14:22:29.122983",
"digest": "308fe2e39369c94be63fc0f1c638b82bfe69d616ae80a065d8bb1c23e16e4bf6",
"id": 6,
"name": "2.0.0-4"
},
"stats": {
"directories": 0,
"files": 5,
"objects": 11,
"objects_stored_size": 9038,
"total_size": 6419
},
"version": 1
}