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
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""SVFS 插件服务端的最小示例(FastAPI)。
一个进程同时承担客户端的两件事:
1. **插件下载**:把每个插件的 publish 产物(``manifest.json`` + ``blobs/``)
放在 ``PLUGINS_DIR/<插件id>/`` 下并由静态文件服务吐出——客户端
「从服务器安装」填插件 id 即可(对象逐个 sha256 校验);
2. **接口服务**:``/api/*`` 是普通 HTTP API,插件经
``svfs.net.request({path: "/api/ping"})`` 调用。宿主代发请求,
不经过浏览器的 CORS,服务端不需要任何跨域配置。
``/api/*`` 需要登录:客户端登录界面调 ``POST /api/login`` 换 token,
之后宿主自动给发往本服务器的请求附上 ``Authorization: Bearer``。
运行:
.. code-block:: bash
uv sync --extra fastapi # fastapi + starlette(本仓库的 extra)
uv pip install uvicorn # 服务器(示例直接用 uvicorn 起进程)
uv run python examples/plugin_server.py
# → http://127.0.0.1:8000 (客户端「服务器」卡片填这个地址)
发布一个插件(也可以用下面的 POST /plugins/<id> 接口上传):
.. code-block:: bash
svfs pack ./my-plugin dist.svfs --compress
svfs publish dist.svfs svfs-plugins-root/com.example.my-plugin
# 客户端「从服务器安装」填:com.example.my-plugin
目录布局(客户端约定,勿改):
.. code-block:: text
PLUGINS_DIR/
└── <插件id>/
├── manifest.json
└── blobs/<hash 前 2 位>/<hash>
"""
import os
import secrets
import sqlite3
import tempfile
from datetime import datetime
from pathlib import Path
from fastapi import Depends, FastAPI, File, Header, HTTPException, Request, UploadFile
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from sqlite_vfs.core import SQLiteVFS
from sqlite_vfs.publish import export_objects
from svfs_client.manifest import ManifestError, read_from_vfs
#: 插件包根:每个 <插件id>/ 一个子目录,里面是 svfs publish 的产物
PLUGINS_DIR = Path(os.environ.get("SVFS_PLUGINS_DIR", "./svfs-plugins-root")).resolve()
PLUGINS_DIR.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="SVFS 插件服务", version="0.1.0")
# ----------------------------------------------------------------------
# 登录认证:客户端启动时经登录界面换取 token,之后访问 /api/* 都带上它
# (会话在内存里,重启服务端后客户端会收到 401 并被要求重新登录)
# ----------------------------------------------------------------------
#: 演示账号:未设置 ``SVFS_USERS`` 环境变量时的兜底
DEMO_USERS = {"admin": "admin123"}
def _load_users():
"""从 ``SVFS_USERS``(``user:pass,user2:pass2`` 格式)读账号表,每次请求现读,
改环境变量即时生效;未设置(或置空)时用内置演示账号 admin / admin123。"""
raw = os.environ.get("SVFS_USERS", "")
users = {}
for item in raw.split(","):
item = item.strip()
if not item:
continue
name, _, password = item.partition(":")
name, password = name.strip(), password.strip()
if name and password:
users[name] = password
return users or DEMO_USERS
#: token -> 会话(username / login_time;进程内存会话,重启即失效)
SESSIONS = {}
def require_auth(authorization: str = Header(default="")):
"""FastAPI 依赖:校验 ``Authorization: Bearer <token>``,通过返回会话 dict。"""
token = authorization[7:].strip() if authorization.startswith("Bearer ") else ""
session = SESSIONS.get(token)
if not session:
raise HTTPException(status_code=401, detail="未登录或登录已过期")
return session
class LoginBody(BaseModel):
username: str
password: str
@app.post("/api/login")
def login(body: LoginBody):
"""客户端登录界面调用:账号密码换 token。"""
if _load_users().get(body.username) != body.password or not body.password:
raise HTTPException(status_code=401, detail="用户名或密码不正确")
token = secrets.token_hex(32)
login_time = datetime.now().isoformat(timespec="seconds")
SESSIONS[token] = {"username": body.username, "login_time": login_time}
return {"ok": True, "token": token, "username": body.username,
"login_time": login_time}
@app.get("/api/me")
def me(session: dict = Depends(require_auth)):
"""客户端启动时校验记住的 token 是否仍然有效。"""
return {"ok": True, **session}
@app.post("/api/logout")
def logout(authorization: str = Header(default="")):
"""退出登录:作废当前 token。"""
token = authorization[7:].strip() if authorization.startswith("Bearer ") else ""
SESSIONS.pop(token, None)
return {"ok": True}
# ----------------------------------------------------------------------
# 接口服务:插件经 svfs.net.request 调用(随你自己的业务随意增删)
# ----------------------------------------------------------------------
@app.get("/api/ping")
def ping(session: dict = Depends(require_auth)):
"""演示插件的「请求 /api/ping」按钮打的就是这里。
需要登录(Bearer token):客户端桥会给发往本服务器的 net 请求
自动带上登录 token,插件代码不用关心认证。
"""
return {"pong": True, "user": session["username"],
"time": datetime.now().isoformat(timespec="seconds")}
@app.post("/api/echo")
async def echo(request: Request, session: dict = Depends(require_auth)):
"""演示 POST + JSON body:插件把 body 作为字符串放进 net 请求。"""
body = await request.json()
return {"received": body, "ok": True, "user": session["username"]}
# ----------------------------------------------------------------------
# 插件上传:收一个 .svfs,服务端完成 publish(对象 + 清单)
# ----------------------------------------------------------------------
def _check_upload_token(request_token):
"""可选的上传令牌:设置环境变量 ``SVFS_UPLOAD_TOKEN`` 后,
上传必须带 ``X-SVFS-Token`` 头(每次请求现读,改环境变量即时生效)。"""
expected = os.environ.get("SVFS_UPLOAD_TOKEN", "")
if expected and request_token != expected:
raise HTTPException(status_code=403, detail="上传令牌不正确")
@app.post("/plugins/{plugin_id}")
async def upload_plugin(plugin_id: str, file: UploadFile = File(...),
x_svfs_token: str = Header(default="")):
"""上传一个插件包并就地发布。
服务端做三件事:读 plugin.json 校验清单(与客户端同一套规则)、
确认清单 id 与 URL 一致、用 ``export_objects`` 导出成
``manifest.json + blobs/``(内容寻址,重复上传自动去重)。
"""
_check_upload_token(x_svfs_token)
data = await file.read()
handle_fd, tmp_path = tempfile.mkstemp(suffix=".svfs")
try:
with os.fdopen(handle_fd, "wb") as handle:
handle.write(data)
try:
vfs = SQLiteVFS(tmp_path, readonly=True)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"不是有效的 .svfs 包: {exc}")
try:
try:
manifest = read_from_vfs(vfs)
except ManifestError as exc:
raise HTTPException(status_code=400, detail=str(exc))
report = vfs.verify()
if not report["ok"]:
raise HTTPException(status_code=400,
detail="包完整性校验未通过: " + ";".join(report["errors"][:3]))
finally:
vfs.close()
if manifest["id"] != plugin_id:
raise HTTPException(
status_code=400,
detail=f"清单 id ({manifest['id']}) 与 URL 里的插件 id ({plugin_id}) 不一致",
)
stats = export_objects(tmp_path, str(PLUGINS_DIR / plugin_id), overwrite=True)
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
return {
"ok": True,
"id": manifest["id"],
"version": manifest["version"],
"files": stats["files"],
"digest": stats["digest"],
}
# ----------------------------------------------------------------------
# 客户端自更新信息(可选)
# ----------------------------------------------------------------------
@app.get("/client/manifest.json")
def client_manifest():
"""客户端启动时查一次"有没有推荐的新版客户端"。
生产上这个文件通常是静态托管的(与插件同一套 ``<base_url>/…`` 布局,
内容 ``{"version", "url", "notes"}``);示例服务端用环境变量现场生成,
方便演示提示链路:
.. code-block:: bash
SVFS_CLIENT_VERSION=9.9.9 SVFS_CLIENT_URL=https://example.com/dl \\
uv run python examples/plugin_server.py
没设置时返回 404 = 客户端"没有更新信息"(静默跳过,不当错误)。
"""
version = os.environ.get("SVFS_CLIENT_VERSION", "").strip()
if not version:
raise HTTPException(status_code=404, detail="未配置 SVFS_CLIENT_VERSION")
return {"version": version,
"url": os.environ.get("SVFS_CLIENT_URL", "").strip(),
"notes": os.environ.get("SVFS_CLIENT_NOTES", "").strip()}
# ----------------------------------------------------------------------
# 插件下载:静态吐出 PLUGINS_DIR(务必注册在所有 API 路由之后)
# ----------------------------------------------------------------------
@app.get("/")
def index():
plugins = sorted(
entry.name for entry in PLUGINS_DIR.iterdir()
if (entry / "manifest.json").is_file()
)
return {"service": "svfs-plugin-server", "plugins": plugins}
app.mount("/", StaticFiles(directory=str(PLUGINS_DIR)), name="plugins")
if __name__ == "__main__":
import uvicorn
demo = os.environ.get("SVFS_USERS", "").strip()
if demo:
print(f"登录账号来自 SVFS_USERS 环境变量({len(demo.split(','))} 个)")
else:
print("登录账号:admin / admin123(可用 SVFS_USERS=\"user:pass,user2:pass2\" 自定义)")
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("SVFS_PORT", "8000")))
+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",
},
});