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
+53
View File
@@ -0,0 +1,53 @@
# SVFS 插件开发模板(Vue 3 + Vite)
用 Vue 3 开发 SVFS 插件的起点模板:构建产物打进 `.svfs` 包、在客户端的
WebView 里以 `svfs://<插件id>/` 独立 origin 运行。附赠浏览器 mock,让
`npm run dev` 不进客户端也能迭代 UI。
## 目录结构
```text
public/plugin.json 插件清单(vite 会原样拷进 dist 根目录——清单必须在包根)
src/main.js 入口;dev 模式自动装 window.svfs 模拟器
src/svfs.js window.svfs 的薄封装(Promise + JSON 便捷层),组件统一从这里用
src/mock-svfs.js 浏览器版宿主模拟器(storage→localStorage,net→fetch,对话框→confirm/alert)
src/App.vue 分页签的组织方式(主机信息 / 存储 / 网络 / 界面·对话框)
src/components/*.vue 每类能力一个面板组件,照着抄就行
```
## 开发流程
```bash
npm install
# 1) 浏览器里迭代 UI(mock 桥;net 相对路径打到 vite dev server)
npm run dev
# 2) 构建 + 打包(plugin.json 在 public/ 下,构建后位于 dist 根,满足"清单在包根")
npm run build
svfs pack ./dist plugin.svfs --compress
# 3a) 安装到客户端:客户端「设置 → 插件管理 → 安装本地插件…」选 plugin.svfs
# 3b) 或发布到示例服务端,客户端「从服务器安装」填 com.example.vue-demo:
svfs publish plugin.svfs svfs-plugins-root/com.example.vue-demo
```
## 桥能力速查(src/svfs.js)
| 能力 | 需要权限 | 说明 |
|---|---|---|
| `getHostInfo()` | 无 | `host.info`;含登录用户 `authUser`(v3+) |
| `createStorage(prefix)` | storage | 按插件隔离的键值存储 |
| `netRequest/netJson` | net | 宿主代发 HTTP,绕开 CORS,同源请求自动带登录 token |
| `notify / setTitle` | notify / title | 状态栏通知 / 改窗口标题 |
| `confirmDialog / messageBox` | dialog | 宿主**原生对话框**(v4+),Promise 返回用户选择 |
## 注意事项
* **`vite.config` 里 `base: "./"` 不能丢**:包内是独立 origin,绝对路径资源会 404。
* manifest 的 `api_version` 是你要求的最低宿主版本:用了 `dialog` 就写 4,
没用新能力可以写 2(兼容面更大)。改 id 记得同步 `public/plugin.json`
与上传路径——两处必须一致。
* 静态资源放 `src/assets/` 里 `import` 引用(会被 vite 重写为相对路径);
放 `public/` 的文件按根相对路径引用。
* 真实桥的行为以客户端「设置 → 桥调试台」插件为准;mock 只覆盖日常迭代。
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vue 模板插件</title>
</head>
<body>
<!-- 挂载点:样式由各组件自行负责(插件页面没有全局样式约束) -->
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "svfs-vue-plugin-template",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0"
}
}
@@ -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 插件模板,演示全部桥能力"
}
Binary file not shown.
+94
View File
@@ -0,0 +1,94 @@
<script setup>
import { ref } from "vue";
import HostPanel from "./components/HostPanel.vue";
import StoragePanel from "./components/StoragePanel.vue";
import NetPanel from "./components/NetPanel.vue";
import UiPanel from "./components/UiPanel.vue";
const tabs = [
{ id: "host", label: "主机信息" },
{ id: "storage", label: "存储" },
{ id: "net", label: "网络" },
{ id: "ui", label: "界面 / 对话框" },
];
const active = ref("host");
</script>
<template>
<main>
<header>
<h1>Vue 模板插件</h1>
<nav>
<button
v-for="tab in tabs"
:key="tab.id"
:class="{ active: active === tab.id }"
@click="active = tab.id"
>
{{ tab.label }}
</button>
</nav>
</header>
<HostPanel v-if="active === 'host'" />
<StoragePanel v-else-if="active === 'storage'" />
<NetPanel v-else-if="active === 'net'" />
<UiPanel v-else />
</main>
</template>
<style>
/* 插件页面的全局基底:跟随客户端的暗色观感(也可完全自定义) */
: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; }
/* 插件页内的滚动条也走暗色细条(Chromium 内核,webkit 伪元素生效) */
::-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; }
</style>
@@ -0,0 +1,31 @@
<script setup>
import { ref } from "vue";
import { getHostInfo } from "../svfs.js";
const info = ref(null);
const error = ref("");
getHostInfo()
.then((value) => (info.value = value))
.catch((err) => (error.value = err.message));
</script>
<template>
<section class="panel">
<h2>host.info —— 无需权限</h2>
<p v-if="error" class="muted">{{ error }}</p>
<dl v-else-if="info">
<template v-for="(value, key) in info" :key="key">
<dt>{{ key }}</dt>
<dd>{{ value ?? "-" }}</dd>
</template>
</dl>
<p v-else class="muted">加载中…</p>
</section>
</template>
<style scoped>
dl { display: grid; grid-template-columns: auto 1fr; gap: 4px 16px; margin: 0; }
dt { color: var(--muted); }
dd { margin: 0; font-family: Consolas, monospace; font-size: 12px; }
</style>
@@ -0,0 +1,47 @@
<script setup>
import { ref } from "vue";
import { netJson } from "../svfs.js";
const method = ref("GET");
const path = ref("/api/ping");
const body = ref("");
const output = ref("未请求。相对 path 拼在客户端配置的服务器地址后。");
const busy = ref(false);
async function send() {
busy.value = true;
output.value = "请求中…";
try {
const options = { method: method.value, path: path.value };
if (body.value && !["GET", "HEAD"].includes(method.value)) {
options.body = body.value;
options.headers = { "Content-Type": "application/json" };
}
const { status, data } = await netJson(options);
output.value = `HTTP ${status}\n${JSON.stringify(data, null, 2)}`;
} catch (err) {
output.value = err.response
? `HTTP ${err.response.status}\n${JSON.stringify(err.response.data, null, 2)}`
: `请求失败: ${err.message}\n(先在「设置 → 服务器地址」里配置服务器)`;
} finally {
busy.value = false;
}
}
</script>
<template>
<section class="panel">
<h2>net —— 宿主代发请求(绕开 CORS,自动附带登录 token)</h2>
<p>
<select v-model="method">
<option>GET</option><option>POST</option>
<option>PUT</option><option>DELETE</option>
</select>
<input v-model="path" size="26" />
<button class="act" :disabled="busy" @click="send">发送请求</button>
</p>
<textarea v-model="body" placeholder="POST/PUT 的 body(JSON 字符串)"></textarea>
<pre>{{ output }}</pre>
<p class="muted">4xx/5xx 也是正常返回(不会 reject);netJson 只是把 ≥400 转成异常方便分支。</p>
</section>
</template>
@@ -0,0 +1,61 @@
<script setup>
import { onMounted, ref } from "vue";
import { createStorage, notify } from "../svfs.js";
const storage = createStorage("demo:");
const count = ref(0);
const key = ref("greeting");
const value = ref("你好,SVFS");
const output = ref("");
onMounted(async () => {
count.value = (await storage.get("visits")) || 0;
count.value += 1;
await storage.set("visits", count.value);
});
async function reset() {
await storage.set("visits", 0);
count.value = 0;
notify("计数已清零");
}
async function run(action) {
try {
if (action === "set") {
let parsed = value.value;
try { parsed = JSON.parse(value.value); } catch { /* 字符串原样存 */ }
await storage.set(key.value, parsed);
output.value = `set(${key.value}) 成功`;
} else if (action === "get") {
output.value = `get(${key.value}) → ${JSON.stringify(await storage.get(key.value))}`;
} else if (action === "remove") {
await storage.remove(key.value);
output.value = `remove(${key.value}) 成功`;
} else {
output.value = `keys() → ${JSON.stringify(await storage.keys())}`;
}
} catch (err) {
output.value = `失败: ${err.message}`;
}
}
</script>
<template>
<section class="panel">
<h2>storage —— 按插件隔离的键值存储(本插件已打开 {{ count }} 次)</h2>
<p>
<input v-model="key" placeholder="键" size="14" />
<input v-model="value" placeholder="值(支持 JSON)" size="22" />
<button class="act" @click="run('set')">写入</button>
<button class="act" @click="run('get')">读取</button>
<button class="act" @click="run('remove')">删除</button>
<button class="act" @click="run('keys')">全部键</button>
</p>
<pre>{{ output || "(操作结果显示在这里)" }}</pre>
<p class="muted">
<button class="act" @click="reset">打开计数清零</button>
换台机器 / 重装插件,这个存储也不会串到别的插件。
</p>
</section>
</template>
@@ -0,0 +1,44 @@
<script setup>
import { ref } from "vue";
import { confirmDialog, messageBox, notify, setTitle } from "../svfs.js";
const output = ref("(用户的选择显示在这里)");
async function ask() {
const accepted = await confirmDialog(
"这是一条宿主原生确认框(QMessageBox.question)。\n点「确定」返回 true。",
"确认操作"
);
output.value = `ui.confirm → ${accepted}`;
}
async function show(kind) {
await messageBox(`这是一条 ${kind} 级原生消息框。`, {
title: "原生对话框",
kind,
});
output.value = `ui.message(${kind}) → 已关闭`;
}
</script>
<template>
<section class="panel">
<h2>notify / title / dialog —— 原生对话框(dialog 权限,v4 起)</h2>
<p>
<button class="act" @click="notify('来自 Vue 模板插件的通知')">发通知</button>
<button class="act" @click="setTitle('Vue 模板插件 · ' + new Date().toLocaleTimeString())">
改窗口标题
</button>
</p>
<p>
<button class="act" @click="ask">confirm(确定 / 取消)</button>
<button class="act" @click="show('info')">message · info</button>
<button class="act" @click="show('warn')">message · warn</button>
<button class="act" @click="show('error')">message · error</button>
</p>
<pre>{{ output }}</pre>
<p class="muted">
浏览器 dev 模式下对话框退化为 confirm()/alert();真实效果以客户端内为准。
</p>
</section>
</template>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from "vue";
import App from "./App.vue";
import { installMockSvfs } from "./mock-svfs";
// 浏览器里 npm run dev 时没有宿主桥:装上模拟器,保证组件代码两态一致
if (import.meta.env.DEV) {
installMockSvfs();
}
createApp(App).mount("#app");
@@ -0,0 +1,82 @@
// 开发模式的 window.svfs 模拟器:让 `npm run dev` 在普通浏览器里也能跑。
//
// * storage → localStorage(真实客户端里是按插件隔离的 JSON 文件)
// * net → fetch(相对 path 打到 vite dev server;要连示例服务端可以
// 在 vite.config 里加 proxy,或请求时直接给完整 url)
// * 对话框 → window.confirm / alert(真实客户端里是原生 QMessageBox)
//
// 客户端内的最终效果以「设置 → 桥调试台」插件为准;mock 只覆盖开发迭代。
export function installMockSvfs() {
if (window.svfsReady) return; // 已在宿主里,别覆盖
let resolveReady;
window.svfsReady = new Promise((resolve) => (resolveReady = resolve));
const KEY_PREFIX = "mock-svfs:";
const storageKey = (key) => KEY_PREFIX + key;
window.svfs = {
host: {
info: async () => ({
pluginId: "com.example.vue-demo",
appName: "Browser Dev",
appVersion: "0.0.0",
apiVersion: 4,
platform: "browser",
baseUrl: location.origin,
authUser: "",
}),
},
storage: {
get: async (key) => localStorage.getItem(storageKey(key)),
set: async (key, value) => {
localStorage.setItem(storageKey(key), JSON.stringify(value));
return true;
},
remove: async (key) => {
localStorage.removeItem(storageKey(key));
return true;
},
keys: async () =>
Object.keys(localStorage)
.filter((key) => key.startsWith(KEY_PREFIX))
.map((key) => key.slice(KEY_PREFIX.length)),
},
net: {
request: async (options) => {
const response = await fetch(options.url || options.path || "/", {
method: options.method || "GET",
headers: options.headers,
body: options.body,
});
const bodyText = await response.text();
return {
ok: response.ok,
status: response.status,
headers: {},
bodyText,
bodyBase64: "",
};
},
},
ui: {
notify: async (message) => {
console.log("[svfs notify]", message);
return true;
},
setTitle: async (title) => {
document.title = title;
return true;
},
confirm: async (options) =>
window.confirm(`${options.title || "确认"}\n\n${options.text || ""}`),
message: async (options) => {
window.alert(`[${options.kind || "info"}] ${options.title || ""}\n\n${options.text || ""}`);
return true;
},
},
};
console.info("[mock-svfs] 已安装浏览器版 window.svfs 模拟器(真实效果以客户端为准)");
resolveReady(window.svfs);
}
+87
View File
@@ -0,0 +1,87 @@
// window.svfs 的薄封装:统一等待桥就绪(window.svfsReady 是宿主注入的
// Promise),把每类能力包成小函数,组件里直接 import 使用,不用碰全局。
//
// 在浏览器里 npm run dev 时没有宿主桥——main.js 会先装上 mock-svfs,
// 所以这些函数在两种环境下行为一致。
export function onSvfsReady() {
if (window.svfsReady) return window.svfsReady;
return Promise.reject(
new Error("未检测到 svfs 桥:请在 SVFS Client 中运行本插件")
);
}
/** 宿主信息:{pluginId, appName, appVersion, apiVersion, platform, baseUrl, authUser} */
export async function getHostInfo() {
const svfs = await onSvfsReady();
return svfs.host.info();
}
/** 带命名空间前缀的键值存储(按插件隔离) */
export function createStorage(prefix = "") {
return {
async get(key) {
const svfs = await onSvfsReady();
const raw = await svfs.storage.get(prefix + key);
return raw === undefined || raw === null ? null : raw;
},
async set(key, value) {
const svfs = await onSvfsReady();
return svfs.storage.set(prefix + key, value);
},
async remove(key) {
const svfs = await onSvfsReady();
return svfs.storage.remove(prefix + key);
},
async keys() {
const svfs = await onSvfsReady();
return svfs.storage.keys();
},
};
}
/** 原始 net 请求 → {ok, status, headers, bodyText, bodyBase64};4xx/5xx 也是正常返回 */
export async function netRequest(options) {
const svfs = await onSvfsReady();
if (!svfs.net) throw new Error("宿主没有 net 能力(manifest 需要声明 net 权限)");
return svfs.net.request(options);
}
/** net + JSON 便捷封装:path 相对服务器地址;非 2xx 抛错(带 response) */
export async function netJson(options) {
const res = await netRequest(options);
let data = null;
try {
data = res.bodyText ? JSON.parse(res.bodyText) : null;
} catch {
data = res.bodyText;
}
if (res.status >= 400) {
const err = new Error(`HTTP ${res.status}`);
err.response = { status: res.status, data };
throw err;
}
return { status: res.status, data };
}
export async function notify(message) {
const svfs = await onSvfsReady();
return svfs.ui.notify(message);
}
export async function setTitle(title) {
const svfs = await onSvfsReady();
return svfs.ui.setTitle(title);
}
/** 宿主原生确认框(dialog 权限):Promise<boolean>,用户点"确定"返回 true */
export async function confirmDialog(text, title = "确认") {
const svfs = await onSvfsReady();
return svfs.ui.confirm({ text, title });
}
/** 宿主原生消息框:kind = "info" | "warn" | "error" */
export async function messageBox(text, { title = "提示", kind = "info" } = {}) {
const svfs = await onSvfsReady();
return svfs.ui.message({ text, title, kind });
}
@@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// 插件最终从 svfs://<插件id>/ 包内加载:必须用相对路径(base: "./"),
// 否则构建产物里的 /assets/... 绝对路径在包内会 404。
export default defineConfig({
plugins: [vue()],
base: "./",
build: {
outDir: "dist",
// 客户端内嵌 Chromium 较新,无需过度降级
target: "chrome110",
},
});