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")))
|
||||
Reference in New Issue
Block a user