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:
@@ -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")))
|
||||
@@ -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 只覆盖日常迭代。
|
||||
@@ -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>
|
||||
+1265
File diff suppressed because it is too large
Load Diff
@@ -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.
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user