一、消息模块:服务端主动推送 + 插件定向分发
* topics.py:主题方案 <prefix>/<org>/user/<user>/message、.../user/<user>/plugin/<id>、
.../broadcast/{message,plugin/<id>}。org 是租户位(多租户上线不用改格式,broker
按 <prefix>/<org>/# 下发 ACL 即可隔离);用户名/插件 id 经**可逆**百分号编码占
一个层级,客户端与示例服务端共用同一套拼主题函数;
* bus.py:统一消息总线——按**主题**分类(主机消息进消息中心、插件消息不进,仪表盘
数据那类业务载荷永远不会混进给人看的列表)、归属校验(org/user 不符即丢弃,broker
ACL 配错也挡得住)、按 id 去重(QoS 1 重投与"推送/补齐重叠"都不会重复显示)、
插件消息按插件缓存 50 条,等页面打开后一次性补投;
* mqtt.py + mqtt_service.py:paho 适配(可选依赖 [mqtt])+ Qt 适配(paho 的网络
线程经排队信号把消息交给主线程的总线);重连交给 paho(指数退避),重连成功
自动重新订阅;没装 paho / 没配 broker 都只是"没有实时推送",HTTP 补齐照常;
* worker.py:登录后**先**补齐(GET /api/messages?since=)再连 MQTT,两条路进同一
总线;在途结果带身份快照,退出登录/换账号后丢弃;
* 界面:侧栏 ✉ 未读徽标 + 消息中心(级别/标题/时间/来源/广播标记、立即同步、
全部已读、清空、通道状态灯);warn/error 顺手发系统托盘通知;
* 插件侧:新权限 messages(PLUGIN_API_VERSION→5),页面里 svfs.messages.subscribe();
宿主经 window.__svfsPush 投递,早到的先排队、订阅时补发(桥调试台已接上演示);
* examples/plugin_server.py:/api/messages、/api/push(kind=message|plugin)、
/api/broadcast;配了 SVFS_MQTT_HOST 就真发布到 broker,并把 broker 地址随登录
响应下发(/api/login 与 /api/me 都带),客户端不用手填。
二、退出登录 = 关掉主窗口 + 回到登录界面
* 强制登录下"未登录的主窗口"本就不该存在:重新登录成功用新会话开回主窗口,关掉
登录窗即退出客户端;退出时断开推送、清掉该账号收到的消息与总线身份(下一个人
不该看到上一个人的消息),并收掉已打开的插件页面;
* 用户中心的"退出登录"先关掉自己再进登录门,避免模态窗口叠模态。
三、插件顺序编排
* installer.arrange():编排过的在前、未编排的按名称;管理页卡片加 ↑/↓,侧栏/图标栏/
管理页三者一致;存 settings 的 plugin_order,只影响显示(不动包与注册表);
* "启动时打开"的默认规则改为"显示顺序里第一个启用的插件"(原来是按 id 排);
* 新增「恢复默认顺序」(编排过才出现)。
四、修复
* 收缩侧栏后页面左上角露出一块纯白方块:页面栈对 Qt 是透明的(主题只给
QMainWindow/QDialog 设了背景),侧栏让出的那条在重绘时序里露白。给 QStackedWidget
不透明底色,并加像素级回归用例(撤掉修复即 FAIL,实测 1237 个白像素);
* build_opener() 每次都要隐式枚举 Windows 证书库:全量测试跑几百次后以断点异常
崩在证书枚举里(faulthandler 定位在 _load_windows_store_certs)。改成进程内只建
一次 SSL context、启动时在主线程预建;
* 在途的消息补齐结果会喂给新会话(退出登录时用户名已空 → 拼主题抛异常,被 Qt 回调
静默吞掉):带身份快照,不匹配即丢弃。
五、设置界面按类型分页签(插件 / 服务器 / 存储 / 网络 / 诊断)
* 每页内容在滚动区里(窗口拉小或以后再加项是滚动条,而不是把控件压扁);
* 新增「消息推送(MQTT)」一节:broker(host / host:port / mqtts://host:8883)、组织、
端口、TLS、主题前缀、broker 账号,保存即重连;状态行显示**生效**配置与来源
(手配 / 服务端下发)。broker 密码不进界面:默认用登录 token(已加密存储),需要
单独的密码时由服务端下发——与"凭据不明文落盘"保持一致。
测试与验证
* 新增 tests/test_client_messages.py(主题/封皮/总线,纯层)、tests/test_client_mqtt.py
(装配层 + 自建最小 broker 的端到端,不依赖 mosquitto)、test_plugin_server.py 的
消息接口与"/api/push → broker → 客户端总线"闭环;WebEngine 冒烟里加了页面级
messages 探针;
* **测试入口改为 tests/run_tests.py**:QtWebEngine 在 offscreen 下的解释器退出路径会
崩(测试全绿但退出码 139,CI 只看退出码就会判失败),runner 按 unittest 的结果
os._exit 绕开那段清理;测试真崩在中途时退出码照样非 0(用故意失败的用例验证过)。
README 与 CI 都切到这个入口;
* 全量 720 条通过(6 skip),exit 0。
461 lines
18 KiB
Python
461 lines
18 KiB
Python
#!/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 json
|
||
import os
|
||
import secrets
|
||
import sqlite3
|
||
import tempfile
|
||
import threading
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
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
|
||
from svfs_client.topics import (
|
||
broadcast_message_topic,
|
||
user_message_topic,
|
||
user_plugin_topic,
|
||
)
|
||
|
||
#: 插件包根:每个 <插件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, **_session_extra(body.username, login_time)}
|
||
|
||
|
||
def _session_extra(username, login_time):
|
||
"""登录/校验响应里的额外字段:组织标识 + broker 地址(有才带)。"""
|
||
extra = {"username": username, "login_time": login_time, "org": _org()}
|
||
mqtt = _mqtt_announcement()
|
||
if mqtt:
|
||
extra["mqtt"] = mqtt
|
||
return extra
|
||
|
||
|
||
@app.get("/api/me")
|
||
def me(session: dict = Depends(require_auth)):
|
||
"""客户端启动时校验记住的 token 是否仍然有效。
|
||
|
||
这里也要下发组织与 broker:静默校验通过后就**不再弹登录窗**,是客户端
|
||
拿到推送配置的另一条主路径。
|
||
"""
|
||
return {"ok": True, **_session_extra(session["username"],
|
||
session.get("login_time", ""))}
|
||
|
||
|
||
@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"],
|
||
}
|
||
|
||
|
||
# ----------------------------------------------------------------------
|
||
# 消息:服务端主动推给用户(MQTT),登录后客户端先同步一次补齐
|
||
# ----------------------------------------------------------------------
|
||
#
|
||
# 主题方案(组织标识在第一版就占好位子,多租户上线不用改格式):
|
||
#
|
||
# <prefix>/<org>/user/<用户名>/message 主机消息 → 客户端的消息中心
|
||
# <prefix>/<org>/user/<用户名>/plugin/<插件id> 插件消息 → 只给该插件
|
||
# <prefix>/<org>/broadcast/message 组织广播 → 消息中心
|
||
#
|
||
# 服务端与客户端用同一套拼主题的函数(直接 import svfs_client.topics),
|
||
# 用户名里的特殊字符两端编码一致,不会错位。
|
||
#
|
||
# 演示跑法(需要一个 broker,比如 mosquitto):
|
||
#
|
||
# SVFS_MQTT_HOST=127.0.0.1 SVFS_ORG=acme uv run python examples/plugin_server.py
|
||
# # 客户端登录后会自动连上(broker 地址由登录响应下发),然后:
|
||
# curl -X POST http://127.0.0.1:8000/api/push -H "Authorization: Bearer $token" \
|
||
# -H "Content-Type: application/json" \
|
||
# -d '{"kind":"message","title":"备份完成","body":"3 个包","level":"info"}'
|
||
# # 插件数据(不进消息中心):
|
||
# curl -X POST http://127.0.0.1:8000/api/push -H "Authorization: Bearer $token" \
|
||
# -H "Content-Type: application/json" \
|
||
# -d '{"kind":"plugin","plugin":"com.svfs.demo","data":{"cpu":0.42}}'
|
||
#
|
||
# 没配 broker 时 /api/push 只写待收列表(响应里如实说明 mqtt=skipped),
|
||
# 客户端仍能靠登录后的同步看到消息——这条降级路径让演示不依赖 broker。
|
||
|
||
#: 每个用户的待收消息上限(演示用;生产该入库)
|
||
MESSAGE_BACKLOG = 100
|
||
#: 待收列表:username → [消息, …](内存,重启即清空)
|
||
MESSAGES: dict = {}
|
||
#: 同一条消息可能被多个客户端(同账号多端)各收一次,所以按用户存
|
||
_mqtt_lock = threading.Lock()
|
||
|
||
|
||
def _mqtt_prefix():
|
||
return os.environ.get("SVFS_MQTT_PREFIX", "svfs").strip() or "svfs"
|
||
|
||
|
||
def _org():
|
||
return os.environ.get("SVFS_ORG", "").strip() or "default"
|
||
|
||
|
||
def _mqtt_announcement():
|
||
"""登录响应里下发 broker 信息(客户端据此自动订阅,不用用户手填)。
|
||
|
||
没配 ``SVFS_MQTT_HOST`` 时返回 None——客户端就走"只同步、不推送"的降级。
|
||
"""
|
||
host = os.environ.get("SVFS_MQTT_HOST", "").strip()
|
||
if not host:
|
||
return None
|
||
tls = os.environ.get("SVFS_MQTT_TLS", "").strip().lower() in ("1", "true", "yes")
|
||
announcement = {"host": host, "tls": tls,
|
||
"prefix": _mqtt_prefix(), "org": _org()}
|
||
port = os.environ.get("SVFS_MQTT_PORT", "").strip()
|
||
if port.isdigit():
|
||
announcement["port"] = int(port)
|
||
return announcement
|
||
|
||
|
||
def _mqtt_publish(topic, payload):
|
||
"""发一条到 broker;没配 broker / 没装 paho 都返回说明字符串(不抛)。"""
|
||
host = os.environ.get("SVFS_MQTT_HOST", "").strip()
|
||
if not host:
|
||
return "skipped: 未配置 SVFS_MQTT_HOST(消息只进了待收列表)"
|
||
try:
|
||
import paho.mqtt.client as mqtt
|
||
except Exception:
|
||
return "skipped: 服务端没装 paho-mqtt(pip install paho-mqtt)"
|
||
|
||
tls = os.environ.get("SVFS_MQTT_TLS", "").strip().lower() in ("1", "true", "yes")
|
||
port = int(os.environ.get("SVFS_MQTT_PORT", "") or (8883 if tls else 1883))
|
||
kwargs = {"client_id": f"svfs-server-{secrets.token_hex(4)}"}
|
||
api_version = getattr(mqtt, "CallbackAPIVersion", None)
|
||
if api_version is not None: # paho 2.x 要求显式声明回调 API 版本
|
||
kwargs["callback_api_version"] = api_version.VERSION2
|
||
client = mqtt.Client(**kwargs)
|
||
if tls:
|
||
client.tls_set()
|
||
try:
|
||
with _mqtt_lock: # 演示用:一次一条,连上→发→断开
|
||
client.connect(host, port, 10)
|
||
client.loop_start()
|
||
info = client.publish(topic, payload, qos=0)
|
||
info.wait_for_publish(timeout=5)
|
||
client.loop_stop()
|
||
client.disconnect()
|
||
except Exception as exc:
|
||
return f"error: {exc}"
|
||
return "published"
|
||
|
||
|
||
class PushBody(BaseModel):
|
||
"""推送请求:``kind`` 决定走哪条通道。"""
|
||
|
||
kind: str = "message" # message = 给人看的;plugin = 给插件的业务载荷
|
||
user: str = "" # 目标用户(空 = 发给调用者自己)
|
||
plugin: str = "" # kind=plugin 时必填:目标插件 id
|
||
title: str = ""
|
||
body: str = ""
|
||
level: str = "info"
|
||
data: Optional[dict] = None # kind=plugin 的载荷(原样发给插件)
|
||
id: str = ""
|
||
|
||
|
||
@app.get("/api/messages")
|
||
def list_messages(since: str = "", session: dict = Depends(require_auth)):
|
||
"""客户端登录后/点「立即同步」时补齐消息(``since`` = 本地最新一条的时间)。"""
|
||
items = list(MESSAGES.get(session["username"], []))
|
||
if since:
|
||
items = [item for item in items if str(item.get("time") or "") > since]
|
||
return {"ok": True, "messages": items}
|
||
|
||
|
||
@app.post("/api/push")
|
||
def push_message(body: PushBody, session: dict = Depends(require_auth)):
|
||
"""服务端主动推一条:写待收列表(离线也能补齐)+ 发到 MQTT(在线实时到)。"""
|
||
target = (body.user or session["username"]).strip()
|
||
if not target:
|
||
raise HTTPException(status_code=400, detail="缺少目标用户")
|
||
|
||
if body.kind == "plugin":
|
||
if not body.plugin:
|
||
raise HTTPException(status_code=400, detail="kind=plugin 需要 plugin 字段")
|
||
topic = user_plugin_topic(_mqtt_prefix(), _org(), target, body.plugin)
|
||
payload = json.dumps(body.data if body.data is not None else {},
|
||
ensure_ascii=False)
|
||
# 插件数据**不进**消息中心,也不进待收列表:它是实时的业务载荷,
|
||
# 补历史没有意义(仪表盘要的是当下)。
|
||
return {"ok": True, "topic": topic, "mqtt": _mqtt_publish(topic, payload),
|
||
"stored": False}
|
||
|
||
message = {
|
||
"id": body.id or secrets.token_hex(8),
|
||
"title": body.title,
|
||
"body": body.body,
|
||
"level": body.level if body.level in ("info", "warn", "error") else "info",
|
||
"time": datetime.now().isoformat(timespec="seconds"),
|
||
"source": "example-server",
|
||
}
|
||
backlog = MESSAGES.setdefault(target, [])
|
||
backlog.append(message)
|
||
del backlog[:-MESSAGE_BACKLOG]
|
||
topic = user_message_topic(_mqtt_prefix(), _org(), target)
|
||
return {"ok": True, "topic": topic, "message": message, "stored": True,
|
||
"mqtt": _mqtt_publish(topic, json.dumps(message, ensure_ascii=False))}
|
||
|
||
|
||
@app.post("/api/broadcast")
|
||
def broadcast_message(body: PushBody, session: dict = Depends(require_auth)):
|
||
"""组织广播:发给整个组织(消息中心显示,带"广播"标记)。"""
|
||
topic = broadcast_message_topic(_mqtt_prefix(), _org())
|
||
message = {
|
||
"id": body.id or secrets.token_hex(8),
|
||
"title": body.title or "组织通知",
|
||
"body": body.body,
|
||
"level": body.level if body.level in ("info", "warn", "error") else "info",
|
||
"time": datetime.now().isoformat(timespec="seconds"),
|
||
"source": f"broadcast@{session['username']}",
|
||
}
|
||
return {"ok": True, "topic": topic, "message": message, "stored": False,
|
||
"mqtt": _mqtt_publish(topic, json.dumps(message, ensure_ascii=False))}
|
||
|
||
|
||
# ----------------------------------------------------------------------
|
||
# 客户端自更新信息(可选)
|
||
# ----------------------------------------------------------------------
|
||
|
||
|
||
@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")))
|