diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1e9d62a..6693482 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -49,4 +49,6 @@ jobs: run: uv sync --extra gui --extra fastapi - name: 全量测试 - run: uv run python -m unittest discover -s tests -p "test_*.py" + # 走 tests/run_tests.py:QtWebEngine 在 offscreen 下的解释器退出路径会崩 + # (测试全绿、退出码却是 139),runner 按 unittest 的结果 os._exit + run: uv run python tests/run_tests.py diff --git a/README.md b/README.md index 8f56d55..bd60050 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,7 @@ wheel 里三个包(sqlite_vfs / svfs_studio / svfs_client)一起分发—— ```bash pip install "sqlite-vfs[client]" # 只装插件客户端(含 PySide6) +pip install "sqlite-vfs[mqtt]" # 再加消息模块的 MQTT 推送通道(paho-mqtt) pip install sqlite-vfs # 只要核心库 + CLI(零第三方依赖) ``` @@ -358,11 +359,13 @@ pip install sqlite-vfs # 只要核心库 + CLI(零第三方依赖 PLUGIN_API_VERSION` 的插件(桥能力只增不破,升级客户端不会废掉旧插件; 要求更高版本的插件会得到「请升级客户端」提示)。取值参考:v2 = net 与 host.info 的 baseUrl;v3 = host.info 的 authUser、宿主给同源 net 请求自动 - 附加登录 token;v4 = dialog 权限与 `ui.confirm`/`ui.message` 原生对话框。 + 附加登录 token;v4 = dialog 权限与 `ui.confirm`/`ui.message` 原生对话框; + v5 = messages 权限与服务端推送(`svfs.messages.subscribe`)。 没用新能力就写 2(甚至 1)即可,兼容面更大; * `entry`:SPA 入口。路径 404 且末段没有扩展名时回退到它(每个插件都是完整前端); * `permissions`:声明这个插件要用哪些桥能力(storage / notify / title / net / - dialog),安装时向用户展示; + dialog / **messages**),安装时向用户展示。messages = 接收服务端推给本插件的 + 消息(见下文「消息模块」); * `net_hosts`(可选,**net 目标白名单**):声明后,该插件经桥发的请求只能指向 列表里的主机,其它一律被宿主拒绝并给出明确错误。支持精确主机名与 `*.` 后缀 匹配(`"*.corp.example"` 含 `corp.example` 本身)。不写 = 不限制(老插件行为 @@ -409,28 +412,52 @@ pip install sqlite-vfs # 只要核心库 + CLI(零第三方依赖 误报「离线」),横幅给出「重新登录…」一键入口,用户中心与侧栏头像同步显示 失效状态。提示只发一次,重新登录成功后自动恢复; * `host.info` 新增 `authUser` 字段(当前登录用户名,未登录为空串); +* 退出登录(用户中心或服务器卡片):清掉本地凭据 → 关闭主窗口 → 回到登录 + 界面(重新登录成功用新会话开回主窗口;关掉登录窗即退出客户端)——强制登录 + 下不存在"能用的未登录状态"。退出时同时断开消息推送并清掉该账号收到的消息 + (下一个登录的人不该看到上一个人的消息); +* 登录成功后接上消息模块:**先同步**(HTTP 补齐离线期间的消息),再连 MQTT + 接收实时推送;两者都进统一总线按 id 去重; * 「插件管理 → 服务器」卡片显示登录状态,可「重新登录…」或「退出登录」。 ### 界面:侧栏 / 设置 / 用户中心 * 启动后**直接显示插件内容**(可配置):优先用设置里配置的启动插件, - 未配置则按默认规则(上次打开的 > 按 id 排序第一个启用的插件),都没有 + 未配置则按默认规则(上次打开的 > 显示顺序里第一个启用的插件),都没有 才停在插件管理页; +* **插件顺序可编排**:插件管理页每张卡片右下角有 ↑/↓,上移/下移一位即改 + 显示顺序(侧栏、图标栏、管理页三者一致);顺序存在 `settings.json` 的 + `plugin_order` 里,只影响显示——不动包、不动注册表、不请求服务器。没排到 + 的插件(新装的)按名称跟在后面,"恢复默认顺序"一键清掉编排(那之后默认 + 按名称排)。启动直显的默认规则也读这个顺序,把常用插件排到最前面就默认 + 打开它; * 侧栏顶部「«」把侧栏收成**图标栏**(宽度动画过渡,不再是瞬跳也不再让插件 不可达——图标带悬停提示,点击即打开),再点「»」展开;选择会记住, 下次启动保持; * 侧栏搜索框**模糊匹配**插件名与 id(包含匹配优先,其次首字母缩写: 「csd」能搜到 `com.svfs.demo`、中文名直接搜汉字);无结果时给出提示, 清空即恢复全部; -* 侧栏底部「设置」:**插件管理入口**(安装本地 .svfs、从服务器安装、 - 升级/回滚/卸载)、**启动时打开**的插件、修改服务器地址(与服务器卡片 - 同步生效)、**数据目录**(插件路径,写指针文件,重启生效)、**对象缓存 - 路径**(留空 = 系统默认,安装时即时生效)与缓存清理、**网络**(插件 net - 请求的超时与重试次数、代理、额外 CA 证书——内网自签 HTTPS 与公司代理都 - 在这里配,保存即生效,CA 路径写错会当场报错而不是等请求失败)、 - **登录凭据**存储方式、**诊断日志落盘**开关; +* 侧栏底部「设置」——设置项按类型分成五个页签(每页内容超出时可滚动,不会被 + 压扁),每页里保存按钮只作用于自己那一组: + * **插件**:插件管理入口(安装本地 .svfs、从服务器安装、升级/回滚/卸载)、 + 桥调试台(一键装调试插件)、启动时打开的插件; + * **服务器**:服务器地址(与服务器卡片同步生效)、登录凭据的存储方式、 + 消息推送(MQTT)的 broker/组织/前缀/账号(保存即重连); + * **存储**:数据目录(插件路径,写指针文件,重启生效)、对象缓存路径 + (留空 = 系统默认,安装时即时生效)与缓存清理; + * **网络**:插件 net 请求的超时与重试次数、代理、额外 CA 证书(内网自签 + HTTPS 与公司代理都在这里配,保存即生效,CA 路径写错会当场报错); + * **诊断**:诊断日志落盘开关、导出诊断报告; +* 侧栏底部的 **✉ 消息入口**(带未读数字)进「消息中心」:服务端推来的主机 + 消息按时间倒序列出(级别、标题、时间、来源、广播标记),顶部是 MQTT 通道 + 状态灯与「立即同步 / 全部已读 / 清空」——进页面即算已读,徽标不会一直挂着; * 侧栏底部的圆形头像(登录用户首字母)点击进「用户中心」:当前登录 - 状态、服务器地址、本次登录时间,以及退出登录 / 重新登录。 + 状态、服务器地址、本次登录时间,以及退出登录 / 重新登录; +* **退出登录 = 关掉主窗口并回到登录界面**:客户端是强制登录的,"未登录的 + 主窗口"本就不该存在,所以退出后不会留一个没有凭据的界面继续用。已打开的 + 插件页面会一并收掉(插件的键值存储按插件隔离、不区分用户,页面状态同理, + 换账号重进时重新加载);重新登录成功则用新会话把主窗口开回来,用户关掉 + 登录窗则退出客户端。退出登录时也会尽力通知服务器作废 token; * 插件页面底色固定为主题底色;WebEngine 在启动阶段预热(Profile 提前 创建 + 隐藏预热视图 + 共享 OpenGL 上下文)——首次打开插件不再闪白屏 或整窗闪烁。 @@ -494,12 +521,87 @@ await window.svfs.ui.setTitle('时钟 · 12:30'); // 需要 "title" 权限 // 需要 "dialog" 权限(v4+):宿主**原生对话框**,Promise 返回用户是否点了"确定" const ok = await window.svfs.ui.confirm({ title: '删除', text: '确定删除这条记录?' }); await window.svfs.ui.message({ kind: 'warn', title: '警告', text: '余额不足' }); + +// 需要 "messages" 权限(v5+):订阅服务端推给**本插件**的消息 +const off = (await window.svfsReady).messages.subscribe(function (message) { + // message.text 是原文;能解析成 JSON 时另有 message.json(仪表盘数据这类) + render(message.json || message.text); +}); +// off() 取消订阅 ``` `host.info` 不需要权限——它只包含插件自己的元数据、宿主版本号与配置的服务器 地址,不含用户数据。 +### 消息模块:服务端主动推送(MQTT) + +服务端要能随时把消息推给某个用户(或某个插件),走的是**统一消息总线**: +所有通道的消息先进总线,由总线按主题分类、去重、再分发。两条通道: + +| 通道 | 时机 | 说明 | +|---|---|---| +| MQTT 推送 | 实时 | 服务端主动推;broker 地址由登录响应下发(也可在设置里手配) | +| HTTP 补齐 | **登录成功后先做一次**,也可点「立即同步」 | `GET /api/messages?since=…` 把离线期间攒下的消息补回来 | + +两条路的消息都喂进同一个总线、按 id 去重,所以"推送"与"补齐"重叠、QoS 1 +重投都不会重复显示。 + +**主题方案**(组织 `org` 是租户位,从第一版就占好——多租户上线不用改格式, +broker 侧按 `//#` 下发 ACL 即可隔离): + +```text +//user//message 主机消息 → 消息中心 +//user//plugin/ 插件消息 → 只给该插件 +//broadcast/message 组织内广播 → 消息中心 +//broadcast/plugin/ 组织内广播 → 只给该插件 +``` + +* 登录后客户端只订阅两条(最小权限,不是整个组织):`//user//#` + 与 `//broadcast/#`; +* 用户名/插件 id 占一个主题层级,经**可逆**的百分号编码(`topic_segment`), + 所以 "a b" 与 "a_b" 不会串线;服务端用同一个函数(`svfs_client.topics`) + 拼主题,两端规则一致; +* 即使 broker 的 ACL 配错、把别人的消息推过来,总线还会按当前组织/用户再挡 + 一道(`forbidden` 计数 + 诊断日志)。 + +**两类消息泾渭分明**(分类只看主题,不看载荷): + +* **主机消息**进消息中心(侧栏底部的 ✉,带未读徽标):载荷是消息封皮 + `{id, title, body, level, time, source}`,纯字符串也当正文; +* **插件消息**不进消息中心:服务端发什么插件就收到什么(仪表盘数据、行情、 + 位号刷新这类业务载荷,补历史没有意义),宿主只负责按插件 id 投递。 + +插件页面是懒加载的,所以**消息可能比页面先到**:总线按插件缓存最近 50 条 +(超了丢最旧的并计数),页面打开后一次性补投;页面侧还会再排一次队 +(宿主在 DocumentCreation 阶段就立好投递入口),插件订阅时补发——所以 +插件写 `subscribe` 时不用担心"消息是不是已经来过了"。 + +```js +// 插件侧(清单里声明 "messages" 权限才会存在) +const svfs = await window.svfsReady; +svfs.messages.subscribe((message) => { + console.log(message.text, message.json); // 原文 + 尽量解析出的 JSON +}); +``` + +**配置与降级**: + +* broker 地址优先用登录响应下发的 `mqtt: {host, port, tls, prefix, org}` + (部署信息不该让每个用户手填)。**也可以在「设置 → 消息推送(MQTT)」里直接 + 配**:broker(接受 `host` / `host:1883` / `mqtts://host:8883`,URL 形式里的 + scheme 与端口优先)、端口、TLS、主题前缀、组织标识、broker 账号——保存即重连, + 不用等下次登录;那一节还会显示**生效**的配置与来源(手配 / 服务端下发); +* broker 密码不走设置界面:缺省用登录 token(已加密存储、不落明文);需要单独的 + broker 密码时由服务端在登录响应里下发(或写 `settings.json` 的 + `mqtt_password`)。落盘的键:`mqtt_host` / `mqtt_port` / `mqtt_tls` / + `mqtt_prefix` / `mqtt_username` / `mqtt_password` / `org`; +* MQTT 需要可选依赖:`pip install "sqlite-vfs[mqtt]"`(`paho-mqtt`)。没装、 + 没配 broker、连不上,都只是"没有实时推送"——界面在消息中心显示状态 + (未启用 / 连接中 / 已连接 / 连接失败),补齐照常工作; +* 断线重连交给 paho(指数退避 1→30 秒),重连成功后自动重新订阅; +* 连接状态、每个通道的收发与丢弃计数都进诊断日志(含 `forbidden`/`dropped`)。 + ### 发布 → 安装的完整链路 发布端用现成的 `svfs publish`,客户端用 manifest 安装(对象逐个 sha256 校验): @@ -548,6 +650,19 @@ API 供插件经 `net` 调用;可选 `SVFS_UPLOAD_TOKEN` 环境变量保护上 | `GET /api/me` | 带 Bearer token → `{"ok","username","login_time"}`,用于启动时静默校验 | | `POST /api/logout` | 作废当前 token | +消息相关(见上文「消息模块」): + +| 接口 | 说明 | +|---|---| +| `GET /api/messages?since=` | 客户端登录后/「立即同步」时补齐消息(只返回该登录用户的) | +| `POST /api/push` | 服务端主动推:`kind=message` 进消息中心 + 待收列表;`kind=plugin` 只走插件主题(`plugin` 字段指定目标插件,`data` 原样发给插件) | +| `POST /api/broadcast` | 组织广播(主题里带 `broadcast`) | + +配了 broker(`SVFS_MQTT_HOST`,可选 `SVFS_MQTT_PORT` / `SVFS_MQTT_TLS` / +`SVFS_ORG`)时,推送会真的发布到 broker,且 broker 地址随登录响应下发, +客户端自动连上;没配就只写待收列表(响应里如实说明 `mqtt=skipped`), +客户端仍能靠登录后的同步看到消息——这条降级路径让演示不依赖 broker。 + 另外提供可选的 `/client/manifest.json`(客户端自更新检查):设置环境变量 `SVFS_CLIENT_VERSION=9.9.9 SVFS_CLIENT_URL=https://…` 即可看到「客户端有新版本」 提示,不设置时返回 404(客户端静默跳过)。生产上这个文件直接静态托管即可, @@ -595,20 +710,32 @@ curl -H "Authorization: Bearer $token" http://127.0.0.1:8000/api/ping ### 实现分层(与 svfs-studio 同一套约定) `manifest` / `paths` / `registry` / `installer` / `sample` / `auth` / -`credentials` / `diagnostics` / `netutil` / `search` 是**纯标准库** -(不 import Qt,可在无 PySide6 的环境单独测试);`scheme` / `bridge` / -`webview` / `worker` / `login`(启动动画与登录窗)/ `shell` / `app` 属于 Qt 层。 +`credentials` / `diagnostics` / `netutil` / `search` / `topics` / `messages` / +`bus` / `mqtt` 是**纯标准库/无 Qt 依赖**(不 import Qt,可在无 PySide6 的环境 +单独测试;`mqtt` 的 paho 也是可选依赖,缺了只报 `missing` 状态); +`scheme` / `bridge` / `webview` / `worker` / `mqtt_service` / `messages_page` / +`login`(启动动画与登录窗)/ `shell` / `app` 属于 Qt 层。 界面复用 svfs-studio 的主题与基础部件,视觉与 Studio 一致。 测试(逻辑层纯标准库;界面层 offscreen,含一次真实的 WebEngine 冒烟——加载 -示例插件并验证桥注入与 storage 往返,环境起不来 WebEngine 时自动跳过): +示例插件并验证桥注入、storage 往返与消息订阅;环境起不来 WebEngine 时自动跳过)。 +**入口是 `tests/run_tests.py` 而不是直接 `python -m unittest`**:QtWebEngine 在 +offscreen 下的解释器退出路径会崩(测试全绿、退出码却是 139,CI 会判失败), +runner 拿到 unittest 的结果后用 `os._exit` 退出,绕开那段清理;测试真正崩在 +中途时退出码照样非 0: ```bash -python -m unittest discover -s tests -p "test_client_logic.py" -v -python -m unittest discover -s tests -p "test_client_auth.py" -v # 登录认证/网络策略(纯层) -python -m unittest discover -s tests -p "test_client_credentials.py" -v # 凭据存储(纯层) -python -m unittest discover -s tests -p "test_client_login_ui.py" -v # 登录窗/启动动画/桥注入 -python -m unittest discover -s tests -p "test_client_ui.py" -v +uv run python tests/run_tests.py # 全部(716 条) +uv run python tests/run_tests.py -v # 逐条打印 + +# 只跑某一层(同样建议走 runner,参数透传给 unittest discover) +uv run python tests/run_tests.py -p "test_client_logic.py" # 清单/路径/注册表/安装器/顺序编排(纯层) +uv run python tests/run_tests.py -p "test_client_auth.py" # 登录认证/网络策略(纯层) +uv run python tests/run_tests.py -p "test_client_credentials.py" # 凭据存储(纯层) +uv run python tests/run_tests.py -p "test_client_messages.py" # 消息主题/封皮/总线(纯层) +uv run python tests/run_tests.py -p "test_client_mqtt.py" # MQTT 通道(含自建 broker 端到端) +uv run python tests/run_tests.py -p "test_client_login_ui.py" # 登录窗/启动动画/桥注入 +uv run python tests/run_tests.py -p "test_client_ui.py" # 主窗口/协议层/WebEngine 冒烟 ``` @@ -892,12 +1019,13 @@ sqlite_vfs/ # 安装开发依赖 uv sync --dev -# 运行测试(测试是纯标准库 unittest,没有引入 pytest) -uv run python -m unittest discover -s tests +# 运行测试(测试是纯标准库 unittest,没有引入 pytest;入口见 tests/run_tests.py +# 的说明——QtWebEngine 的解释器退出路径会崩,直接 discover 会拿到非零退出码) +uv run python tests/run_tests.py # 只跑界面测试(offscreen,不需要显示器;未装含 GUI 的 extra 时会整体跳过) uv sync --extra gui -uv run python -m unittest discover -s tests -p "test_studio_ui.py" -v +uv run python tests/run_tests.py -p "test_studio_ui.py" -v # 构建包 uv build diff --git a/examples/plugin_server.py b/examples/plugin_server.py index e0ad83d..98af9f0 100644 --- a/examples/plugin_server.py +++ b/examples/plugin_server.py @@ -39,12 +39,15 @@ └── blobs// """ +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 @@ -53,6 +56,11 @@ 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() @@ -112,14 +120,27 @@ def login(body: LoginBody): 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} + 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 是否仍然有效。""" - return {"ok": True, **session} + """客户端启动时校验记住的 token 是否仍然有效。 + + 这里也要下发组织与 broker:静默校验通过后就**不再弹登录窗**,是客户端 + 拿到推送配置的另一条主路径。 + """ + return {"ok": True, **_session_extra(session["username"], + session.get("login_time", ""))} @app.post("/api/logout") @@ -218,6 +239,171 @@ async def upload_plugin(plugin_id: str, file: UploadFile = File(...), } +# ---------------------------------------------------------------------- +# 消息:服务端主动推给用户(MQTT),登录后客户端先同步一次补齐 +# ---------------------------------------------------------------------- +# +# 主题方案(组织标识在第一版就占好位子,多租户上线不用改格式): +# +# //user/<用户名>/message 主机消息 → 客户端的消息中心 +# //user/<用户名>/plugin/<插件id> 插件消息 → 只给该插件 +# //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))} + + # ---------------------------------------------------------------------- # 客户端自更新信息(可选) # ---------------------------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index ff910bc..28c2a1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,12 @@ fastapi = [ "uvicorn>=0.23", "python-multipart>=0.0.6", ] +# 消息模块的 MQTT 推送通道(服务端主动推消息给客户端)。 +# 不装也不影响消息中心:登录后的 HTTP 补齐照常工作,只是没有实时推送。 +# 客户端在缺这个库时会在消息中心显示"未启用 MQTT",不会报错。 +mqtt = [ + "paho-mqtt>=2.0", +] # 桌面工具用的 Qt 绑定。核心库与 CLI 依然零第三方依赖,所以放在 extra 里。 # # 三个 extra 的分工(都装着 PySide6,区别只在要不要 WebEngine): @@ -56,6 +62,9 @@ dev = [ "twine>=4.0.2", # starlette 1.6+ 的 TestClient(tests/test_plugin_server.py 用)需要它 "httpx2>=0.1", + # 消息模块的 MQTT 通道:测试里用真 paho 客户端对自建的最小 broker 跑端到端 + # (见 tests/test_client_mqtt.py),所以开发环境需要它 + "paho-mqtt>=2.0", ] [project.scripts] diff --git a/src/svfs_client/__init__.py b/src/svfs_client/__init__.py index 7bda103..3af66e8 100644 --- a/src/svfs_client/__init__.py +++ b/src/svfs_client/__init__.py @@ -33,4 +33,5 @@ APP_NAME = "SVFS Client" #: 自动附加 ``Authorization: Bearer <登录token>``(纯增量,v2 插件 #: 不受影响,在 v3 宿主上照常安装运行) #: 4 —— 新增 dialog 权限与 ui.confirm / ui.message(宿主原生对话框) -PLUGIN_API_VERSION = 4 +#: 5 —— 新增 messages 权限:服务端经消息总线推给插件的消息(svfs.messages) +PLUGIN_API_VERSION = 5 diff --git a/src/svfs_client/app.py b/src/svfs_client/app.py index 3fedc0f..0c4dc76 100644 --- a/src/svfs_client/app.py +++ b/src/svfs_client/app.py @@ -66,7 +66,12 @@ def create_app(argv=None): app.setOrganizationName("svfs-client") theme.apply_theme(app) # 客户端特有的少量补充样式:插件列表的行更"胖",页面栈背景与窗口一致 + # + # QStackedWidget 必须显式给不透明底色:主题只给 QMainWindow/QDialog 设了 + # 背景,页面栈因此是"透明"的——收缩侧栏时让出的那一条在重绘时序里会露出 + # 没被重画的底色,实际表现是页面左上角一块白方块(实测复现,加这条即消失)。 app.setStyleSheet(app.styleSheet() + f""" +QStackedWidget {{ background: {theme.BG}; }} QListWidget#PluginList {{ background: transparent; border: none; padding: 0; }} QListWidget#PluginList::item {{ padding: 6px 8px; border-radius: {theme.RADIUS}px; }} QListWidget#PluginRail {{ background: transparent; border: none; padding: 0; }} diff --git a/src/svfs_client/bridge.py b/src/svfs_client/bridge.py index d5d98cf..5f0d349 100644 --- a/src/svfs_client/bridge.py +++ b/src/svfs_client/bridge.py @@ -15,6 +15,8 @@ notify ui.notify(系统托盘通知;没有托盘时退回状 title ui.setTitle(改窗口标题) net net.request(宿主代发 HTTP 请求,url 或相对服务器地址的 path) dialog ui.confirm / ui.message(宿主原生对话框,见 uiDialog) +messages 收服务端推给本插件的消息(页面侧 svfs.messages.subscribe; + 推送由宿主经 runJavaScript 送进页面,不走槽) =================== ========================================================= host.info 不需要权限——它只包含插件自己的元数据、宿主版本号与配置的服务器 @@ -243,6 +245,17 @@ class SvfsBridge(QObject): accepted = box.exec() == QMessageBox.StandardButton.Ok return json.dumps({"ok": True, "accepted": accepted}) + # ---- messages(需要 "messages" 权限) --------------------------- + # + # 推送是"宿主 → 页面"的方向,没有槽可调:载荷由 PluginView 经 + # runJavaScript 送进页面(同 net 的应答投递,见 webview.py 的 + # __svfsPush)。这里只回答"这个插件能不能收"——页面侧据此决定要不要 + # 暴露 svfs.messages(没权限时它整个不存在,插件按能力缺失降级)。 + + @Slot(result=bool) + def messagesPermitted(self): + return self._allowed("messages") + # ---- net(需要 "net" 权限) ------------------------------------- # # slot 里绝不做网络 IO(会卡主线程):netRequest 只投递并返回请求 id, diff --git a/src/svfs_client/bus.py b/src/svfs_client/bus.py new file mode 100644 index 0000000..0832d0d --- /dev/null +++ b/src/svfs_client/bus.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""统一消息总线(纯标准库):所有通道的消息都先进这里,再按需分发。 + +通道有两条,汇到总线上之后再分流: + +* **MQTT 推送**(服务端主动推,实时); +* **HTTP 补齐**(登录后先同步、以及推送漏掉的)——见 worker 的 ``sync_messages``。 + +总线只做四件事: + +1. **分类**:按**主题**判定"主机消息(进消息中心)"还是"定向插件消息", + 不看载荷内容——仪表盘数据之类的业务载荷永远不会混进给人看的消息列表, + 主机消息也不会被投给插件; +2. **归属校验**:解析出的组织/用户必须与当前登录身份一致(broker 配错、 + ACL 没生效时把别人的消息推过来,这里挡掉并记日志); +3. **去重**:主机消息进 :class:`MessageLog`(按 id 去重);插件消息按 + "插件 + 去重键"记最近若干条——MQTT 的 QoS 1 可能重投,HTTP 补齐与推送 + 也会重叠,两条路叠在一起才最需要去重; +4. **分发**:主机消息落在日志里等界面来取;插件消息进**该插件的队列**, + 页面打开后再取走(插件页面是懒加载的,消息可能比页面先到)。 + +坏消息(畸形载荷、不认识的主题、越权归属)只记诊断日志并计数,不抛—— +消息通道不能因为一条坏消息就断掉。 +""" + +from collections import Counter, deque +from datetime import datetime + +from . import diagnostics, messages as messages_mod, topics + +__all__ = ["MessageBus"] + +#: 每个插件缓存的上限(页面没打开时先攒着;超了丢最旧的) +PLUGIN_BUFFER = 50 +#: 插件消息去重记忆的上限(按插件) +PLUGIN_SEEN = 200 + + +class MessageBus: + """消息总线。线程安全由调用方保证(客户端里统一在主线程使用)。""" + + def __init__(self, org="", user="", prefix=topics.DEFAULT_PREFIX, + log=None, plugin_buffer=PLUGIN_BUFFER): + self.prefix = prefix or topics.DEFAULT_PREFIX + #: 当前身份(空 = 不校验,测试与未登录场景)。同时用于过滤"别人的消息" + self.org = str(org or "") + self.user = str(user or "") + self.log = log if log is not None else messages_mod.MessageLog() + self.plugin_buffer = max(1, int(plugin_buffer)) + self._queues = {} # 插件 id → deque(未投递的消息) + self._seen = {} # 插件 id → deque(去重键) + self._counters = Counter() + + # ---- 身份 ------------------------------------------------------- + + def identify(self, org="", user=""): + """登录/切组织后更新身份;传空 = 退出登录(此后一律不收消息)。 + + 换身份时一并清掉上一个身份攒下的插件队列——消息是按人推的,留给下一个 + 人就是把两个账号的数据混在一起。 + """ + self.org = str(org or "") + self.user = str(user or "") + self._queues.clear() + self._seen.clear() + + # ---- 入口 ------------------------------------------------------- + + def deliver(self, topic, payload, received_time=""): + """收一条消息(来自任何通道),返回处理结果 dict(测试与日志都读它)。 + + 结果里的 ``action`` 取值:``host``(进消息中心)/ ``plugin``(投给 + 插件)/ ``duplicate`` / ``unknown``(主题不认识)/ ``forbidden`` + (不是发给这个组织/用户的)/ ``empty``(解析不出正文)。 + """ + self._counters["received"] += 1 + info = topics.parse(topic, self.prefix) + if info is None: + self._counters["unknown"] += 1 + diagnostics.log(f"不认识的消息主题: {topic}", "bus") + return {"action": "unknown", "topic": topic} + + if not self._allowed(info): + self._counters["forbidden"] += 1 + diagnostics.log( + f"丢弃不属于当前身份的消息(org={info['org']} " + f"user={info['user'] or '-'}): {topic}", "bus") + return {"action": "forbidden", "topic": topic, **info} + + if info["kind"] == "plugin": + return self._deliver_plugin(info, payload) + return self._deliver_host(info, payload, received_time) + + def _allowed(self, info): + """组织/用户是否与当前身份相符。 + + 没有身份(未登录 / 已退出登录)时**一律不收**:消息模块只在登录后 + 工作,退出登录后残留的推送不该再落进消息中心。 + """ + if not self.org: + return False + if info["org"] != self.org: + return False + if info["scope"] == "user" and self.user and info["user"] != self.user: + return False + return True + + def _deliver_host(self, info, payload, received_time): + message = messages_mod.parse_message(payload, received_time=received_time) + if message is None: + self._counters["empty"] += 1 + diagnostics.log(f"消息没有可展示的内容: {info}", "bus") + return {"action": "empty", "topic": info, **info} + message["scope"] = info["scope"] # user / broadcast(界面上做个标记) + stored = self.log.add(message) + if stored is None: + self._counters["duplicate"] += 1 + return {"action": "duplicate", "message": message, **info} + self._counters["host"] += 1 + return {"action": "host", "message": stored, **info} + + def _deliver_plugin(self, info, payload): + plugin_id = info["plugin"] + body = _decode(payload) + key = _plugin_key(info, body) + seen = self._seen.setdefault(plugin_id, deque(maxlen=PLUGIN_SEEN)) + if key in seen: + self._counters["duplicate"] += 1 + return {"action": "duplicate", "plugin": plugin_id, **info} + seen.append(key) + + queue = self._queues.setdefault(plugin_id, deque(maxlen=self.plugin_buffer)) + if len(queue) == queue.maxlen: + self._counters["dropped"] += 1 + diagnostics.log( + f"插件 {plugin_id} 的消息积压到 {self.plugin_buffer} 条,丢最旧的" + "(页面还没打开或没来取)", "bus") + queue.append({"payload": body, "time": _now(), "scope": info["scope"]}) + self._counters["plugin"] += 1 + return {"action": "plugin", "plugin": plugin_id, "payload": body, **info} + + # ---- 插件侧取用 ------------------------------------------------- + + def drain(self, plugin_id): + """取走某个插件攒下的消息(取完即清);没有则返回空列表。""" + queue = self._queues.pop(plugin_id, None) + return list(queue) if queue else [] + + def pending(self, plugin_id): + """看一眼某个插件攒了多少条(不取走)。""" + return len(self._queues.get(plugin_id, ())) + + def buffered_plugins(self): + return sorted(pid for pid, queue in self._queues.items() if queue) + + # ---- 观测 ------------------------------------------------------- + + def stats(self): + """计数快照(诊断报告/状态栏用)。""" + return dict(self._counters) + + def reset(self): + """清空消息与计数(退出登录/切换账号时用)。""" + self.log.clear() + self._queues.clear() + self._seen.clear() + self._counters.clear() + + +def _decode(payload): + if isinstance(payload, (bytes, bytearray)): + try: + return bytes(payload).decode("utf-8") + except UnicodeDecodeError: + return bytes(payload).decode("utf-8", "replace") + if isinstance(payload, str): + return payload + return "" if payload is None else str(payload) + + +def _plugin_key(info, body): + """插件消息的去重键:同一插件 + 同一正文 + 同一秒内视为重复。 + + 插件载荷是插件自己的协议,宿主不解析语义,所以只能按"内容 + 时间窗" + 粗去重——目的只是挡住 QoS 1 重投与推送/补齐的重叠,不动真业务。 + """ + scope = info.get("scope") or "" + return f"{scope}|{body}|{_now()[:19]}" + + +def _now(): + return datetime.now().astimezone().isoformat(timespec="seconds") diff --git a/src/svfs_client/dialogs.py b/src/svfs_client/dialogs.py index a89b8dd..5f20085 100644 --- a/src/svfs_client/dialogs.py +++ b/src/svfs_client/dialogs.py @@ -24,8 +24,11 @@ from PySide6.QtWidgets import ( QLineEdit, QMessageBox, QPushButton, + QScrollArea, QSpinBox, + QTabWidget, QVBoxLayout, + QWidget, ) from svfs_studio import format as fmt @@ -81,28 +84,82 @@ class SettingsDialog(QDialog): * ``startup_provider()`` —— 当前配置的启动插件 id(空 = 默认规则); * ``apply_startup(raw)`` —— MainWindow.set_startup_plugin,返回错误或 None; * ``on_net_settings_changed`` —— 网络项(代理/超时/CA)保存成功后的回调, - 壳层用它重探一次服务器状态灯。 + 壳层用它重探一次服务器状态灯; + * ``session_provider`` —— 返回当前登录会话的 callable,用来算"生效的 + MQTT 配置"(服务端下发的 broker 也算数); + * ``on_mqtt_settings_changed`` —— MQTT 项保存后的回调,壳层据此重连。 """ def __init__(self, base, apply_base_url, on_open_manager=None, plugins_provider=None, startup_provider=None, apply_startup=None, on_install_debug=None, - on_net_settings_changed=None, parent=None): + on_net_settings_changed=None, session_provider=None, + on_mqtt_settings_changed=None, parent=None): super().__init__(parent) self.base = base self._apply_base_url = apply_base_url # MainWindow.set_base_url self._on_open_manager = on_open_manager self._plugins_provider = plugins_provider self._apply_startup = apply_startup or (lambda plugin_id: None) + self._startup_provider = startup_provider or (lambda: "") self._on_install_debug = on_install_debug self._on_net_settings_changed = on_net_settings_changed + self._session_provider = session_provider or (lambda: None) + self._on_mqtt_settings_changed = on_mqtt_settings_changed self.setWindowTitle("设置") - self.setMinimumWidth(500) + self.setMinimumWidth(560) cfg = settings.load(base) - column = vbox(self, 16, 10) + outer = vbox(self, 12, 10) + + # 按类型分页签:设置项多起来之后一屏塞不下,分组比堆成一长条好找 + self.tabs = QTabWidget() + self.tabs.addTab(self._build_plugins_tab(cfg), "插件") + self.tabs.addTab(self._build_server_tab(cfg), "服务器") + self.tabs.addTab(self._build_storage_tab(cfg), "存储") + self.tabs.addTab(self._build_network_tab(cfg), "网络") + self.tabs.addTab(self._build_diagnostics_tab(cfg), "诊断") + outer.addWidget(self.tabs, 1) + + close_row = hbox() + close_row.addStretch(1) + close_button = QPushButton("关闭") + close_button.clicked.connect(self.accept) + close_row.addWidget(close_button) + outer.addLayout(close_row) + + # 初始尺寸:宽度取一个舒服的定值(自然 sizeHint 会被最长的那行提示撑到 + # 800+),高度给到能放下最"高"的页签;再窄再矮也不怕——每页都在滚动区里 + self.resize(660, 540) + + self._refresh_cache_size() + self._refresh_credentials() + self._refresh_mqtt_status() + + # ---- 行为 ------------------------------------------------------- + + # ---- 页签内容 --------------------------------------------------- + + @staticmethod + def _new_page(): + """页签内容容器:内容装在滚动区里。 + + 窗口被拉小、或某一页以后又加了设置项时,用户看到的是**滚动条**而不是 + 被压扁的控件(Qt 会把放不下的控件硬挤,很难看);页面自身是竖排布局, + 末尾由调用方加弹簧。返回 ``(挂到页签上的控件, 往里放内容的布局)``。 + """ + page = QWidget() + column = vbox(page, 16, 10) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.Shape.NoFrame) + scroll.setWidget(page) + return scroll, column + + def _build_plugins_tab(self, cfg): + """「插件」页签:插件相关的入口与行为。""" + page, column = self._new_page() - # ---- 插件管理(导航入口)---- manager_row = hbox(spacing=8) manager_text = vbox(spacing=2) manager_text.addWidget(_caption("插件管理")) @@ -116,7 +173,6 @@ class SettingsDialog(QDialog): manager_row.addWidget(open_manager_button) column.addLayout(manager_row) - # ---- 桥调试台(开发辅助)---- debug_row = hbox(spacing=8) debug_text = vbox(spacing=2) debug_text.addWidget(_caption("桥调试台")) @@ -130,7 +186,6 @@ class SettingsDialog(QDialog): column.addLayout(debug_row) column.addSpacing(6) - # ---- 启动时打开 ---- column.addWidget(_caption("启动时打开(默认规则:优先上次打开的插件," "没有则用第一个启用的插件)")) startup_row = hbox(spacing=8) @@ -138,7 +193,7 @@ class SettingsDialog(QDialog): self.startup_combo.addItem("默认规则", "") for plugin_id, name in (self._plugins_provider or (lambda: []))(): self.startup_combo.addItem(f"{name}({plugin_id})", plugin_id) - current_startup = (startup_provider or (lambda: ""))() + current_startup = self._startup_provider() startup_index = self.startup_combo.findData(current_startup) self.startup_combo.setCurrentIndex(max(0, startup_index)) startup_row.addWidget(self.startup_combo, 1) @@ -151,7 +206,13 @@ class SettingsDialog(QDialog): column.addWidget(self.startup_status) column.addSpacing(6) - # ---- 服务器地址 ---- + column.addStretch(1) + return page + + def _build_server_tab(self, cfg): + """「服务器」页签:连哪台服务器、以什么身份连、消息走哪条通道。""" + page, column = self._new_page() + column.addWidget(_caption("服务器地址(承载插件下载与接口服务)")) self.server_edit = QLineEdit(cfg.get("base_url") or "") self.server_edit.setFont(theme.font_mono(theme.SIZE_BASE)) @@ -172,7 +233,6 @@ class SettingsDialog(QDialog): column.addLayout(row) column.addSpacing(6) - # ---- 登录凭据(存在哪、受不受保护)---- column.addWidget(_caption("登录凭据")) self.credentials_label = QLabel("") self.credentials_label.setWordWrap(True) @@ -180,16 +240,89 @@ class SettingsDialog(QDialog): column.addWidget(self.credentials_label) column.addSpacing(6) - # ---- 数据目录(插件路径)---- + column.addWidget(_caption("消息推送(MQTT)")) + hint = QLabel( + "broker 地址通常由服务端在登录响应里下发(客户端不用手填);" + "这里填了就以此为准,留空即用下发的。改完立即重连。" + ) + theme.role(hint, "faint") + hint.setWordWrap(True) + column.addWidget(hint) + + mqtt_row = hbox(spacing=6) + mqtt_row.addWidget(QLabel("broker")) + self.mqtt_host_edit = QLineEdit(str(cfg.get("mqtt_host") or "")) + self.mqtt_host_edit.setFont(theme.font_mono(theme.SIZE_BASE)) + self.mqtt_host_edit.setPlaceholderText( + "host / host:1883 / mqtts://host:8883(留空 = 服务端下发)") + mqtt_row.addWidget(self.mqtt_host_edit, 1) + mqtt_row.addWidget(QLabel("组织")) + self.org_edit = QLineEdit(str(cfg.get("org") or "")) + self.org_edit.setFixedWidth(110) + self.org_edit.setPlaceholderText("default") + self.org_edit.setToolTip( + "消息主题里的租户段(多租户隔离靠它);留空 = 服务端下发或 default") + mqtt_row.addWidget(self.org_edit) + column.addLayout(mqtt_row) + + mqtt_row2 = hbox(spacing=6) + self.mqtt_tls_check = QCheckBox("TLS") + self.mqtt_tls_check.setChecked(bool(cfg.get("mqtt_tls"))) + mqtt_row2.addWidget(self.mqtt_tls_check) + mqtt_row2.addWidget(QLabel("端口")) + self.mqtt_port_spin = QSpinBox() + self.mqtt_port_spin.setRange(1, 65535) + try: + port = int(cfg.get("mqtt_port") or settings.MQTT_PORT_DEFAULT) + except (TypeError, ValueError): + port = settings.MQTT_PORT_DEFAULT + self.mqtt_port_spin.setValue(port) + mqtt_row2.addWidget(self.mqtt_port_spin) + mqtt_row2.addWidget(QLabel("主题前缀")) + self.mqtt_prefix_edit = QLineEdit(str(cfg.get("mqtt_prefix") or "svfs")) + self.mqtt_prefix_edit.setFixedWidth(90) + mqtt_row2.addWidget(self.mqtt_prefix_edit) + mqtt_row2.addStretch(1) + mqtt_save = QPushButton("保存并重连") + mqtt_save.clicked.connect(self._save_mqtt_settings) + mqtt_row2.addWidget(mqtt_save) + column.addLayout(mqtt_row2) + + mqtt_row3 = hbox(spacing=6) + mqtt_row3.addWidget(QLabel("broker 账号")) + self.mqtt_user_edit = QLineEdit(str(cfg.get("mqtt_username") or "")) + self.mqtt_user_edit.setPlaceholderText("留空 = 用登录用户名") + mqtt_row3.addWidget(self.mqtt_user_edit, 1) + column.addLayout(mqtt_row3) + self.mqtt_status = QLabel("") + theme.role(self.mqtt_status, "muted") + self.mqtt_status.setWordWrap(True) + column.addWidget(self.mqtt_status) + note = QLabel( + "broker 密码默认用登录 token(已加密存储,不落明文);" + "需要单独的 broker 密码时,由服务端在登录响应里下发。" + ) + theme.role(note, "faint") + note.setWordWrap(True) + column.addWidget(note) + column.addSpacing(6) + + column.addStretch(1) + return page + + def _build_storage_tab(self, cfg): + """「存储」页签:插件包、注册表与缓存落在哪里。""" + page, column = self._new_page() + column.addWidget(_caption("数据目录(注册表 / 设置 / 插件包;更改后重启生效)")) dir_row = hbox(spacing=8) - data_label = ElidedLabel(base) + data_label = ElidedLabel(self.base) theme.role(data_label, "mono") dir_row.addWidget(data_label, 1) open_button = QPushButton("打开目录") open_button.setObjectName("Ghost") open_button.clicked.connect( - lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(base)) + lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(self.base)) ) dir_row.addWidget(open_button) change_button = QPushButton("更改…") @@ -202,7 +335,6 @@ class SettingsDialog(QDialog): column.addWidget(self.data_status) column.addSpacing(6) - # ---- 对象缓存(路径可配置,安装时即时生效)---- column.addWidget(_caption("对象缓存路径(留空 = 系统默认)")) cache_edit_row = hbox(spacing=8) self.cache_edit = QLineEdit(str(cfg.get("cache_dir") or "")) @@ -222,7 +354,7 @@ class SettingsDialog(QDialog): column.addWidget(self.cache_status) cache_row = hbox(spacing=8) - self.cache_label = ElidedLabel(settings.resolve_cache_dir(base)) + self.cache_label = ElidedLabel(settings.resolve_cache_dir(self.base)) theme.role(self.cache_label, "mono") cache_row.addWidget(self.cache_label, 1) self.cache_size_label = QLabel("") @@ -235,14 +367,20 @@ class SettingsDialog(QDialog): column.addLayout(cache_row) column.addSpacing(6) - # ---- 网络(超时/重试/代理/CA)---- + column.addStretch(1) + return page + + def _build_network_tab(self, cfg): + """「网络」页签:所有对外请求的共同策略。""" + page, column = self._new_page() + column.addWidget(_caption("网络(插件 net 请求与服务器连接)")) net_row = hbox(spacing=6) net_row.addWidget(QLabel("超时")) self.timeout_spin = QSpinBox() self.timeout_spin.setRange(1, 600) self.timeout_spin.setSuffix(" 秒") - self.timeout_spin.setValue(settings.net_options(base)[0]) + self.timeout_spin.setValue(settings.net_options(self.base)[0]) self.timeout_spin.setToolTip("单次插件 net 请求的超时(默认 30 秒)") net_row.addWidget(self.timeout_spin) net_row.addSpacing(4) @@ -250,7 +388,7 @@ class SettingsDialog(QDialog): self.retries_spin = QSpinBox() self.retries_spin.setRange(0, 5) self.retries_spin.setSuffix(" 次") - self.retries_spin.setValue(settings.net_options(base)[1]) + self.retries_spin.setValue(settings.net_options(self.base)[1]) self.retries_spin.setToolTip( "只对幂等方法(GET/HEAD)生效:遇 502/503/504 或连接错误按指数退避重试" ) @@ -290,15 +428,21 @@ class SettingsDialog(QDialog): column.addWidget(self.net_error) column.addSpacing(6) - # ---- 关于 / 诊断 ---- + column.addStretch(1) + return page + + def _build_diagnostics_tab(self, cfg): + """「诊断」页签:版本、日志与排障。""" + page, column = self._new_page() + about_row = hbox(spacing=8) about = QLabel(f"{APP_NAME} v{__version__}") theme.role(about, "faint") about_row.addWidget(about, 1) self.log_check = QCheckBox("诊断日志落盘") - self.log_check.setChecked(settings.log_to_file(base)) + self.log_check.setChecked(settings.log_to_file(self.base)) self.log_check.setToolTip( - f"滚动写入 {paths.logs_dir(base)}(1 MB × 4 份);" + f"滚动写入 {paths.logs_dir(self.base)}(1 MB × 4 份);" "崩溃/重启后仍可回看,关掉则只保留内存里最近 600 条" ) self.log_check.toggled.connect(self._toggle_log_to_file) @@ -310,17 +454,8 @@ class SettingsDialog(QDialog): about_row.addWidget(doctor_button) column.addLayout(about_row) - close_row = hbox() - close_row.addStretch(1) - close_button = QPushButton("关闭") - close_button.clicked.connect(self.accept) - close_row.addWidget(close_button) - column.addLayout(close_row) - - self._refresh_cache_size() - self._refresh_credentials() - - # ---- 行为 ------------------------------------------------------- + column.addStretch(1) + return page def _refresh_credentials(self): """登录凭据存哪、受不受保护(明文兜底要看得见,否则没人会发现)。""" @@ -338,6 +473,47 @@ class SettingsDialog(QDialog): f"⚠ 存储方式:{where}(后端: {name})" ) + # ---- 页签 ------------------------------------------------------- + + def page(self, title): + """按标题取页签内容(找不到返回 None)。""" + for index in range(self.tabs.count()): + if self.tabs.tabText(index) == title: + return self.tabs.widget(index) + return None + + def show_tab(self, title): + """切到某个页签(找不到返回 False)——外部想"跳到设置项所在页"时用。""" + page = self.page(title) + if page is None: + return False + self.tabs.setCurrentWidget(page) + return True + + def _save_mqtt_settings(self): + """保存 MQTT 项并让壳层重连(改了 broker 不该等到下次登录才生效)。""" + data = settings.load(self.base) + data["mqtt_host"] = self.mqtt_host_edit.text().strip() + data["mqtt_port"] = int(self.mqtt_port_spin.value()) + data["mqtt_tls"] = bool(self.mqtt_tls_check.isChecked()) + data["mqtt_prefix"] = self.mqtt_prefix_edit.text().strip() or "svfs" + data["org"] = self.org_edit.text().strip() + data["mqtt_username"] = self.mqtt_user_edit.text().strip() + settings.save(self.base, data) + if callable(self._on_mqtt_settings_changed): + self._on_mqtt_settings_changed() + self._refresh_mqtt_status(applied="已保存并重连") + + def _refresh_mqtt_status(self, applied=""): + """显示**生效**的 MQTT 配置(含服务端下发的),并标明来源。""" + config = settings.mqtt_config(self.base, self._session_provider()) + text = settings.describe_mqtt(config) + if config.get("enabled") and not settings.load(self.base).get("mqtt_host"): + text += " · 来自服务端下发" + if applied: + text = f"{applied} · {text}" + self.mqtt_status.setText(text) + def _open_manager(self): self.accept() if self._on_open_manager: @@ -496,7 +672,13 @@ class SettingsDialog(QDialog): class UserCenterDialog(QDialog): - """用户中心:当前登录会话 + 退出/重新登录。""" + """用户中心:当前登录会话 + 退出/重新登录。 + + 「退出登录」按下后只清凭据(``on_logout``)并**关掉自己**,把界面去向交给 + 主窗口(``logout_requested``):登录门要关闭主窗口,而它没法从一个正在 + 运行的模态对话框里做(那会变成模态叠模态)。「重新登录」是普通回调转调, + 不需要这样拆。 + """ def __init__(self, base, session_provider, on_relogin=None, on_logout=None, parent=None): @@ -505,6 +687,8 @@ class UserCenterDialog(QDialog): self._session_provider = session_provider self._on_relogin = on_relogin self._on_logout = on_logout + #: 用户点了「退出登录」——主窗口据此接续登录门 + self.logout_requested = False self.setWindowTitle("用户中心") self.setMinimumWidth(400) @@ -589,6 +773,8 @@ class UserCenterDialog(QDialog): self.refresh() def _do_logout(self): + """退出登录:清凭据 + 关掉自己(登录门由主窗口接续,见类文档)。""" if self._on_logout: self._on_logout() - self.refresh() + self.logout_requested = True + self.accept() diff --git a/src/svfs_client/installer.py b/src/svfs_client/installer.py index eb27f5e..a56f538 100644 --- a/src/svfs_client/installer.py +++ b/src/svfs_client/installer.py @@ -32,6 +32,7 @@ from . import paths, registry __all__ = [ "InstallError", "is_newer_version", + "arrange", "client_update_status", "inspect_package", "install_from_file", @@ -70,6 +71,32 @@ def is_newer_version(candidate, current): return False +def arrange(entries, order): + """按用户编排的顺序排列插件(纯函数,界面与 worker 共用同一套规则)。 + + ``order`` 是插件的 id 列表(settings 里的 ``plugin_order``):列在前面的排 + 前面,保持列表内的相对次序;**没编排到的**(新装的、或 ``order`` 里残留着 + 但已卸载的 id 之后的)按名称排在后面。空 order = 全部按名称——与没有这个 + 功能之前的行为一致。 + + ``order`` 里可能出现已卸载插件的 id(不改注册表、只当没看见),也可能缺少 + 新装的插件——两种情况都在"未编排"这一档里,所以顺序永远稳定、可预期。 + """ + rank = {} + for index, plugin_id in enumerate(order or []): + rank.setdefault(str(plugin_id), index) + + def sort_key(entry): + plugin_id = str(entry.get("id") or "") + name = str(entry.get("name") or plugin_id).lower() + position = rank.get(plugin_id) + if position is None: + return (1, 0, name) + return (0, position, name) + + return sorted(entries, key=sort_key) + + def client_update_status(payload, current_version): """把 ``client/manifest.json`` 的内容解析成客户端更新检查结果。 diff --git a/src/svfs_client/manifest.py b/src/svfs_client/manifest.py index 08ddea4..0e6d62b 100644 --- a/src/svfs_client/manifest.py +++ b/src/svfs_client/manifest.py @@ -50,7 +50,9 @@ ENTRY_PATTERN = re.compile(r"^[A-Za-z0-9._][A-Za-z0-9._/-]*$") #: title —— 改窗口标题 #: net —— 经宿主请求服务端接口(绕开 CORS;url/path 见 svfs.net.request) #: dialog —— 宿主原生对话框(svfs.ui.confirm / svfs.ui.message) -PERMISSIONS = ("storage", "notify", "title", "net", "dialog") +#: messages —— 接收服务端推给本插件的消息(svfs.messages.subscribe; +#: 这类消息不进消息中心,是仪表盘数据这类业务载荷的通道) +PERMISSIONS = ("storage", "notify", "title", "net", "dialog", "messages") _REQUIRED = ("id", "name", "version", "entry", "api_version") diff --git a/src/svfs_client/messages.py b/src/svfs_client/messages.py new file mode 100644 index 0000000..7bd4241 --- /dev/null +++ b/src/svfs_client/messages.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""消息封皮与消息日志(纯标准库)。 + +主机消息(走到消息中心那条通道)的载荷是一个 JSON 对象: + +.. code-block:: json + + { + "id": "b7c1…", // 可选:去重用(没有就按内容+时间算) + "title": "备份完成", // 可选 + "body": "3 个包已同步", // 正文;纯字符串载荷也当正文用 + "level": "info", // info | warn | error(认不出来当 info) + "time": "2026-09-16T10:00:00", // 可选,ISO8601;缺失用收到的时间 + "source": "backup" // 可选,来源标记(服务端自定义) + } + +插件消息**不走这个封皮**:服务端发什么,插件就收到什么(插件自己的业务 +协议自己定),所以解析只在这一层做,且只在主机通道上做。 + +:class:`MessageLog` 是消息中心的列表模型(也是纯层):有上限的环形缓冲、 +未读计数、按 id 去重(MQTT 的 QoS 1 可能重投,HTTP 补齐与推送也可能重叠—— +去重放在这里,两条路就都干净了)。 +""" + +import json +import re +from datetime import datetime, timezone + +__all__ = [ + "LEVELS", + "MAX_MESSAGES", + "MessageLog", + "parse_message", + "message_key", +] + +#: 允许的级别(认不出来的一律当 info——服务端加了新级别不该让消息丢掉) +LEVELS = ("info", "warn", "error") + +#: 消息中心保留的条数上限(再多也不是给人看的了) +MAX_MESSAGES = 500 + +#: body/title 的长度上限(防一条超长消息把界面撑爆) +MAX_TEXT = 4000 + +_ISO_WITH_TZ = re.compile(r"(Z|[+-]\d{2}:?\d{2})$") + + +def _text(value, limit=MAX_TEXT): + text = "" if value is None else str(value) + return text[:limit] + + +def parse_message(payload, received_time=""): + """把主机通道的载荷解析成消息 dict;解析不出正文时返回 None。 + + 能吃的形态:JSON 对象(上文的封皮)、JSON 标量(当正文)、纯字符串。 + 空载荷、空对象、``{}`` 这种没内容的都返回 None——不往消息中心塞空气。 + """ + data = payload + if isinstance(payload, (bytes, bytearray)): + try: + payload = bytes(payload).decode("utf-8") + except UnicodeDecodeError: + payload = bytes(payload).decode("utf-8", "replace") + if isinstance(payload, str): + text = payload.strip() + if not text: + return None + try: + data = json.loads(text) + except ValueError: + data = {"body": text} # 不是 JSON:整段当正文 + + if data is None: + return None + if not isinstance(data, dict): + data = {"body": data} + + title = _text(data.get("title"), 200) + body = _text(data.get("body")) + if not title and not body: + return None + + level = str(data.get("level") or "info").lower() + if level not in LEVELS: + level = "info" + + return { + "id": _text(data.get("id"), 120), + "title": title, + "body": body, + "level": level, + "time": _text(data.get("time"), 40) or received_time or _now(), + "source": _text(data.get("source"), 120), + "unread": True, + } + + +def _now(): + return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + + +def message_key(message): + """去重键:优先用服务端给的 id;没有就按"时间 + 标题 + 正文"算。 + + 没有 id 的消息在服务端重发时会被当成两条——这是可接受的:宁可重复显示 + 也不要把两条真消息合成一条;有 id 时(推荐做法)重投/补齐都能去重。 + """ + if not message: + return "" + if message.get("id"): + return f"id:{message['id']}" + return "raw:{}|{}|{}".format(message.get("time") or "", + message.get("title") or "", + message.get("body") or "") + + +class MessageLog: + """消息中心的列表模型:环形缓冲 + 未读计数 + 去重。 + + 线程安全由调用方保证(客户端里只有一个线程碰它:MQTT 回调经 Qt 信号 + 排队到主线程后才写入)。 + """ + + def __init__(self, limit=MAX_MESSAGES): + self.limit = max(1, int(limit)) + self._items = [] # 旧 → 新 + self._keys = set() # 去重键 + + # ---- 读 --------------------------------------------------------- + + def __len__(self): + return len(self._items) + + def items(self): + """全部消息(旧 → 新)。""" + return list(self._items) + + def unread(self): + return sum(1 for item in self._items if item.get("unread")) + + def find(self, message_id): + for item in self._items: + if item.get("id") == message_id: + return item + return None + + # ---- 写 --------------------------------------------------------- + + def add(self, message): + """收一条消息;重复(去重键已存在)返回 None,否则返回存下的那条。""" + if not message: + return None + key = message_key(message) + if key in self._keys: + return None + self._items.append(message) + self._keys.add(key) + if len(self._items) > self.limit: + dropped = self._items[:-self.limit] + del self._items[:-self.limit] + for item in dropped: + self._keys.discard(message_key(item)) + return message + + def mark_all_read(self): + """全部标为已读,返回被改动的条数。""" + changed = 0 + for item in self._items: + if item.get("unread"): + item["unread"] = False + changed += 1 + return changed + + def mark_read(self, message_id): + item = self.find(message_id) + if item is None or not item.get("unread"): + return False + item["unread"] = False + return True + + def clear(self): + self._items = [] + self._keys = set() diff --git a/src/svfs_client/messages_page.py b/src/svfs_client/messages_page.py new file mode 100644 index 0000000..8beb9c6 --- /dev/null +++ b/src/svfs_client/messages_page.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""消息中心页面(Qt 层):服务端推给**这个用户**的消息。 + +只显示主机通道的消息——插件通道(仪表盘数据那类)由服务端按插件 id 定向 +推送,走的是同一条总线、不同的出口,**不会出现在这里**(页面顶部有一句 +说明,免得用户以为插件数据漏了)。 + +数据都来自 :class:`svfs_client.bus.MessageBus` 的消息日志;这个页面只负责 +画出来,不持有状态——所以"清空/标记已读"是改日志后让界面重画,而不是各存 +一份。 +""" + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from svfs_studio import theme +from svfs_studio.widgets.cards import ( + Badge, + Card, + EmptyState, + SectionTitle, + hbox, + vbox, +) + +from . import mqtt + +__all__ = ["MessageCenterPage", "MessageCard"] + +#: 级别 → Badge 状态 +_LEVEL_STATE = {"info": "muted", "warn": "warn", "error": "err"} +#: 级别 → 中文标签 +_LEVEL_LABEL = {"info": "信息", "warn": "警告", "error": "错误"} + + +class MessageCard(Card): + """一条消息的卡片:级别 + 标题 + 时间 + 正文。""" + + def __init__(self, message, parent=None): + super().__init__(parent, padding=theme.PAD, spacing=6) + self.message = message + + head = hbox(spacing=8) + self.level_badge = Badge(_LEVEL_LABEL.get(message.get("level"), "信息"), + _LEVEL_STATE.get(message.get("level"), "muted")) + head.addWidget(self.level_badge) + if message.get("unread"): + head.addWidget(Badge("未读", "accent")) + self.title_label = QLabel(message.get("title") or message.get("body", "")[:40] + or "(无标题)") + theme.role(self.title_label, "h2") + self.title_label.setWordWrap(True) + head.addWidget(self.title_label, 1) + self.scope_badge = Badge("广播", "muted") + # 广播消息标出来:它是发给整个组织的,不是单独发给你的 + self.scope_badge.setVisible(message.get("scope") == "broadcast") + head.addWidget(self.scope_badge) + self.time_label = QLabel(str(message.get("time") or "")) + theme.role(self.time_label, "faint") + head.addWidget(self.time_label) + self.add_layout(head) + + if message.get("body"): + body = QLabel(message["body"]) + body.setWordWrap(True) + body.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse) + self.add(body) + + meta = [] + if message.get("source"): + meta.append(f"来源: {message['source']}") + if message.get("id"): + meta.append(f"id: {message['id']}") + if meta: + meta_label = QLabel(" · ".join(meta)) + theme.role(meta_label, "faint") + meta_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse) + self.add(meta_label) + + +class MessageCenterPage(QWidget): + """消息中心:列表 + 状态灯 + 同步/已读/清空。""" + + syncRequested = Signal() + markReadRequested = Signal() + clearRequested = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + outer = vbox(self, 0, 0) + + header = hbox(margin=12, spacing=8) + self.title = SectionTitle( + "消息", + "服务端推给当前登录用户的消息;插件数据(仪表盘等)走各自的通道," + "不在这里显示", + ) + header.addWidget(self.title, 1) + self.transport_dot = QLabel() + self.transport_dot.setFixedSize(9, 9) + self.transport_dot.setStyleSheet( + f"background: {theme.TEXT_FAINT}; border-radius: 4px;") + header.addWidget(self.transport_dot) + self.transport_label = QLabel("未启用") + theme.role(self.transport_label, "muted") + header.addWidget(self.transport_label) + self.sync_button = QPushButton("立即同步") + self.sync_button.clicked.connect(self.syncRequested.emit) + header.addWidget(self.sync_button) + self.read_button = QPushButton("全部已读") + self.read_button.clicked.connect(self.markReadRequested.emit) + header.addWidget(self.read_button) + self.clear_button = QPushButton("清空") + self.clear_button.setObjectName("Ghost") + self.clear_button.clicked.connect(self.clearRequested.emit) + header.addWidget(self.clear_button) + outer.addLayout(header) + + body = QWidget() + self.cards_box = vbox(body, 10, 8) + self.cards_box.addStretch(1) # 卡片从顶部排起(末尾这个 stretch 一直留着) + + self.empty = EmptyState( + "还没有消息", + "服务端推送的消息会出现在这里。没连上实时通道时,点「立即同步」" + "从服务器补齐;插件数据不进这里——那是插件自己的通道。", + ) + outer.addWidget(self.empty) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(body) + outer.addWidget(scroll, 1) + + self._rendered = 0 # 已经画到第几条(消息日志是追加的,能增量画) + + # ---- 列表 ------------------------------------------------------- + + def set_messages(self, messages, reset=False): + """按消息日志刷新列表(默认增量:只画新增的尾部)。 + + ``reset=True``(清空/标记已读/首次进入)时整表重画——那些操作会改动 + 已有条目的显示,增量画会画不对。 + """ + messages = list(messages or []) + if reset or len(messages) < self._rendered: + self._clear_cards() + for message in messages[self._rendered:]: + card = MessageCard(message) + self.cards_box.insertWidget(self.cards_box.count() - 1, card) + self._rendered = len(messages) + self.empty.setVisible(not messages) + self.read_button.setEnabled(bool(messages)) + self.clear_button.setEnabled(bool(messages)) + + def _clear_cards(self): + while self.cards_box.count() > 1: # 末尾的 stretch 留着 + item = self.cards_box.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() + self._rendered = 0 + + # ---- 状态 ------------------------------------------------------- + + def set_transport_state(self, state, text): + """MQTT 通道状态灯:连上=绿、连接中/降级=黄、失败=红、未启用=灰。""" + colors = { + mqtt.STATE_CONNECTED: theme.OK, + mqtt.STATE_CONNECTING: theme.WARN, + mqtt.STATE_ERROR: theme.ERR, + mqtt.STATE_MISSING: theme.WARN, + } + color = colors.get(state, theme.TEXT_FAINT) + self.transport_dot.setStyleSheet( + f"background: {color}; border-radius: 4px;") + self.transport_dot.setToolTip(text or state) + self.transport_label.setText(text or mqtt.states().get(state, state)) diff --git a/src/svfs_client/mqtt.py b/src/svfs_client/mqtt.py new file mode 100644 index 0000000..1175dc1 --- /dev/null +++ b/src/svfs_client/mqtt.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""MQTT 推送通道(paho-mqtt 是可选依赖;本模块不 import Qt)。 + +职责刻意做得很窄:连 broker → 订阅当前身份的两条主题 → 把收到的 PUBLISH +交给统一总线。分类、去重、路由全在 bus.py / topics.py 里,所以这一层可以 +用假客户端和自建 broker 测(见 tests/test_client_mqtt.py),换 broker、换 +MQTT 版本都不影响业务逻辑。 + +三个刻意的设计: + +* **paho 没装不算故障**:状态报 ``missing``,界面提示"未启用实时推送",登录 + 后的 HTTP 补齐(worker 的 ``sync_messages``)照常把消息同步过来; +* **重连交给 paho**:``loop_start()`` + ``reconnect_delay_set()`` 的指数退避, + 这里只在重连成功后**重新订阅**(broker 不记得旧会话的订阅); +* **回调跑在 paho 的网络线程上**:本层只调 ``on_message(topic, payload)`` 与 + ``on_state(state)`` 两个回调,由调用方决定怎么把它挪回自己的线程(Qt 层用 + 排队信号,见 mqtt_service.py)。 +""" + +import random +import ssl +import string + +from . import diagnostics, topics + +__all__ = ["MqttTransport", "STATE_DISABLED", "STATE_MISSING", "STATE_CONNECTING", + "STATE_CONNECTED", "STATE_ERROR", "STATE_STOPPED", "states"] + +#: 连接状态(界面状态灯与诊断报告都读它) +STATE_DISABLED = "disabled" # 没配 broker(enabled=False) +STATE_MISSING = "missing" # 没装 paho-mqtt +STATE_CONNECTING = "connecting" # 正在连/等待重连 +STATE_CONNECTED = "connected" +STATE_ERROR = "error" # 连上了但 broker 拒绝(认证失败等) +STATE_STOPPED = "stopped" # 主动断开(退出登录/关窗) + +#: 重连退避区间(秒)——指数退避,交给 paho +RECONNECT_MIN_DELAY = 1 +RECONNECT_MAX_DELAY = 30 + + +def states(): + """状态 → 中文说明(界面提示用)。""" + return { + STATE_DISABLED: "未启用", + STATE_MISSING: "未安装 paho-mqtt,实时推送不可用", + STATE_CONNECTING: "连接中…", + STATE_CONNECTED: "已连接", + STATE_ERROR: "连接失败", + STATE_STOPPED: "已断开", + } + + +def _import_paho(): + """可选依赖:没装返回 None(调用方转成 ``missing`` 状态)。""" + try: + import paho.mqtt.client as mqtt + except Exception: # ImportError / 依赖损坏 + return None + return mqtt + + +class MqttTransport: + """一次登录期间的一条 MQTT 连接(``start`` / ``stop``)。""" + + def __init__(self, config, on_message=None, on_state=None, client=None): + """ + config: settings.mqtt_config() 的返回值(``enabled=False`` = 不连); + on_message: ``callable(topic, payload_bytes)`` —— 收到的消息(网络线程); + on_state: ``callable(state, detail)`` —— 状态变化(网络线程); + client: 注入的 paho 客户端(测试用;给了就不再自己建)。 + """ + self.config = dict(config or {}) + self._on_message = on_message + self._on_state = on_state + self._client = client + self.state = STATE_DISABLED + self.detail = "" + self.connected_count = 0 + self.received_count = 0 + self._stopping = False + + # ---- 主题 ------------------------------------------------------- + + def subscriptions(self): + """要订阅的主题(当前组织 + 当前用户)。""" + return topics.subscriptions(self.config.get("prefix"), self.config.get("org"), + self.config.get("username")) + + # ---- 生命周期 --------------------------------------------------- + + def start(self): + """开始连接(不阻塞)。返回当前状态。""" + if not self.config.get("enabled"): + return self._set_state(STATE_DISABLED, + "未配置 broker(设置里也没有 mqtt_host)") + client = self._client + if client is None: + mqtt = _import_paho() + if mqtt is None: + return self._set_state( + STATE_MISSING, + "需要可选依赖 paho-mqtt:pip install \"sqlite-vfs[mqtt]\"") + client = self._make_client(mqtt) + self._client = client + # 接线单独做:注入进来的客户端(测试替身)也要有线 + self._configure(client) + + self._stopping = False + self._set_state(STATE_CONNECTING, + f"{self.config.get('host')}:{self.config.get('port')}") + try: + client.connect_async(self.config.get("host"), self.config.get("port"), + self.config.get("keepalive") or 30) + client.loop_start() + except Exception as exc: # 参数不合法/DNS 之类:报状态,不抛 + diagnostics.log(f"MQTT 启动失败: {exc}", "mqtt") + return self._set_state(STATE_ERROR, str(exc)) + return self.state + + def stop(self): + """主动断开(退出登录/关窗)。可重复调用。""" + self._stopping = True + client = self._client + if client is None: + return + try: + client.disconnect() + client.loop_stop() + except Exception as exc: + diagnostics.log(f"MQTT 断开时出错(忽略): {exc}", "mqtt") + self._set_state(STATE_STOPPED, "已主动断开") + + def is_connected(self): + return self.state == STATE_CONNECTED + + # ---- paho 装配 -------------------------------------------------- + + def _make_client(self, mqtt): + """建 paho 客户端(回调 API 用 v2;老版本退回 1.x 风格)。""" + client_id = self._client_id() + kwargs = {"client_id": client_id, "clean_session": True} + api_version = getattr(mqtt, "CallbackAPIVersion", None) + if api_version is not None: + kwargs["callback_api_version"] = api_version.VERSION2 + try: + return mqtt.Client(**kwargs) + except TypeError: # 老版本 paho 不认 callback_api_version + kwargs.pop("callback_api_version", None) + return mqtt.Client(**kwargs) + + def _configure(self, client): + """鉴权、TLS、回调、重连退避(自建与注入的客户端走同一段)。""" + if self.config.get("username"): + client.username_pw_set(self.config.get("username"), + self.config.get("password") or None) + if self.config.get("tls"): + client.tls_set(cert_reqs=ssl.CERT_REQUIRED) + client.on_connect = self._on_connect + client.on_disconnect = self._on_disconnect + client.on_message = self._on_message_cb + if hasattr(client, "reconnect_delay_set"): + client.reconnect_delay_set(RECONNECT_MIN_DELAY, RECONNECT_MAX_DELAY) + + def _client_id(self): + """客户端 id:带随机后缀。 + + 固定 id 会让"同一个人开两个客户端"互相把对方踢下线(MQTT 的同 id + 互斥),随机后缀换来的是每次启动一条新会话(clean_session=True, + 本来也不指望 broker 记会话)。 + """ + org = _slug(self.config.get("org") or "default") + user = _slug(self.config.get("username") or "anon") + suffix = "".join(random.choice(string.ascii_lowercase + string.digits) + for _ in range(6)) + return f"svfs-{org}-{user}-{suffix}"[:120] + + # ---- paho 回调(网络线程) -------------------------------------- + + def _on_connect(self, client, userdata, flags, reason_code=0, properties=None): + code = getattr(reason_code, "value", reason_code) + try: + code = int(code) + except (TypeError, ValueError): + code = 1 + if code != 0: + # 连上了但被拒(认证失败/无权限):paho 会持续重试,但状态要如实报 + detail = f"broker 拒绝连接(code={code})" + diagnostics.log(f"MQTT {detail}", "mqtt") + self._set_state(STATE_ERROR, detail) + return + self.connected_count += 1 + for topic in self.subscriptions(): + client.subscribe(topic) + diagnostics.log(f"MQTT 已订阅 {topic}", "mqtt") + self._set_state(STATE_CONNECTED, + f"{self.config.get('host')}:{self.config.get('port')}") + + def _on_disconnect(self, client, userdata, flags=0, reason_code=0, + properties=None): + if self._stopping: + self._set_state(STATE_STOPPED, "已主动断开") + return + code = getattr(reason_code, "value", reason_code) + # 非主动断开:paho 会自动重连(指数退避),这里只报状态 + self._set_state(STATE_CONNECTING, + f"连接断开(code={code}),正在重连…") + + def _on_message_cb(self, client, userdata, message): + self.received_count += 1 + try: + self._on_message and self._on_message(message.topic, message.payload) + except Exception as exc: # 回调出错不能把 paho 的网络线程搞挂 + diagnostics.log(f"处理 MQTT 消息失败: {exc}", "mqtt") + + # ---- 状态 ------------------------------------------------------- + + def _set_state(self, state, detail=""): + changed = (state != self.state) or (detail != self.detail) + self.state = state + self.detail = detail + if changed: + diagnostics.log(f"MQTT 状态: {state}({detail})", "mqtt") + if self._on_state: + try: + self._on_state(state, detail) + except Exception: + pass + return state + + +def _slug(value): + """client id 里只留安全字符(broker 对 client id 的字符集有要求)。""" + return "".join(ch if ch.isalnum() else "_" for ch in str(value))[:32] or "anon" diff --git a/src/svfs_client/mqtt_service.py b/src/svfs_client/mqtt_service.py new file mode 100644 index 0000000..9e74daa --- /dev/null +++ b/src/svfs_client/mqtt_service.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""MQTT 通道的 Qt 适配(Qt 层):paho 的线程 → 总线/界面所在的主线程。 + +为什么需要这一层:paho 的回调跑在它自己的网络线程上,而统一总线、消息中心 +和插件页面都是主线程的东西(bus.py 明确写着"线程安全由调用方保证")。QObject +的信号跨线程 ``emit`` 时 Qt 会自动排队,所以回调里**只 emit**,其余全在主线程 +做——这条界线一旦划清,总线就永远只被一个线程碰。 + +对外只有四个动作:``start(config)`` / ``stop()`` / ``state`` / 两个信号 +(状态变化、收到消息)。 +""" + +from PySide6.QtCore import QObject, Signal + +from . import diagnostics, mqtt + +__all__ = ["MqttService"] + + +class MqttService(QObject): + """一条登录期间的 MQTT 连接(退出登录时 ``stop()``)。""" + + #: 连接状态变化(state, detail),主线程收到后更新状态灯 + stateChanged = Signal(str, str) + #: 收到一条推送(topic, payload 字节)——主线程把它交给总线 + received = Signal(str, object) + + def __init__(self, parent=None): + super().__init__(parent) + self._transport = None + self._state = mqtt.STATE_DISABLED + self._detail = "" + self.config = {} + + # ---- 生命周期 --------------------------------------------------- + + def start(self, config): + """按配置启动(已在跑就先停掉再起,便于"改设置后立刻生效")。""" + self.stop() + self.config = dict(config or {}) + if not self.config.get("enabled"): + self._set_state(mqtt.STATE_DISABLED, "未配置 broker") + return self._state + self._transport = mqtt.MqttTransport( + self.config, + # 回调在 paho 的网络线程:只 emit,Qt 会排队到主线程 + on_message=lambda topic, payload: self.received.emit(topic, payload), + on_state=self._on_state, + ) + self._transport.start() # 状态经 on_state 回到 _on_state + return self.refresh_state() + + def stop(self): + """断开(退出登录/切换账号/关窗)。""" + if self._transport is not None: + self._transport.stop() + self._transport = None + self.refresh_state() + + def is_running(self): + return self._transport is not None + + # ---- 状态 ------------------------------------------------------- + + def state(self): + return self._state + + def detail(self): + return self._detail + + def describe(self): + """一句话状态(消息中心的状态灯与诊断报告用)。""" + label = mqtt.states().get(self._state, self._state) + text = f"{label}:{self._detail}" if self._detail else label + if self._transport is not None and self._transport.is_connected(): + text += f"(已收 {self._transport.received_count} 条)" + return text + + def refresh_state(self): + """把 transport 的状态同步进服务(transport 内部也会推给 _on_state)。""" + if self._transport is not None: + self._state = self._transport.state + self._detail = self._transport.detail + return self._state + + def received_count(self): + return self._transport.received_count if self._transport else 0 + + def _on_state(self, state, detail): + self._state = state + self._detail = detail + diagnostics.log(f"MQTT 状态 {state}: {detail}", "mqtt") + self.stateChanged.emit(state, detail) + + def _set_state(self, state, detail): + self._state = state + self._detail = detail + self.stateChanged.emit(state, detail) diff --git a/src/svfs_client/netutil.py b/src/svfs_client/netutil.py index 781a631..7202d96 100644 --- a/src/svfs_client/netutil.py +++ b/src/svfs_client/netutil.py @@ -20,6 +20,7 @@ import os import ssl +import threading import time import urllib.error import urllib.parse @@ -28,7 +29,7 @@ import urllib.request from . import diagnostics __all__ = ["is_loopback", "configure", "apply_settings", "current", "urlopen", - "open_with_retry"] + "open_with_retry", "prepare_ssl"] #: 触发重试的服务器状态码(临时不可用/网关抖动;其它 4xx/5xx 重试没意义) RETRY_STATUS = (502, 503, 504) @@ -46,6 +47,40 @@ _ca_bundle = "" #: opener 缓存:key = (loopback, proxy, ca_bundle) _openers = {} +#: 默认 SSL context 只建一次(见 _ssl_context) +_default_context = None +_context_lock = threading.Lock() + + +def prepare_ssl(): + """在主线程提前把默认 SSL context 建好(启动时调一次)。 + + 为什么不在用的时候现建:``ssl.create_default_context()`` 在 Windows 上要 + 枚举**系统证书库**,这件事(实测)又慢又脆——全量测试跑出几百次以后会以 + Windows 断点异常(0x80000003)崩在证书枚举里。所以: + + * 进程内只建一次、加锁复用(见 :func:`_ssl_context`); + * 启动时在**主线程**建好,别让 worker 线程去碰系统证书库。 + + 返回新建的 context(已建过则返回缓存的那个);失败时返回 None(调用方照常 + 走系统默认行为,不至于因为预建失败就不能联网)。 + """ + try: + return _ssl_context() + except Exception as exc: # pragma: no cover - 环境级异常 + diagnostics.log(f"预建默认 SSL context 失败(忽略): {exc}", "net") + return None + + +def _ssl_context(): + """进程内共享的默认 SSL context(懒建 + 加锁)。""" + global _default_context + if _default_context is None: + with _context_lock: + if _default_context is None: + _default_context = ssl.create_default_context() + return _default_context + def is_loopback(url): """目标是否指向本机(host 为 localhost/127.0.0.1/::1 或 *.localhost)。""" @@ -113,14 +148,21 @@ def _opener(loopback): elif _proxy: handlers.append(urllib.request.ProxyHandler( {"http": _proxy, "https": _proxy})) + # 永远显式给 HTTPSHandler:不给的话 build_opener 会自己补一个,而它补的那个 + # 每次都要现建 SSL context(Windows 上 = 枚举系统证书库,见 prepare_ssl) + context = None if _ca_bundle: try: context = ssl.create_default_context(cafile=_ca_bundle) except (OSError, ssl.SSLError) as exc: diagnostics.log(f"CA 证书无法加载({_ca_bundle}): {exc}", "net") - context = None - if context is not None: - handlers.append(urllib.request.HTTPSHandler(context=context)) + if context is None: + try: + context = _ssl_context() + except Exception as exc: # 建不出来就退回系统默认行为 + diagnostics.log(f"默认 SSL context 不可用(忽略): {exc}", "net") + if context is not None: + handlers.append(urllib.request.HTTPSHandler(context=context)) opener = urllib.request.build_opener(*handlers) _openers[key] = opener return opener diff --git a/src/svfs_client/sample.py b/src/svfs_client/sample.py index 4a87859..8eed53d 100644 --- a/src/svfs_client/sample.py +++ b/src/svfs_client/sample.py @@ -28,7 +28,7 @@ PLUGIN_ID = "com.svfs.demo" #: 版本 → 说明(2.0.0 有可感知的变化,用来演示升级与回滚) CHANGELOG = { 1: "初始版本", - 2: "v2:新增主题色切换与变更记录", + 2: "v2:新增主题色切换、消息订阅与变更记录", } @@ -40,7 +40,7 @@ def _manifest(version): "api_version": PLUGIN_API_VERSION, "entry": "index.html", "icon": "icon.svg", - "permissions": ["storage", "notify", "title", "net", "dialog"], + "permissions": ["storage", "notify", "title", "net", "dialog", "messages"], "description": "演示/调试客户端桥的全部能力(host.info / storage / net / notify / title / 原生对话框)。", } @@ -159,6 +159,17 @@ _INDEX_TEMPLATE = """\
未请求。相对 path 会拼在客户端配置的服务器地址后。
+
+

消息(messages 权限,服务端主动推送)

+

服务端按插件 id 定向推送的消息;这类消息不进宿主的消息中心。

+
+ + + 已收 0 条 +
+
未订阅。
+
+

通知 / 标题(notify / title 权限)

@@ -287,6 +298,30 @@ async function main() { } }; + // ---- messages:订阅服务端推给本插件的消息(需要 messages 权限)---- + var msgCount = 0; + var msgLines = []; + $("msg-clear").onclick = function () { + msgCount = 0; msgLines = []; + $("msg-count").textContent = "0"; + show("msg-out", "(已清空记录)"); + }; + $("msg-listen").onclick = function () { + if (!svfs.messages) { + show("msg-out", "这个宿主没有 messages 能力(需要客户端支持桥 API v5+," + + "且清单里声明 messages 权限)"); + return; + } + svfs.messages.subscribe(function (message) { + msgCount += 1; + $("msg-count").textContent = String(msgCount); + var body = message.json ? JSON.stringify(message.json) : message.text; + msgLines.unshift("[" + msgCount + "] " + body); + show("msg-out", msgLines.slice(0, 20).join("\\n")); + }); + show("msg-out", "已订阅,等待服务端推送…(仪表盘数据这类业务载荷走的就是这条路)"); + }; + // ---- notify / title ---- $("notify").onclick = function () { svfs.ui.notify("来自 " + info.pluginId + " 的通知(" + PLUGIN_VERSION + ")"); diff --git a/src/svfs_client/settings.py b/src/svfs_client/settings.py index 2f05a4c..3180697 100644 --- a/src/svfs_client/settings.py +++ b/src/svfs_client/settings.py @@ -15,12 +15,21 @@ * ``auth_user`` —— 最近一次登录的用户名(登录界面用来预填)。 * ``startup_plugin`` —— 启动直显的插件 id(空 = 默认规则:上次打开的 > 第一个启用的插件;见 shell.MainWindow._resolve_startup_plugin)。 +* ``plugin_order`` —— 用户编排的插件顺序(id 列表;空 = 按名称排)。 + 只影响显示(侧栏 / 插件管理页 / 启动直显的默认规则),不动包与注册表。 * ``cache_dir`` —— 对象缓存目录(空 = 系统默认;安装插件时现读)。 * ``net_timeout`` —— 插件 ``net`` 请求的超时秒数(0 或负数 = 用默认值)。 * ``net_retries`` —— 幂等请求(GET/HEAD)失败后的重试次数上限。 * ``proxy`` —— HTTP 代理地址(空 = 跟随系统代理;loopback 目标永远直连)。 * ``ca_bundle`` —— 额外的 CA 证书文件(自签/内网 HTTPS 用;空 = 系统信任库)。 * ``log_to_file`` —— 诊断日志是否落盘(滚动文件,见 diagnostics.py)。 +* ``org`` —— 组织/租户标识(消息主题里的那一段;空 = 用登录响应下发的, + 再不行用 ``default``)。多租户上线时它决定订阅哪个组织的主题。 +* ``mqtt_host`` / ``mqtt_port`` / ``mqtt_tls`` / ``mqtt_prefix`` —— 消息推送的 + MQTT broker(``mqtt_host`` 为空就是**不启用推送**,消息中心仍可经 HTTP 补齐); + broker 也可以由服务端在登录响应里下发(``session["mqtt"]``),这时不用手配; +* ``mqtt_username`` / ``mqtt_password`` —— broker 鉴权(空 = 用登录用户名 / + 登录 token,见 :func:`mqtt_config`)。 设置文件很小、读一次毫秒级,允许在主线程同步读写(与 QSettings 同一档), 写仍然是原子的(临时文件 + ``os.replace``)。 @@ -29,13 +38,16 @@ import json import os import tempfile +import urllib.parse -from . import paths +from . import paths, topics __all__ = [ "DEFAULTS", "NET_TIMEOUT_DEFAULT", "NET_RETRIES_DEFAULT", + "MQTT_PORT_DEFAULT", + "MQTT_PORT_TLS", "load", "save", "normalize_base_url", @@ -45,6 +57,11 @@ __all__ = [ "net_options", "proxy_config", "log_to_file", + "plugin_order", + "set_plugin_order", + "org_id", + "mqtt_config", + "describe_mqtt", "apply_runtime", ] @@ -52,12 +69,19 @@ __all__ = [ NET_TIMEOUT_DEFAULT = 30 NET_RETRIES_DEFAULT = 1 +#: MQTT 默认端口(TLS 走 8883)与心跳(秒) +MQTT_PORT_DEFAULT = 1883 +MQTT_PORT_TLS = 8883 +MQTT_KEEPALIVE = 30 + DEFAULTS = { "base_url": "", "auth_token": "", "auth_user": "", # 启动直显的插件 id(空 = 默认规则:上次打开的 > 第一个启用的插件) "startup_plugin": "", + # 用户编排的插件顺序(id 列表;空 = 按名称) + "plugin_order": [], # 对象缓存目录(空 = 系统默认;安装时即时生效) "cache_dir": "", # 网络行为(见 net_options/proxy_config) @@ -67,6 +91,14 @@ DEFAULTS = { "ca_bundle": "", # 诊断日志落盘 "log_to_file": True, + # 组织/租户标识(消息主题里的那一段)与 MQTT 推送通道 + "org": "", + "mqtt_host": "", + "mqtt_port": MQTT_PORT_DEFAULT, + "mqtt_tls": False, + "mqtt_prefix": topics.DEFAULT_PREFIX, + "mqtt_username": "", + "mqtt_password": "", } #: 相对地址能接受的形态:插件 id(com.example.clock)或带斜杠的相对路径 @@ -75,9 +107,15 @@ _MANIFEST_SUFFIX = "manifest.json" def load(base): """读设置;缺文件或字段缺失都用默认值补齐。坏文件视为未配置(不抛异常—— - 设置坏了不该让客户端起不来)。""" + 设置坏了不该让客户端起不来)。 + + 列表型的值(如 ``plugin_order``)返回**副本**:DEFAULTS 是模块级对象, + 直接把里面的列表交给调用方,一处改动就会污染之后所有读取。 + """ file = paths.settings_file(base) data = dict(DEFAULTS) + for key, default in DEFAULTS.items(): + data[key] = list(default) if isinstance(default, list) else default try: with open(file, "r", encoding="utf-8") as handle: stored = json.load(handle) @@ -85,7 +123,10 @@ def load(base): return data if isinstance(stored, dict): for key, default in DEFAULTS.items(): - data[key] = stored.get(key, default) + value = stored.get(key, default) + if isinstance(default, list) and not isinstance(value, list): + value = list(default) # 类型不对(手改坏了)→ 回默认,不崩 + data[key] = list(value) if isinstance(value, list) else value return data @@ -223,6 +264,141 @@ def log_to_file(base): return bool(load(base).get("log_to_file", True)) +def plugin_order(base): + """用户编排的插件顺序(id 列表;空 = 按名称排)。 + + 只返回字符串 id,顺序与去重由 :func:`set_plugin_order` 保证;列表里可能 + 还留着已卸载插件的 id——重排时按"没装当没看见"处理(见 installer.arrange)。 + """ + value = load(base).get("plugin_order") or [] + return [str(item) for item in value if isinstance(item, str) and item] + + +def org_id(base, session=None): + """生效的组织/租户标识:登录响应 > 登录响应里的 broker 段 > 设置 > ``default``。 + + 登录响应里带 ``org`` 时以它为准(服务端知道你在哪个组织);设置里的 + ``org`` 用于"服务端没下发、但部署上要固定成某个组织"的场景。 + """ + announced = (session or {}).get("mqtt") + announced = announced if isinstance(announced, dict) else {} + for candidate in ((session or {}).get("org"), announced.get("org"), + load(base).get("org")): + value = str(candidate or "").strip() + if value: + return value + return topics.DEFAULT_ORG + + +def _explicit(data, key): + """设置里这一项是否被显式改过(与默认值不同即视为显式)。""" + return data.get(key) != DEFAULTS.get(key) + + +def _broker_from_host(raw, tls, port): + """把 host 归一成 ``(host, port, tls)``。 + + 允许三种写法:``broker.example``、``broker.example:1884``、 + ``mqtts://broker.example:8883``。写了 URL 形式就以它为准(scheme 决定 + ``tls``、端口写在里面就带出来)——"地址写全了"不该再被另外两个字段覆盖。 + """ + raw = str(raw or "").strip() + if not raw: + return "", port, tls + if "://" in raw: + parsed = urllib.parse.urlsplit(raw) + tls = parsed.scheme.lower() in ("mqtts", "ssl", "tls") + return (parsed.hostname or "", + parsed.port or (MQTT_PORT_TLS if tls else MQTT_PORT_DEFAULT), + tls) + if ":" in raw: + host, _, port_text = raw.rpartition(":") + if host and port_text.isdigit(): + return host, int(port_text), tls + return raw, port, tls + + +def mqtt_config(base, session=None): + """生效的 MQTT 连接参数(``enabled=False`` 表示不启用推送)。 + + 优先级:**设置里显式改过的项 > 登录响应下发的 > 设置里的默认值**。 + 让服务端能下发是有意的——broker 地址属于部署信息,不该让每个用户手填; + 留出设置项是为了"服务端没下发"与本地调试。 + + 注意这里判的是"显式改过"而不是"有值":``mqtt_port`` / ``mqtt_tls`` 这类 + 字段有默认值,若按"有值就优先",服务端下发的端口/TLS 永远被 1883/false + 压掉——那是真踩过的坑。 + + ``username`` / ``password`` 缺省用登录身份:用户名用登录名,密码用登录 + token(常见做法,broker 侧拿同一个 token 校验)。返回的 dict 直接喂给 + :class:`svfs_client.mqtt.MqttTransport`。 + """ + data = load(base) + announced = (session or {}).get("mqtt") + announced = announced if isinstance(announced, dict) else {} + + def pick(key, announced_key, default): + if _explicit(data, key): + return data.get(key) + value = announced.get(announced_key) + if value in (None, ""): + return data.get(key, default) + return value + + tls = bool(pick("mqtt_tls", "tls", False)) + port = pick("mqtt_port", "port", MQTT_PORT_TLS if tls else MQTT_PORT_DEFAULT) + try: + port = int(port) or (MQTT_PORT_TLS if tls else MQTT_PORT_DEFAULT) + except (TypeError, ValueError): + port = MQTT_PORT_TLS if tls else MQTT_PORT_DEFAULT + host, port, tls = _broker_from_host(pick("mqtt_host", "host", ""), tls, port) + prefix = str(pick("mqtt_prefix", "prefix", topics.DEFAULT_PREFIX) or "") + + return { + "enabled": bool(host), + "host": host, + "port": port, + "tls": tls, + "prefix": prefix.strip("/") or topics.DEFAULT_PREFIX, + "org": org_id(base, session), + "username": str(pick("mqtt_username", "username", "") + or (session or {}).get("username") or ""), + "password": str(pick("mqtt_password", "password", "") + or (session or {}).get("token") or ""), + "keepalive": MQTT_KEEPALIVE, + } + + +def describe_mqtt(config): + """给界面/诊断用的一句话(不启用、缺库、连不上都是不同的话)。""" + if not config: + return "未配置" + if not config.get("enabled"): + return "未启用(服务端未下发 broker,也没手配 mqtt_host)" + scheme = "mqtts" if config.get("tls") else "mqtt" + return (f"{scheme}://{config.get('host')}:{config.get('port')}" + f"(org={config.get('org')},prefix={config.get('prefix')})") + + +def set_plugin_order(base, ids): + """保存编排顺序(去重 + 保序),返回写入的列表。 + + 界面上"上移/下移"实际就是把当前显示顺序整份存下来——不做增量 diff, + 省得为了一个显示偏好维护第二份状态。 + """ + cleaned = [] + for item in ids or []: + if not item: # None / "" 这种空位直接丢(不能 str 成 "None" 混进去) + continue + item = str(item) + if item not in cleaned: + cleaned.append(item) + data = load(base) + data["plugin_order"] = cleaned + save(base, data) + return cleaned + + def apply_runtime(base): """把设置应用到运行时:日志落盘 + 代理/CA(返回错误信息,None = 成功)。 @@ -235,6 +411,8 @@ def apply_runtime(base): diagnostics.configure( paths.logs_dir(base) if log_to_file(base) else None) + # 在主线程把系统证书库那次枚举做掉(见 netutil.prepare_ssl 的说明) + netutil.prepare_ssl() error = netutil.apply_settings(base) if error: diagnostics.log(f"网络设置未生效: {error}", "startup") diff --git a/src/svfs_client/shell.py b/src/svfs_client/shell.py index f18b5ff..32582d4 100644 --- a/src/svfs_client/shell.py +++ b/src/svfs_client/shell.py @@ -9,6 +9,7 @@ payload → :meth:`MainWindow.apply_payload` 把 payload 扇给侧栏与管理 刷新时只填数据不重连——否则一次刷新叠一层连接,点一次按钮触发 N 次。 """ +import json import os from PySide6.QtCore import ( @@ -65,11 +66,14 @@ from svfs_studio.widgets.cards import ( vbox, ) -from . import (APP_NAME, credentials, diagnostics, paths, sample, settings, - search as search_mod, __version__) +from . import (APP_NAME, credentials, diagnostics, mqtt, paths, sample, settings, + search as search_mod, topics, __version__) from .app import _logo_pixmap +from .bus import MessageBus +from .messages_page import MessageCenterPage +from .mqtt_service import MqttService from .dialogs import SettingsDialog, UserCenterDialog, avatar_pixmap -from .installer import is_newer_version +from .installer import arrange, is_newer_version from .scheme import SvfsSchemeHandler, make_lookup, plugin_url from .webview import PluginView, QWebEngineView, create_profile @@ -179,10 +183,27 @@ class PluginCard(Card): self.uninstall_button.setObjectName("Danger") actions.addWidget(self.verify_button) actions.addWidget(self.uninstall_button) + # 顺序编排:移动一位(侧栏与管理页都跟随);边界上不可用 + actions.addSpacing(6) + self.up_button = QPushButton("↑") + self.up_button.setObjectName("Ghost") + self.up_button.setFixedSize(26, 26) + self.up_button.setToolTip("上移一位(编排侧栏与启动默认顺序)") + self.down_button = QPushButton("↓") + self.down_button.setObjectName("Ghost") + self.down_button.setFixedSize(26, 26) + self.down_button.setToolTip("下移一位(编排侧栏与启动默认顺序)") + actions.addWidget(self.up_button) + actions.addWidget(self.down_button) self.add_layout(actions) self.fill() + def set_move_state(self, index, count): + """按位置启用/禁用 ↑↓:第一个不能上移、最后一个不能下移。""" + self.up_button.setEnabled(index > 0) + self.down_button.setEnabled(index < count - 1) + def fill(self): """按当前 entry 刷新显示(不连接任何信号)。""" entry = self.entry @@ -254,6 +275,8 @@ class ManagerPage(QWidget): versionRequested = Signal(str, int) serverEditRequested = Signal() upgradeRequested = Signal(str) + moveRequested = Signal(str, int) # 插件 id, 位移(-1 上移 / +1 下移) + orderResetRequested = Signal() def __init__(self, parent=None): super().__init__(parent) @@ -265,6 +288,11 @@ class ManagerPage(QWidget): "每个插件是一个完整的 .svfs 前端包;升级即新快照,切换版本即回滚", ) header.addWidget(self.title, 1) + self.order_reset_button = QPushButton("恢复默认顺序") + self.order_reset_button.setObjectName("Ghost") + self.order_reset_button.setToolTip("清掉手动编排的顺序,回到按名称排") + self.order_reset_button.hide() + header.addWidget(self.order_reset_button) self.check_updates_button = QPushButton("检查更新") header.addWidget(self.check_updates_button) self.install_url_button = QPushButton("从服务器安装…") @@ -325,8 +353,15 @@ class ManagerPage(QWidget): self.cards = {} self._updates = {} # 插件id → 服务器上的新版本(检查更新结果) + self._ordered = False # 当前顺序是不是用户编排过的(决定复位按钮显示) self._banner_action = None # 当前横幅动作按钮要做的事(见 show_message) self.banner.action.connect(self._on_banner_action) + self.order_reset_button.clicked.connect(self.orderResetRequested.emit) + + def set_order_arranged(self, arranged): + """告知"当前顺序是用户编排过的"——"恢复默认顺序"按钮据此显隐。""" + self._ordered = bool(arranged) + self.order_reset_button.setVisible(self._ordered) # ---- 数据 ---- @@ -336,7 +371,8 @@ class ManagerPage(QWidget): card = self.cards.pop(gone) card.setParent(None) card.deleteLater() - for entry in entries: + count = len(entries) + for index, entry in enumerate(entries): card = self.cards.get(entry["id"]) if card is None: card = PluginCard(entry) @@ -347,7 +383,10 @@ class ManagerPage(QWidget): card.entry = entry card.fill() card.set_update(self._updates.get(entry["id"])) + card.set_move_state(index, count) self.empty.setVisible(not entries) + # "恢复默认顺序"只在真的编排过时才出现(没编排时它没有意义) + self.order_reset_button.setVisible(bool(self._ordered)) def set_base_url(self, url): """刷新服务器地址的显示(空串 = 未配置)。""" @@ -390,6 +429,8 @@ class ManagerPage(QWidget): card.uninstall_button.clicked.connect(lambda: self.uninstallRequested.emit(pid)) card.upgrade_button.clicked.connect( lambda: self.upgradeRequested.emit(pid)) + card.up_button.clicked.connect(lambda: self.moveRequested.emit(pid, -1)) + card.down_button.clicked.connect(lambda: self.moveRequested.emit(pid, 1)) card.version_combo.currentIndexChanged.connect(self._on_version_combo) def _on_version_combo(self): @@ -484,6 +525,8 @@ class MainWindow(QMainWindow): self._build_ui() self._restore_state() self.refresh() + # 到这一步登录已经成功(app.main 的顺序):先同步消息,再靠推送续上 + self._start_messaging(self._session) # ------------------------------------------------------------------ # UI 构建 @@ -550,7 +593,7 @@ class MainWindow(QMainWindow): self.rail_list.hide() side_box.addWidget(self.rail_list, 1) - # ---- 侧栏底部:账号条(头像 + 用户名 → 用户中心)+ 设置齿轮 ---- + # ---- 侧栏底部:账号条(头像 + 用户名 → 用户中心)+ 消息 + 设置齿轮 ---- self._bottom_box = bottom = hbox(spacing=6) self.user_button = QPushButton("未登录") self.user_button.setObjectName("Ghost") @@ -558,6 +601,12 @@ class MainWindow(QMainWindow): self.user_button.setToolTip("用户中心") self.user_button.setCursor(Qt.CursorShape.PointingHandCursor) bottom.addWidget(self.user_button, 1) + self.messages_button = QPushButton("✉") + self.messages_button.setObjectName("Ghost") + self.messages_button.setFixedSize(30, 30) + self.messages_button.setToolTip("消息中心") + self.messages_button.setCursor(Qt.CursorShape.PointingHandCursor) + bottom.addWidget(self.messages_button) self.settings_button = QPushButton("⚙") self.settings_button.setObjectName("Ghost") self.settings_button.setFixedSize(30, 30) @@ -571,6 +620,8 @@ class MainWindow(QMainWindow): self.stack = QStackedWidget() self.manager = ManagerPage() self.stack.addWidget(self.manager) + self.messages_page = MessageCenterPage() + self.stack.addWidget(self.messages_page) layout.addWidget(self.stack, 1) self.setCentralWidget(central) @@ -589,13 +640,14 @@ class MainWindow(QMainWindow): self.search_edit.textChanged.connect(self._on_search_changed) self.collapse_button.clicked.connect(self._toggle_sidebar) self.user_button.clicked.connect(self._open_user_center) + self.messages_button.clicked.connect(self._show_messages) self.settings_button.clicked.connect(self._open_settings) self.manager.install_button.clicked.connect(self._install_file) self.manager.install_url_button.clicked.connect(self._install_url) self.manager.check_updates_button.clicked.connect(self._check_updates) self.manager.server_edit_button.clicked.connect(self._edit_base_url) self.manager.server_login_button.clicked.connect(self._relogin) - self.manager.server_logout_button.clicked.connect(self.logout) + self.manager.server_logout_button.clicked.connect(self._logout_with_gate) self.manager.openRequested.connect(self.open_plugin) self.manager.uninstallRequested.connect(self._uninstall) @@ -604,6 +656,8 @@ class MainWindow(QMainWindow): self.manager.versionRequested.connect(self._switch_version) self.manager.upgradeRequested.connect(self._upgrade_plugin) self.manager.serverEditRequested.connect(self._edit_base_url) + self.manager.moveRequested.connect(self._move_plugin) + self.manager.orderResetRequested.connect(self._reset_plugin_order) self.manager.set_base_url(settings.load(self.base).get("base_url") or "") self.manager.set_session(self._session) @@ -631,6 +685,17 @@ class MainWindow(QMainWindow): self.client.log.connect(lambda line: self.status_label.setText(line)) # 托盘:把插件的 notify 变成真正的系统通知(不可用则退回界面内提示) self._tray = self._setup_tray() + # 消息模块:总线(分类/去重/路由,主线程)+ MQTT 通道(paho 的网络线程 + # 经排队信号把消息送进来) + self.bus = MessageBus() + self.mqtt = MqttService(self) + self.mqtt.received.connect(self._on_push) + self.mqtt.stateChanged.connect(self._on_mqtt_state) + self.messages_page.syncRequested.connect(self._sync_messages) + self.messages_page.markReadRequested.connect(self._mark_messages_read) + self.messages_page.clearRequested.connect(self._clear_messages) + self._messaging_key = None + self._pending_sync = False def _fill_sidebar(self, current_id=None): """按当前搜索词填充两套列表(完整列表 + 图标栏),并恢复选中。 @@ -701,9 +766,11 @@ class MainWindow(QMainWindow): # ------------------------------------------------------------------ def _resolve_startup_plugin(self): - """启动直显的插件:配置 > 上次打开 > 按 id 排序第一个启用的插件。 + """启动直显的插件:配置 > 上次打开 > 显示顺序里第一个启用的插件。 - 都不满足(没有插件/全部禁用)返回 None——停在插件管理页。 + 第三个候选用的是**编排后的显示顺序**(而不是按 id 排)——用户把某个 + 插件排到最前面,默认打开它才符合直觉。都不满足(没有插件/全部禁用) + 返回 None——停在插件管理页。 """ candidates = [settings.load(self.base).get("startup_plugin") or ""] candidates.append(_ui_settings().value("ui/last_plugin", "", type=str) or "") @@ -711,7 +778,7 @@ class MainWindow(QMainWindow): entry = self.entry_of(plugin_id) if entry and entry.get("enabled", False): return plugin_id - for entry in sorted(self._plugins, key=lambda e: e["id"]): + for entry in self._plugins: # 已按 plugin_order 编排 if entry.get("enabled", False): return entry["id"] return None @@ -736,6 +803,7 @@ class MainWindow(QMainWindow): def apply_payload(self, payload): plugins = payload.get("plugins", []) self._plugins = plugins + self.manager.set_order_arranged(bool(settings.plugin_order(self.base))) # --open 的启动意图:列表就绪后打开指定插件 pending_open = getattr(self, "_pending_open", None) @@ -787,8 +855,9 @@ class MainWindow(QMainWindow): _plugin_icon(entry.get("icon_bytes"), entry["id"]).pixmap(34, 34) ) - # 若当前页面被收掉,回到管理页 - if self.stack.currentIndex() > 0 and self._current_view_id() is None: + # 若当前页面是已卸载/禁用的插件视图,回到管理页(消息中心不算) + if (self.stack.currentWidget() not in (self.manager, self.messages_page) + and self._current_view_id() is None): self._show_manager() if payload.get("message"): @@ -854,6 +923,10 @@ class MainWindow(QMainWindow): view.sessionRejected.connect(self._on_session_rejected) self._views[entry["id"]] = view self.stack.addWidget(view) + # 页面加载完成后再补投"页面没开时收到的"插件消息:更早投递时页面侧 + # 的 window.__svfsPush 可能还没立起来,消息会白丢 + view.page().loadFinished.connect( + lambda _ok, pid=entry["id"]: self._flush_plugin_pushes(pid)) view.load_entry(entry.get("entry")) return view @@ -951,6 +1024,8 @@ class MainWindow(QMainWindow): self._relogin_prompted = False self.manager.set_session(self._session) self._update_user_chip() + # 登录成功 → 同步 + 订阅;退出登录 → 断开并清掉上一个身份的消息 + self._start_messaging(self._session) def _on_session_rejected(self, detail): """插件的 net 调用暴露"登录已失效"(宿主注入的 token 被 401 拒绝)。 @@ -990,12 +1065,22 @@ class MainWindow(QMainWindow): self.status_label.setText(f"已登录 {session.get('username') or ''}") def logout(self): - """退出登录:清掉本地凭据,并尽力通知服务器作废 token。""" + """退出登录:清掉本地凭据、尽力通知服务器作废 token、收掉插件页面。 + + 这里只做"把凭据和会话清干净",不负责界面去向——界面由 + :meth:`_relogin_gate` 接续(服务器卡片的按钮与用户中心都走它)。 + 这样拆开是为了让"用户中心的退出登录"先关掉自己再进登录门,避免在 + 一个模态对话框里再叠一个模态登录窗。 + """ session = self._session data = settings.load(self.base) data["auth_token"] = "" # 老字段:迁移后不再使用 settings.save(self.base, data) credentials.clear(self.base) + # 插件页面的键值存储是按插件隔离的、不区分用户,页面里的内容同理: + # 退出登录后一律收掉,换账号重进时重新加载,不留上一个人的界面状态 + for plugin_id in list(self._views): + self.close_plugin(plugin_id) self.set_session(None) if session: self.client.submit( @@ -1004,6 +1089,56 @@ class MainWindow(QMainWindow): ) self.status_label.setText("已退出登录") + def _logout_with_gate(self): + """服务器卡片的「退出登录」:清凭据 → 关主窗口 → 回到登录界面。""" + self.logout() + self._relogin_gate("已退出登录,请重新登录以继续使用") + + def _relogin_gate(self, note): + """退出登录后的登录门:关掉主窗口 → 登录界面 → 重新打开或退出客户端。 + + 客户端是**强制登录**的(启动时没登录根本进不来),所以"未登录的主窗口" + 这个状态本就不该存在——退出登录必须把用户送回登录界面,不能留一个没有 + 凭据的界面继续用。返回是否重新登录成功。 + + 主窗口只 hide 不 close:``closeEvent`` 会拆掉 WebEngine profile 与工作 + 线程(进程级资源,重建代价高、Qt 那边也容易踩坑),而用户看到的"窗口 + 关掉了"由 hide 提供。用户关掉登录窗 = 强制登录下没有可去的界面 → 退出客户端。 + """ + from PySide6.QtWidgets import QApplication + + from .login import LoginWindow + + app = QApplication.instance() + if app is not None: + # hide 期间"最后一个窗口关闭"会成立,别让应用自己先退了 + app.setQuitOnLastWindowClosed(False) + self.hide() + login = LoginWindow(self.base, note=note) + # 先亮出来再进模态循环,并主动要一次前台(主窗口刚隐藏,进程可能不在 + # 前台,Windows 的前台锁会不让新窗口提到最前) + login.show() + login.raise_() + login.activateWindow() + login.exec() + session = login.session + if session: + self.set_session(session) + self.show() + self.raise_() + self.activateWindow() + if app is not None: + app.setQuitOnLastWindowClosed(True) + self.status_label.setText(f"已登录 {session.get('username') or ''}") + self._ping_server() # 换了账号/服务器状态可能变了,立刻探一次 + return True + if app is not None: + app.setQuitOnLastWindowClosed(True) + self.close() + if app is not None: + app.quit() + return False + # ------------------------------------------------------------------ # 侧栏收缩 / 设置 / 用户中心 # ------------------------------------------------------------------ @@ -1082,7 +1217,13 @@ class MainWindow(QMainWindow): self.user_button.setToolTip("用户中心") def _open_user_center(self): - """头像点击 → 用户中心;退出/重登后对话框按现值自刷新。""" + """头像点击 → 用户中心。 + + 「退出登录」在对话框里只清凭据并关掉自己(``logout_requested``),登录门 + 由这里接续——否则会变成"模态对话框里再叠一个模态登录窗",层级和焦点 + 都很难说清。「重新登录」仍是对话框自己转调(两个都是普通对话框,Qt + 处理嵌套模态没有问题)。 + """ dialog = UserCenterDialog( self.base, session_provider=lambda: self._session, @@ -1091,6 +1232,8 @@ class MainWindow(QMainWindow): parent=self, ) dialog.exec() + if dialog.logout_requested: + self._relogin_gate("已退出登录,请重新登录以继续使用") def _install_debug_plugin(self): """安装/升级桥调试台插件(设置 → 桥调试台),完成后自动打开新版页面。""" @@ -1231,6 +1374,8 @@ class MainWindow(QMainWindow): apply_startup=self.set_startup_plugin, on_install_debug=self._install_debug_plugin, on_net_settings_changed=self._ping_server, + session_provider=lambda: self._session, + on_mqtt_settings_changed=self._restart_messaging, ) dialog.exec() @@ -1299,6 +1444,225 @@ class MainWindow(QMainWindow): on_done=self.apply_payload, on_fail=self._on_fail, label="卸载", ) + # ------------------------------------------------------------------ + # 插件顺序编排 + # ------------------------------------------------------------------ + + def _move_plugin(self, plugin_id, delta): + """把某个插件在显示顺序里上移/下移一位,并持久化整份顺序。 + + 纯显示层的编排:不动包、不动注册表,也不额外请求服务器——写进 + settings 的 ``plugin_order`` 后,按与 worker 相同的纯函数 + (installer.arrange)就地重排当前列表再重画,点一下即见效。 + """ + ids = [entry["id"] for entry in self._plugins] + if plugin_id not in ids: + return + index = ids.index(plugin_id) + target = index + delta + if not 0 <= target < len(ids): + return + ids[index], ids[target] = ids[target], ids[index] + settings.set_plugin_order(self.base, ids) + self.apply_payload({"plugins": arrange(self._plugins, ids)}) + self.status_label.setText(f"{plugin_id} 已移到第 {target + 1} 位") + + def _reset_plugin_order(self): + """清掉手动编排的顺序(回到按名称排)。""" + settings.set_plugin_order(self.base, []) + self.apply_payload({"plugins": arrange(self._plugins, [])}) + self.status_label.setText("插件顺序已恢复默认(按名称)") + + # ------------------------------------------------------------------ + # 消息模块(统一总线 + MQTT 推送通道 + 消息中心) + # ------------------------------------------------------------------ + + def _start_messaging(self, session): + """登录成功后接上消息模块:**先 HTTP 补齐历史,再交给 MQTT 实时推送**。 + + "先同步"是有意的顺序:MQTT 只能保证"从现在起"的消息,用户离线期间 + 攒下的那批只能靠补齐拿回来。两条路的消息都喂进同一个总线(见 + :meth:`_feed_messages`),按 id 去重,所以重叠不会重复显示。 + + 身份或 broker 没变就不重连(``set_session`` 会被调用多次)。 + """ + if not session or not session.get("username"): + self._stop_messaging() + return + config = settings.mqtt_config(self.base, session) + # 连接键覆盖所有会影响连接的东西(含 TLS 与 broker 凭据):任何一项变了 + # 就重连,"改完不生效、要等下次登录"是很容易踩的坑 + key = (config.get("prefix"), config.get("org"), session.get("username"), + config.get("host"), config.get("port"), bool(config.get("tls")), + config.get("username"), config.get("password")) + if key == self._messaging_key: + return + self._messaging_key = key + self.bus.identify(org=config.get("org"), user=session.get("username")) + self.mqtt.start(config) + self._update_messages_ui(reset=True) + self.messages_page.set_transport_state(self.mqtt.state(), self.mqtt.describe()) + self._sync_messages() + + def _restart_messaging(self): + """MQTT 设置改过之后重连(清掉连接键,下一次 _start_messaging 就会重连)。""" + self._messaging_key = None + self._start_messaging(self._session) + + def _stop_messaging(self): + """退出登录/切换账号:断开推送,清掉上一个身份的消息与身份本身。""" + self.mqtt.stop() + self.bus.reset() + self.bus.identify("", "") # 没有身份 = 此后一条都不收(见 bus._allowed) + self._messaging_key = None + self._update_messages_ui(reset=True) + self.messages_page.set_transport_state(self.mqtt.state(), self.mqtt.describe()) + + def _update_messages_ui(self, reset=False): + self.messages_page.set_messages(self.bus.log.items(), reset=reset) + unread = self.bus.log.unread() + self.messages_button.setText("✉" if not unread else f"✉{unread}") + self.messages_button.setToolTip( + f"消息中心({unread} 条未读)" if unread else "消息中心") + + def _show_messages(self): + """切到消息中心(进来就算读过——徽标不该一直挂着)。""" + self.stack.setCurrentWidget(self.messages_page) + self.setWindowTitle(f"消息 · {APP_NAME}") + self._select_in_lists(None) # 侧栏取消插件选中(两套列表保持一致) + self._mark_messages_read(silent=True) + self.messages_page.set_transport_state(self.mqtt.state(), self.mqtt.describe()) + + def _mark_messages_read(self, silent=False): + changed = self.bus.log.mark_all_read() + self._update_messages_ui(reset=True) + if not silent: + self.status_label.setText( + f"已读 {changed} 条" if changed else "没有未读消息") + + def _clear_messages(self): + self.bus.log.clear() + self._update_messages_ui(reset=True) + self.status_label.setText("消息已清空") + + # ---- 补齐(HTTP) ------------------------------------------------- + + def _sync_messages(self): + """把服务端攒下的消息补齐(登录后自动一次,也可手动「立即同步」)。""" + user = (self._session or {}).get("username") or "" + if not user: + self.messages_page.set_transport_state(mqtt.STATE_DISABLED, "未登录") + return + if not (settings.load(self.base).get("base_url") or ""): + # 没配服务器:没有可补齐的地方,别白跑一趟 worker,也别刷一条失败 + self.messages_page.set_transport_state( + self.mqtt.state(), f"{self.mqtt.describe()};未配置服务器,跳过补齐") + return + if self._pending_sync: + return + self._pending_sync = True + # 把身份一起带上:请求在途时可能退出登录/换账号(见 _on_messages_synced) + self.client.submit( + "sync_messages", + {"since": self._newest_message_time(), "for": user}, + on_done=lambda payload: self._on_messages_synced(payload, user), + on_fail=self._on_fail_silent, + label="同步消息", + ) + + def _newest_message_time(self): + """本地最新消息的时间:只请服务端给这之后的消息(增量补齐)。""" + items = self.bus.log.items() + return str(items[-1].get("time") or "") if items else "" + + def _on_messages_synced(self, payload, for_user): + self._pending_sync = False + current = (self._session or {}).get("username") or "" + if for_user != current: + # 请求发出后身份变了(退出登录/换账号):这批消息属于上一个人, + # 绝不能喂给现在的会话——丢掉 + diagnostics.log( + f"丢弃过期的消息同步结果({for_user or '未登录'} → " + f"{current or '未登录'})", "messages") + return + added = self._feed_messages(payload.get("messages") or []) + error = payload.get("error") or "" + if payload.get("unsupported"): + note = "服务端未提供消息接口" + elif error: + note = f"同步失败:{error}" + elif added: + note = f"同步到 {added} 条新消息" + else: + note = "已是最新" + self.messages_page.set_transport_state(self.mqtt.state(), + f"{self.mqtt.describe()};{note}") + if added or error: + self.status_label.setText(note) + + def _feed_messages(self, items): + """把一批消息(补齐来的)喂进总线——与推送走同一条路、同一套去重。 + + 主题用"用户主机消息"那条:``/api/messages`` 返回的就是给这个用户看的 + 消息。逐条 deliver,分类与去重全交给总线(所以补齐与推送重叠时只会 + 留一条)。 + """ + user = (self._session or {}).get("username") or "" + if not user: + return 0 # 没有身份就不存在"这个用户的消息"(在途回调会走到这儿) + topic = topics.user_message_topic(self.bus.prefix, self.bus.org, user) + added = 0 + for item in items: + payload = item if isinstance(item, str) else json.dumps( + item, ensure_ascii=False) + if self.bus.deliver(topic, payload).get("action") == "host": + added += 1 + return added + + # ---- 推送(MQTT) ------------------------------------------------- + + def _on_push(self, topic, payload): + """收到一条推送(已在主线程,见 mqtt_service):交给总线再按结果分发。""" + result = self.bus.deliver(topic, payload) + action = result.get("action") + if action == "host": + self._on_host_message(result.get("message") or {}) + elif action == "plugin": + # 取走并投递(页面没开就留在队列里)——队列只有这一个消费口, + # 否则同一条消息会被"即时投递"和"页面打开时补投"各送一次 + self._flush_plugin_pushes(result.get("plugin") or "") + # duplicate / forbidden / unknown / empty:总线已记日志,界面不动 + + def _on_host_message(self, message): + """主机消息:进消息中心 + 更新未读徽标;warn/error 顺手发系统通知。""" + self._update_messages_ui() + title = message.get("title") or "新消息" + self.status_label.setText(f"收到消息:{title}") + level = message.get("level") + if level in ("warn", "error") and self._tray is not None: + from PySide6.QtWidgets import QSystemTrayIcon + + icon = (QSystemTrayIcon.MessageIcon.Warning if level == "warn" + else QSystemTrayIcon.MessageIcon.Critical) + self._tray.showMessage(title, message.get("body") or title, icon, 6000) + + def _flush_plugin_pushes(self, plugin_id): + """把总线里攒的插件消息投给该插件的页面(页面没开就留着)。 + + 唯一的消费口:实时推送和"页面刚打开"都走这里。页面侧还会再排一次队 + (window.__svfsPush 早于插件订阅时先攒着,订阅时补发)。 + """ + view = self._views.get(plugin_id) + if view is None: + return + for item in self.bus.drain(plugin_id): + view.push(item.get("payload") or "") + + def _on_mqtt_state(self, state, detail): + self.messages_page.set_transport_state(state, self.mqtt.describe()) + if state == mqtt.STATE_ERROR: + self.status_label.setText(f"消息推送:{detail}") + # ------------------------------------------------------------------ # 桥事件 # ------------------------------------------------------------------ @@ -1388,6 +1752,7 @@ class MainWindow(QMainWindow): def closeEvent(self, event): # noqa: N802 _ui_settings().setValue("geometry", self.saveGeometry()) + self.mqtt.stop() for plugin_id in list(self._views): self.close_plugin(plugin_id) if self.profile is not None: diff --git a/src/svfs_client/topics.py b/src/svfs_client/topics.py new file mode 100644 index 0000000..20a8d31 --- /dev/null +++ b/src/svfs_client/topics.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""消息主题方案(纯标准库):把"谁、在哪个组织、发到哪条通道"编码进 MQTT 主题。 + +主题结构(``org`` 是**组织/租户标识**,从第一版就占好位子——多租户上线时 +不用改主题格式,broker 侧按 ``//#`` 下发 ACL 就能隔离):: + + //user//message 主机消息 → 消息中心 + //user//plugin/ 插件消息 → 只给该插件 + //broadcast/message 组织内广播 → 消息中心 + //broadcast/plugin/ 组织内广播 → 只给该插件 + +举例(prefix=svfs,org=acme,user=alice,插件 com.example.clock):: + + svfs/acme/user/alice/message + svfs/acme/user/alice/plugin/com.example.clock + svfs/acme/broadcast/message + +两条通道泾渭分明——**插件消息不会进消息中心**(仪表盘数据之类的业务载荷 +不该混进给人看的消息列表),主机消息也不投给插件:分类只由主题决定,不看 +载荷内容,所以服务端不会"一不小心把调试数据推到用户的消息列表里"。 + +客户端登录后订阅两条(最小权限,不是 ``/#`` 一把梭):: + + //user//# 自己的消息(主机 + 插件) + //broadcast/# 组织广播 + +用户名与插件 id 都要占一个主题层级,所以经过 :func:`topic_segment` 转义: +安全字符原样保留,其余按 URL 百分号编码(**可逆**,不会出现 "a b" 与 "a_b" +撞成同一个主题这种租户串线)。服务端发布时必须用同一个函数,示例服务端 +直接 import 这里,保证两端规则一致。 +""" + +import re +import urllib.parse + +__all__ = [ + "DEFAULT_PREFIX", + "DEFAULT_ORG", + "topic_segment", + "parse", + "user_message_topic", + "user_plugin_topic", + "broadcast_message_topic", + "broadcast_plugin_topic", + "user_wildcard", + "broadcast_wildcard", + "subscriptions", + "SCOPES", + "KINDS", +] + +#: 主题前缀(部署时可用配置覆盖;不要用 ``$`` 开头——那是 broker 保留的) +DEFAULT_PREFIX = "svfs" +#: 未指定组织时的兜底租户标识 +DEFAULT_ORG = "default" + +#: 通道:user = 定向到某个登录用户;broadcast = 组织内广播 +SCOPES = ("user", "broadcast") +#: 类别:message = 给人看的(进消息中心);plugin = 定向给插件(不进消息中心) +KINDS = ("message", "plugin") + +#: 原样保留的字符(不含 ``/ + #`` 这些主题里有特殊含义的) +_SAFE_SEGMENT = re.compile(r"^[A-Za-z0-9._@-]+$") + + +def topic_segment(value): + """把一个值转成安全的主题层级(可逆)。 + + ``/`` 会切分主题、``+`` 与 ``#`` 是通配符,都不能出现在层级里;其余 + 不安全字符(空格、中文……)按 URL 百分号编码。``parse`` 会解开它, + 所以 ``topic_segment("a b") != topic_segment("a_b")``——不会串线。 + """ + value = str(value or "") + if not value: + raise ValueError("主题层级不能为空") + if _SAFE_SEGMENT.match(value): + return value + return urllib.parse.quote(value, safe="") + + +def _join(prefix, org, *parts): + segments = [str(prefix or DEFAULT_PREFIX).strip("/"), + topic_segment(org or DEFAULT_ORG)] + segments.extend(parts) + return "/".join(segments) + + +def user_message_topic(prefix, org, user): + """主机消息(进消息中心)。""" + return _join(prefix, org, "user", topic_segment(user), "message") + + +def user_plugin_topic(prefix, org, user, plugin_id): + """定向给某个插件的消息(不进消息中心)。""" + return _join(prefix, org, "user", topic_segment(user), "plugin", + topic_segment(plugin_id)) + + +def broadcast_message_topic(prefix, org): + """组织内广播(进消息中心)。""" + return _join(prefix, org, "broadcast", "message") + + +def broadcast_plugin_topic(prefix, org, plugin_id): + """组织内广播的插件消息(不进消息中心)。""" + return _join(prefix, org, "broadcast", "plugin", topic_segment(plugin_id)) + + +def user_wildcard(prefix, org, user): + """某个用户订阅的通配主题(自己的主机消息 + 插件消息)。""" + return _join(prefix, org, "user", topic_segment(user), "#") + + +def broadcast_wildcard(prefix, org): + """组织广播的通配主题。""" + return _join(prefix, org, "broadcast", "#") + + +def subscriptions(prefix, org, user): + """登录后该订阅的主题(两条:自己的 + 组织广播)。""" + return [user_wildcard(prefix, org, user), broadcast_wildcard(prefix, org)] + + +def parse(topic, prefix=DEFAULT_PREFIX): + """解析主题,返回 :data:`SCOPES` / :data:`KINDS` 描述的结构;不认识返回 None。 + + 返回 ``{"org", "scope", "user", "kind", "plugin"}``:``scope`` 是 + ``user`` / ``broadcast``,``kind`` 是 ``message`` / ``plugin``, + ``plugin`` 只在 kind=plugin 时有值,``user`` 只在 scope=user 时有值。 + """ + parts = str(topic or "").split("/") + if len(parts) < 4 or parts[0] != str(prefix or DEFAULT_PREFIX): + return None + org = urllib.parse.unquote(parts[1]) + scope = parts[2] + if scope == "user": + # user//message | user//plugin/ + if len(parts) < 5: + return None + user = urllib.parse.unquote(parts[3]) + kind = parts[4] + if kind == "message" and len(parts) == 5: + return {"org": org, "scope": "user", "user": user, + "kind": "message", "plugin": ""} + if kind == "plugin" and len(parts) == 6 and parts[5]: + return {"org": org, "scope": "user", "user": user, + "kind": "plugin", "plugin": urllib.parse.unquote(parts[5])} + return None + if scope == "broadcast": + # broadcast/message | broadcast/plugin/ + kind = parts[3] + if kind == "message" and len(parts) == 4: + return {"org": org, "scope": "broadcast", "user": "", + "kind": "message", "plugin": ""} + if kind == "plugin" and len(parts) == 5 and parts[4]: + return {"org": org, "scope": "broadcast", "user": "", + "kind": "plugin", "plugin": urllib.parse.unquote(parts[4])} + return None + return None diff --git a/src/svfs_client/webview.py b/src/svfs_client/webview.py index 5cd21b8..138a0ac 100644 --- a/src/svfs_client/webview.py +++ b/src/svfs_client/webview.py @@ -73,11 +73,52 @@ _BRIDGE_BOOTSTRAP = """ keys: function () { return api.storageKeys(); } }, net: bindNet(api), - ui: bindUi(api) + ui: bindUi(api), + messages: bindMessages(api) }; return window.svfs; } + // 服务端推给这个插件的消息(需要 "messages" 权限)。 + // 推送方向是"宿主 → 页面",所以页面侧只需要登记回调;宿主经 + // window.__svfsPush 送进来(见 PluginView.push)。 + function bindMessages(api) { + if (typeof api.messagesPermitted !== 'function') { return undefined; } + var handlers = []; + var queue = window.__svfsPushQueue || []; + window.__svfsPushQueue = queue; + + // 宿主投递入口:先立好(DocumentCreation 阶段就存在),早到的推送不会丢; + // 还没有订阅者时先攒着,订阅时补发 + window.__svfsPush = function (text) { + var message = { text: String(text), time: '' }; + try { + var parsed = JSON.parse(message.text); + if (parsed && typeof parsed === 'object') { message.json = parsed; } + } catch (err) { /* 不是 JSON:当作纯文本 */ } + if (!handlers.length) { queue.push(message); return; } + handlers.forEach(function (fn) { + try { fn(message); } catch (err) { /* 插件自己的回调出错不该影响别的订阅者 */ } + }); + }; + + return { + // 订阅(返回取消订阅的函数);订阅时补发"订阅之前"收到的消息 + subscribe: function (handler) { + if (typeof handler !== 'function') { return function () {}; } + handlers.push(handler); + var pending = queue.splice(0, queue.length); + pending.forEach(function (message) { + try { handler(message); } catch (err) {} + }); + return function () { + var index = handlers.indexOf(handler); + if (index >= 0) { handlers.splice(index, 1); } + }; + } + }; + } + function bindUi(api) { var ui = { notify: function (msg) { return api.notify(String(msg)); }, @@ -392,6 +433,25 @@ button { background: #5b9dff; color: #0b1220; border: none; border-radius: 6px; except RuntimeError: pass # 页面正在销毁 + def push(self, payload): + """把服务端推给这个插件的消息送进页面(需要 "messages" 权限)。 + + 和 net 应答同样的投递方式(runJavaScript:QWebChannel 的"宿主→页面" + 信号在部分 Qt 版本不可靠)。页面侧的 ``window.__svfsPush`` 在 + DocumentCreation 阶段就立好了,早到的消息会先排队,等插件订阅时补发, + 所以这里不需要关心页面加载到哪一步。 + """ + if not self._bridge.messagesPermitted(): + return False + text = payload if isinstance(payload, str) else str(payload or "") + try: + self.page().runJavaScript( + "window.__svfsPush && window.__svfsPush(" + + json.dumps(text) + ");", 0) + return True + except RuntimeError: + return False # 页面正在销毁 + def load_entry(self, entry_path=None): self._entry_path = entry_path or self._entry_path self.load(QUrl(plugin_url(self.plugin_id, entry_path or ""))) diff --git a/src/svfs_client/worker.py b/src/svfs_client/worker.py index 002d212..b76a229 100644 --- a/src/svfs_client/worker.py +++ b/src/svfs_client/worker.py @@ -22,6 +22,7 @@ import threading import time import traceback import urllib.error +import urllib.parse import urllib.request from PySide6.QtCore import QObject, QThread, Signal, Slot @@ -138,9 +139,15 @@ class ClientService(QObject): return {"plugins": self._snapshot_plugins()} def _snapshot_plugins(self): - """当前注册表 → 界面用的插件列表(带包路径、包大小与图标字节)。""" + """当前注册表 → 界面用的插件列表(带包路径、包大小与图标字节)。 + + 顺序在这里定:按用户编排的 ``plugin_order`` 排,没编排的按名称跟在后面。 + 侧栏、插件管理页、"启动直显"的第一个启用插件都读这一份,所以排序只做 + 这一处(纯函数在 installer.arrange)。 + """ entries = [] - for item in installer.list_installed(self.base): + for item in installer.arrange(installer.list_installed(self.base), + settings.plugin_order(self.base)): row = dict(item) package = paths.plugin_package(self.base, item["id"]) row["package_path"] = package @@ -219,6 +226,50 @@ class ClientService(QObject): vfs.close() return {"verify": report, "id": payload["id"]} + def _op_sync_messages(self, payload): + """登录后先同步消息:把服务端攒下的消息补齐(HTTP),随后 MQTT 续上。 + + ``GET /api/messages[?since=…]`` → ``{"messages": [封皮, …]}``, + 与 MQTT 推送用的是同一套封皮,所以两条路的消息进总线后按 id 一起去重 + (推送与补齐重叠、QoS 1 重投都不会重复显示)。 + + 拿不到不算错误:服务器没提供这个接口(404)、网络不通都只是"这次没 + 补齐",实时推送照旧,返回 ``error`` 让界面提示一句就行。**只搬运, + 不分发**——分发在总线里(主线程),worker 不碰界面状态。 + """ + base = self._server_base() + if not base: + return {"messages": [], "fetched": 0, "error": "未配置服务器地址"} + query = "" + since = str(payload.get("since") or "").strip() + if since: + query = "?" + urllib.parse.urlencode({"since": since}) + request = urllib.request.Request(f"{base}/api/messages{query}") + request.add_header("Accept", "application/json") + token = credentials.load(self.base, base) + if token: + request.add_header("Authorization", f"Bearer {token}") + try: + timeout, retries = 15, 1 + with netutil.open_with_retry(request, timeout=timeout, + retries=retries) as response: + data = json.loads(response.read(2 * 1024 * 1024) or b"{}") + except urllib.error.HTTPError as exc: + if exc.code != 404: # 404 = 服务端没这个接口,属正常降级 + diagnostics.log(f"同步消息失败: HTTP {exc.code}", "messages") + return {"messages": [], "fetched": 0, + "error": f"HTTP {exc.code}"} + return {"messages": [], "fetched": 0, "error": "", "unsupported": True} + except Exception as exc: + diagnostics.log(f"同步消息失败: {exc}", "messages") + return {"messages": [], "fetched": 0, "error": str(exc)} + + items = data.get("messages") if isinstance(data, dict) else data + if not isinstance(items, list): + return {"messages": [], "fetched": 0, "error": "响应格式不对"} + # 原样回给主线程(总线负责解析封皮与去重);只做长度上的保护 + return {"messages": items[:500], "fetched": len(items)} + # ---- 登录认证(网络操作,与 SQLite 无关) ------------------------ def _op_login(self, payload): diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100644 index 0000000..a1a89dd --- /dev/null +++ b/tests/run_tests.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""测试入口:跑全部测试,然后按 unittest 的判定结果退出。 + +为什么需要这一层(而不是直接 ``python -m unittest discover -s tests``): + +**QtWebEngine 在 offscreen 下的解释器退出路径会崩**(Windows 实测:测试全绿、 +进程却以段错误结束,退出码 139)。CI 只看退出码,于是绿色测试被判成失败; +而且崩在哪一步与用例数量有关,非常难缠。测试的成败本来就该由 unittest 的 +``result`` 决定,所以拿到结果后立刻 ``os._exit``,绕开那段我们控制不了的 +C++/解释器清理路径。 + +注意这**不掩盖真实崩溃**:测试跑到一半崩(进程真的挂了)时,unittest 根本 +拿不到结果,退出码照样非 0。 + +用法:: + + python tests/run_tests.py # 全部 + python tests/run_tests.py -p "test_client_*" # 只跑一部分(同 discover 的 -p) + python tests/run_tests.py -v # 逐条打印 +""" + +import argparse +import os +import sys +import unittest + +TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-p", "--pattern", default="test_*.py", + help="测试文件匹配(同 unittest discover 的 -p)") + parser.add_argument("-v", "--verbose", action="store_true", help="逐条打印") + parser.add_argument("-f", "--failfast", action="store_true", + help="第一个失败就停") + args = parser.parse_args(argv) + + # 从测试目录里发现:helpers.py 与测试模块都按"平铺导入"写(见 helpers) + sys.path.insert(0, TESTS_DIR) + os.chdir(TESTS_DIR) + suite = unittest.TestLoader().discover(TESTS_DIR, pattern=args.pattern) + runner = unittest.TextTestRunner(verbosity=2 if args.verbose else 1, + failfast=args.failfast) + result = runner.run(suite) + + passed = result.wasSuccessful() + summary = ("全部通过" if passed else + f"失败 {len(result.failures)} / 错误 {len(result.errors)}") + sys.stdout.write(f"{summary}\n") + sys.stdout.flush() + sys.stderr.flush() + # 立刻退出:不要让解释器去跑那段会崩的 Qt/Chromium 清理 + os._exit(0 if passed else 1) + + +if __name__ == "__main__": + main() diff --git a/tests/test_client_logic.py b/tests/test_client_logic.py index baabb6d..0fde1dc 100644 --- a/tests/test_client_logic.py +++ b/tests/test_client_logic.py @@ -257,10 +257,13 @@ class SettingsTest(VFSTestCase): self.assertEqual( data, {"base_url": "", "auth_token": "", "auth_user": "", - "startup_plugin": "", "cache_dir": "", + "startup_plugin": "", "plugin_order": [], "cache_dir": "", "net_timeout": settings.NET_TIMEOUT_DEFAULT, "net_retries": settings.NET_RETRIES_DEFAULT, - "proxy": "", "ca_bundle": "", "log_to_file": True}, + "proxy": "", "ca_bundle": "", "log_to_file": True, + "org": "", "mqtt_host": "", + "mqtt_port": settings.MQTT_PORT_DEFAULT, "mqtt_tls": False, + "mqtt_prefix": "svfs", "mqtt_username": "", "mqtt_password": ""}, ) data["base_url"] = "http://10.0.0.5:9000" settings.save(self.tmp, data) @@ -271,10 +274,13 @@ class SettingsTest(VFSTestCase): self.assertEqual( settings.load(self.tmp), {"base_url": "", "auth_token": "", "auth_user": "", - "startup_plugin": "", "cache_dir": "", + "startup_plugin": "", "plugin_order": [], "cache_dir": "", "net_timeout": settings.NET_TIMEOUT_DEFAULT, "net_retries": settings.NET_RETRIES_DEFAULT, - "proxy": "", "ca_bundle": "", "log_to_file": True}, + "proxy": "", "ca_bundle": "", "log_to_file": True, + "org": "", "mqtt_host": "", + "mqtt_port": settings.MQTT_PORT_DEFAULT, "mqtt_tls": False, + "mqtt_prefix": "svfs", "mqtt_username": "", "mqtt_password": ""}, ) def test_resolve_cache_dir(self): @@ -587,6 +593,69 @@ class VersionCompareTest(VFSTestCase): self.assertEqual(result["current"], "0.1.0") +class PluginOrderTest(VFSTestCase): + """插件顺序编排(纯函数 + 设置持久化)。""" + + @staticmethod + def _entry(plugin_id, name): + return {"id": plugin_id, "name": name} + + def test_arrange_without_order_keeps_name_order(self): + entries = [self._entry("com.b", "Bravo"), self._entry("com.a", "Alpha")] + arranged = installer.arrange(entries, []) + self.assertEqual([e["id"] for e in arranged], ["com.a", "com.b"]) + # 空 order 与 None 等价("从没编排过"这两种写法都可能出现) + self.assertEqual(installer.arrange(entries, None), arranged) + # 不改动传入的列表(原地排序会污染调用方持有的 payload) + self.assertEqual([e["id"] for e in entries], ["com.b", "com.a"]) + + def test_arrange_puts_ordered_ids_first(self): + entries = [self._entry("com.a", "Alpha"), self._entry("com.b", "Bravo"), + self._entry("com.c", "Charlie")] + arranged = installer.arrange(entries, ["com.c", "com.a"]) + self.assertEqual([e["id"] for e in arranged], + ["com.c", "com.a", "com.b"]) # 未编排的按名称跟后面 + + def test_arrange_ignores_uninstalled_ids(self): + entries = [self._entry("com.a", "Alpha"), self._entry("com.b", "Bravo")] + # com.gone 已卸载:当没看见,剩下两个仍按给定顺序排 + arranged = installer.arrange(entries, ["com.gone", "com.b"]) + self.assertEqual([e["id"] for e in arranged], ["com.b", "com.a"]) + + def test_arrange_is_stable_for_new_plugins(self): + """新装的插件(不在 order 里)落在"未编排"那一档,不干扰已编排的。""" + entries = [self._entry("com.a", "Alpha"), self._entry("com.new", "Zulu"), + self._entry("com.b", "Bravo")] + arranged = installer.arrange(entries, ["com.b", "com.a"]) + self.assertEqual([e["id"] for e in arranged], + ["com.b", "com.a", "com.new"]) + + def test_plugin_order_roundtrip_and_cleanup(self): + base = paths.ensure_layout(self.path("data")) + self.assertEqual(settings.plugin_order(base), []) + # 去重 + 保序 + 丢掉空值 + self.assertEqual( + settings.set_plugin_order(base, ["com.b", "com.a", "com.b", "", None]), + ["com.b", "com.a"], + ) + self.assertEqual(settings.plugin_order(base), ["com.b", "com.a"]) + # 复位 + settings.set_plugin_order(base, []) + self.assertEqual(settings.plugin_order(base), []) + + def test_plugin_order_survives_bad_values(self): + base = paths.ensure_layout(self.path("data")) + data = settings.load(base) + data["plugin_order"] = "com.a" # 手改坏成字符串 + settings.save(base, data) + self.assertEqual(settings.plugin_order(base), []) + # 返回的是副本:改它不影响模块级默认值(否则后面每次读取都被污染) + first = settings.load(base)["plugin_order"] + first.append("com.polluted") + self.assertEqual(settings.load(base)["plugin_order"], []) + self.assertEqual(settings.DEFAULTS["plugin_order"], []) + + class DiagnosticsTest(VFSTestCase): """诊断日志:环形缓冲、报告、自身绝不抛异常。""" diff --git a/tests/test_client_messages.py b/tests/test_client_messages.py new file mode 100644 index 0000000..1c8dd07 --- /dev/null +++ b/tests/test_client_messages.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +"""消息模块纯标准库层的测试:主题方案 / 消息封皮与日志 / 统一总线。 + +不 import Qt、不连 broker——这一层必须能在最小环境里跑通(分层约定见 +src/svfs_client/__init__.py)。 +""" + +import sys +import unittest + +from helpers import VFSTestCase + +from svfs_client import bus as bus_mod +from svfs_client import messages as messages_mod +from svfs_client import topics + +assert sys.modules.get("helpers") is not None + + +class TopicTest(unittest.TestCase): + """主题方案:组织位、转义可逆、通配订阅。""" + + ORG = "acme" + USER = "alice" + + def test_topics_carry_org_and_user(self): + self.assertEqual( + topics.user_message_topic("svfs", self.ORG, self.USER), + "svfs/acme/user/alice/message") + self.assertEqual( + topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.example.clock"), + "svfs/acme/user/alice/plugin/com.example.clock") + self.assertEqual( + topics.broadcast_message_topic("svfs", self.ORG), + "svfs/acme/broadcast/message") + self.assertEqual( + topics.broadcast_plugin_topic("svfs", self.ORG, "com.example.clock"), + "svfs/acme/broadcast/plugin/com.example.clock") + + def test_defaults_fill_in_prefix_and_org(self): + self.assertEqual(topics.user_message_topic("", "", "alice"), + "svfs/default/user/alice/message") + # 前后多余的斜杠不该造出空层级 + self.assertEqual(topics.broadcast_message_topic("/svfs/", "acme"), + "svfs/acme/broadcast/message") + + def test_parse_roundtrip(self): + topic = topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.example.x") + info = topics.parse(topic, "svfs") + self.assertEqual(info["org"], self.ORG) + self.assertEqual(info["scope"], "user") + self.assertEqual(info["user"], self.USER) + self.assertEqual(info["kind"], "plugin") + self.assertEqual(info["plugin"], "com.example.x") + + info = topics.parse(topics.broadcast_message_topic("svfs", self.ORG), "svfs") + self.assertEqual((info["scope"], info["kind"], info["user"], info["plugin"]), + ("broadcast", "message", "", "")) + + def test_parse_rejects_other_schemes(self): + for topic in ("", "svfs", "svfs/acme", "svfs/acme/user", + "other/acme/user/alice/message", # 前缀不对 + "svfs/acme/user/alice/unknown", # 类别不认识 + "svfs/acme/user/alice/plugin", # 少了插件 id + "svfs/acme/broadcast", # 少了类别 + "svfs/acme/broadcast/plugin/"): # 空插件 id + self.assertIsNone(topics.parse(topic, "svfs"), topic) + + def test_unsafe_segments_are_escaped_and_invertible(self): + # 空格、中文、斜杠都不能破坏层级;解析回来必须还是原值 + for user in ("a b", "张三", "a/b", "a+b", "a#b", "a@corp.example"): + topic = topics.user_message_topic("svfs", self.ORG, user) + self.assertEqual(len(topic.split("/")), 5, topic) + self.assertEqual(topics.parse(topic, "svfs")["user"], user) + + def test_escaping_never_collides(self): + # 转义必须是可逆的:不能把两个不同用户映射到同一个主题 + self.assertNotEqual(topics.topic_segment("a b"), + topics.topic_segment("a_b")) + self.assertNotEqual(topics.topic_segment("a/b"), + topics.topic_segment("ab")) + self.assertEqual(topics.topic_segment("safe.name-1@x"), "safe.name-1@x") + + def test_empty_segment_is_rejected(self): + with self.assertRaises(ValueError): + topics.topic_segment("") + with self.assertRaises(ValueError): + topics.user_message_topic("svfs", self.ORG, "") + + def test_subscriptions_are_least_privilege(self): + subs = topics.subscriptions("svfs", self.ORG, self.USER) + self.assertEqual(subs, ["svfs/acme/user/alice/#", "svfs/acme/broadcast/#"]) + # 不订阅整个组织的 #(多租户下这正是 ACL 要挡的范围) + self.assertNotIn("svfs/acme/#", subs) + + +class MessageLogTest(VFSTestCase): + """消息封皮解析与消息中心列表。""" + + def test_parse_json_envelope(self): + message = messages_mod.parse_message( + '{"id": "m1", "title": "备份完成", "body": "3 个包",' + ' "level": "warn", "time": "2026-09-16T10:00:00", "source": "backup"}') + self.assertEqual(message["id"], "m1") + self.assertEqual(message["title"], "备份完成") + self.assertEqual(message["body"], "3 个包") + self.assertEqual(message["level"], "warn") + self.assertEqual(message["time"], "2026-09-16T10:00:00") + self.assertEqual(message["source"], "backup") + self.assertTrue(message["unread"]) + + def test_parse_plain_and_scalar_payloads(self): + self.assertEqual(messages_mod.parse_message(" 开机完成 ")["body"], "开机完成") + self.assertEqual(messages_mod.parse_message('"一句话"')["body"], "一句话") + self.assertEqual(messages_mod.parse_message("[1,2]")["body"], "[1, 2]") + self.assertEqual(messages_mod.parse_message(b"\xe4\xb8\xad\xe6\x96\x87")["body"], + "中文") + + def test_parse_rejects_empty_and_contentless(self): + for payload in (None, "", " ", "{}", '{"title": ""}', b""): + self.assertIsNone(messages_mod.parse_message(payload), repr(payload)) + + def test_parse_normalizes_level_and_time(self): + message = messages_mod.parse_message('{"body": "x", "level": "FATAL"}', + received_time="2026-09-16T12:00:00") + self.assertEqual(message["level"], "info") # 新级别也当 info,消息不能丢 + self.assertEqual(message["time"], "2026-09-16T12:00:00") # 缺失时用收到时间 + self.assertTrue(messages_mod.parse_message('{"body": "x"}')["time"]) + + def test_parse_caps_length(self): + message = messages_mod.parse_message( + '{"title": "%s", "body": "%s"}' % ("t" * 500, "b" * 9000)) + self.assertLessEqual(len(message["title"]), 200) + self.assertLessEqual(len(message["body"]), messages_mod.MAX_TEXT) + + def test_log_dedupes_by_id(self): + log = messages_mod.MessageLog() + self.assertIsNotNone(log.add(messages_mod.parse_message('{"id": "m1", "body": "a"}'))) + # 同 id 重投(QoS 1 重发 / 补齐与推送重叠)→ 丢掉 + self.assertIsNone(log.add(messages_mod.parse_message('{"id": "m1", "body": "a"}'))) + self.assertEqual(len(log), 1) + + def test_log_dedupes_id_less_messages_by_content_and_second(self): + """服务端没给 id 时按"时间(秒)+ 标题 + 正文"去重——这正是没有 id 的 + 消息同时经推送与 HTTP 补齐两条路到达的场景,显示一条才对。""" + log = messages_mod.MessageLog() + payload = '{"body": "同一条", "time": "2026-09-16T10:00:00"}' + self.assertIsNotNone(log.add(messages_mod.parse_message(payload))) + self.assertIsNone(log.add(messages_mod.parse_message(payload))) + self.assertEqual(len(log), 1) + # 时间不同(真·第二条)或内容不同都算新消息 + self.assertIsNotNone(log.add(messages_mod.parse_message( + '{"body": "同一条", "time": "2026-09-16T10:00:01"}'))) + self.assertIsNotNone(log.add(messages_mod.parse_message( + '{"body": "另一条", "time": "2026-09-16T10:00:01"}'))) + self.assertEqual(len(log), 3) + + def test_log_unread_and_mark_read(self): + log = messages_mod.MessageLog() + for index in range(3): + log.add(messages_mod.parse_message(f'{{"id": "m{index}", "body": "x"}}')) + self.assertEqual(log.unread(), 3) + self.assertEqual(log.mark_all_read(), 3) + self.assertEqual(log.unread(), 0) + self.assertEqual(log.mark_all_read(), 0) + log.add(messages_mod.parse_message('{"id": "m9", "body": "y"}')) + self.assertTrue(log.mark_read("m9")) + self.assertFalse(log.mark_read("m9")) # 已经读过 + self.assertFalse(log.mark_read("nope")) + + def test_log_caps_and_forgets_dropped_keys(self): + log = messages_mod.MessageLog(limit=3) + for index in range(5): + log.add(messages_mod.parse_message(f'{{"id": "m{index}", "body": "x"}}')) + self.assertEqual([m["id"] for m in log.items()], ["m2", "m3", "m4"]) + # 被挤掉的 id 不再占去重表:同样的 id 再来是"新消息"(而不是永久沉默) + self.assertIsNotNone( + log.add(messages_mod.parse_message('{"id": "m0", "body": "x"}'))) + + def test_log_clear(self): + log = messages_mod.MessageLog() + log.add(messages_mod.parse_message('{"id": "m1", "body": "x"}')) + log.clear() + self.assertEqual(len(log), 0) + self.assertEqual(log.unread(), 0) + self.assertIsNotNone( + log.add(messages_mod.parse_message('{"id": "m1", "body": "x"}'))) + + +class MessageBusTest(VFSTestCase): + """总线的分类、归属校验与分发。""" + + ORG = "acme" + USER = "alice" + + def setUp(self): + super().setUp() + self.bus = bus_mod.MessageBus(org=self.ORG, user=self.USER) + + def _deliver(self, topic, payload, received_time=""): + return self.bus.deliver(topic, payload, received_time=received_time) + + # ---- 分类 ------------------------------------------------------- + + def test_host_message_goes_to_message_log(self): + topic = topics.user_message_topic("svfs", self.ORG, self.USER) + result = self._deliver(topic, '{"id": "m1", "title": "备份完成"}') + self.assertEqual(result["action"], "host") + self.assertEqual(len(self.bus.log), 1) + self.assertEqual(self.bus.log.items()[0]["title"], "备份完成") + self.assertEqual(self.bus.log.items()[0]["scope"], "user") + + def test_plugin_message_never_reaches_message_log(self): + topic = topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.example.dash") + result = self._deliver(topic, '{"cpu": 0.42}') + self.assertEqual(result["action"], "plugin") + self.assertEqual(result["plugin"], "com.example.dash") + self.assertEqual(len(self.bus.log), 0, "插件消息不能进消息中心") + # 插件取走原文(宿主不解析插件协议) + drained = self.bus.drain("com.example.dash") + self.assertEqual(len(drained), 1) + self.assertEqual(drained[0]["payload"], '{"cpu": 0.42}') + self.assertEqual(self.bus.drain("com.example.dash"), []) + + def test_broadcast_plugin_message(self): + topic = topics.broadcast_plugin_topic("svfs", self.ORG, "com.example.dash") + result = self._deliver(topic, "tick") + self.assertEqual(result["action"], "plugin") + self.assertEqual(result["scope"], "broadcast") + self.assertEqual(self.bus.pending("com.example.dash"), 1) + + def test_broadcast_host_message_is_marked(self): + topic = topics.broadcast_message_topic("svfs", self.ORG) + self._deliver(topic, '{"body": "全员通知"}') + self.assertEqual(self.bus.log.items()[0]["scope"], "broadcast") + + def test_unknown_topic_and_empty_payload(self): + self.assertEqual(self._deliver("svfs/acme/user/alice/nope", "x")["action"], + "unknown") + topic = topics.user_message_topic("svfs", self.ORG, self.USER) + self.assertEqual(self._deliver(topic, " ")["action"], "empty") + self.assertEqual(len(self.bus.log), 0) + + # ---- 归属校验 --------------------------------------------------- + + def test_other_org_or_user_is_rejected(self): + other_org = topics.user_message_topic("svfs", "other", self.USER) + self.assertEqual(self._deliver(other_org, '{"body": "x"}')["action"], + "forbidden") + other_user = topics.user_message_topic("svfs", self.ORG, "bob") + self.assertEqual(self._deliver(other_user, '{"body": "x"}')["action"], + "forbidden") + forbidden_plugin = topics.user_plugin_topic("svfs", self.ORG, "bob", + "com.example.dash") + self.assertEqual(self._deliver(forbidden_plugin, "x")["action"], "forbidden") + self.assertEqual(len(self.bus.log), 0) + self.assertEqual(self.bus.pending("com.example.dash"), 0) + self.assertEqual(self.bus.stats()["forbidden"], 3) + + def test_identify_switches_tenant_and_clears_buffers(self): + self._deliver(topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.a"), + "x") + self.assertEqual(self.bus.pending("com.a"), 1) + self.bus.identify(org="other", user="bob") + self.assertEqual(self.bus.pending("com.a"), 0, "换身份后不该留着旧队列") + self.assertEqual( + self._deliver(topics.user_message_topic("svfs", self.ORG, self.USER), + '{"body": "x"}')["action"], "forbidden") + + def test_bus_rejects_everything_before_identify(self): + """没有身份(未登录/已退出登录)时一条都不收。 + + 消息模块只在登录后工作;退出登录后残留的推送不该落进消息中心。 + """ + bus = bus_mod.MessageBus() + result = bus.deliver(topics.broadcast_message_topic("svfs", "acme"), + '{"body": "x"}') + self.assertEqual(result["action"], "forbidden") + self.assertEqual(len(bus.log), 0) + bus.identify(org="acme") + self.assertEqual( + bus.deliver(topics.broadcast_message_topic("svfs", "acme"), + '{"body": "x"}')["action"], "host") + + # ---- 去重与兜底 ------------------------------------------------- + + def test_plugin_duplicates_are_dropped(self): + topic = topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.example.dash") + self.assertEqual(self._deliver(topic, "same")["action"], "plugin") + self.assertEqual(self._deliver(topic, "same")["action"], "duplicate") + self.assertEqual(self.bus.pending("com.example.dash"), 1) + # 内容不同就是新消息 + self.assertEqual(self._deliver(topic, "other")["action"], "plugin") + + def test_plugin_buffer_drops_oldest_and_counts(self): + bus = bus_mod.MessageBus(org=self.ORG, user=self.USER, plugin_buffer=3) + topic = topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.example.dash") + for index in range(5): + bus.deliver(topic, f"payload-{index}") + drained = bus.drain("com.example.dash") + self.assertEqual([item["payload"] for item in drained], + ["payload-2", "payload-3", "payload-4"]) + self.assertEqual(bus.stats()["dropped"], 2) + + def test_reset_clears_everything(self): + self._deliver(topics.user_message_topic("svfs", self.ORG, self.USER), + '{"id": "m1", "body": "x"}') + self._deliver(topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.a"), "x") + self.bus.reset() + self.assertEqual(len(self.bus.log), 0) + self.assertEqual(self.bus.pending("com.a"), 0) + self.assertEqual(self.bus.stats(), {}) + + def test_stats_track_each_channel(self): + self._deliver(topics.user_message_topic("svfs", self.ORG, self.USER), + '{"id": "m1", "body": "x"}') + self._deliver(topics.user_message_topic("svfs", self.ORG, self.USER), + '{"id": "m1", "body": "x"}') # 重投 + self._deliver(topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.a"), + "x") + stats = self.bus.stats() + self.assertEqual(stats["received"], 3) + self.assertEqual(stats["host"], 1) + self.assertEqual(stats["plugin"], 1) + self.assertEqual(stats["duplicate"], 1) + self.assertEqual(self.bus.buffered_plugins(), ["com.a"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_client_mqtt.py b/tests/test_client_mqtt.py new file mode 100644 index 0000000..daad895 --- /dev/null +++ b/tests/test_client_mqtt.py @@ -0,0 +1,473 @@ +#!/usr/bin/env python3 +"""MQTT 推送通道的测试。 + +分两层: + +* **装配层**:注入假客户端,确定性地验证"连接参数、订阅哪两条主题、状态机、 + 重连语义、缺 paho 时的降级"——不依赖网络与 broker; +* **端到端**:自建一个最小 MQTT broker(3.1.1、QoS 0,只实现 CONNECT / + SUBSCRIBE / PUBLISH / PINGREQ / DISCONNECT),用**真 paho 客户端**连上去, + 让 broker 主动推消息,验证消息真的落进总线:主机消息进消息中心、插件消息 + 只进插件队列。没装 paho 时这一层自动跳过。 +""" + +import json +import socket +import struct +import sys +import threading +import time +import unittest + +from helpers import VFSTestCase + +from svfs_client import bus as bus_mod +from svfs_client import mqtt as mqtt_mod +from svfs_client import settings, topics + +assert sys.modules.get("helpers") is not None + +try: + import paho.mqtt.client as _paho # noqa: F401 - 端到端测试需要 + HAVE_PAHO = True +except Exception: + HAVE_PAHO = False + + +def config(**overrides): + base = {"enabled": True, "host": "127.0.0.1", "port": 1883, "tls": False, + "prefix": "svfs", "org": "acme", "username": "alice", + "password": "tok", "keepalive": 30} + base.update(overrides) + return base + + +def wait_until(predicate, timeout=10.0, interval=0.02): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(interval) + return False + + +class _FakeClient: + """paho 客户端的替身:记录调用,手动触发回调。""" + + def __init__(self): + self.calls = [] + self.subscriptions = [] + self.on_connect = None + self.on_disconnect = None + self.on_message = None + self.username_pw = None + self.tls = None + self.reconnect_delay = None + + # paho 的接口 + def connect_async(self, host, port, keepalive): + self.calls.append(("connect_async", host, port, keepalive)) + + def loop_start(self): + self.calls.append(("loop_start",)) + + def loop_stop(self): + self.calls.append(("loop_stop",)) + + def disconnect(self): + self.calls.append(("disconnect",)) + + def subscribe(self, topic): + self.subscriptions.append(topic) + self.calls.append(("subscribe", topic)) + + def username_pw_set(self, username, password=None): + self.username_pw = (username, password) + + def tls_set(self, **kwargs): + self.tls = kwargs + + def reconnect_delay_set(self, min_delay, max_delay): + self.reconnect_delay = (min_delay, max_delay) + + # 测试侧的触发点 + def fire_connect(self, code=0): + self.on_connect(self, None, {}, code, None) + + def fire_disconnect(self, code=0): + self.on_disconnect(self, None, None, code, None) + + def fire_message(self, topic, payload): + self.on_message(self, None, _Message(topic, payload)) + + +class _Message: + def __init__(self, topic, payload): + self.topic = topic + self.payload = payload if isinstance(payload, bytes) else payload.encode() + + +class TransportWiringTest(VFSTestCase): + """装配层:参数、订阅、状态机(不需要 broker)。""" + + def setUp(self): + super().setUp() + self.states = [] + self.messages = [] + self.client = _FakeClient() + + def transport(self, **overrides): + return mqtt_mod.MqttTransport( + config(**overrides), + on_message=lambda topic, payload: self.messages.append((topic, payload)), + on_state=lambda state, detail: self.states.append((state, detail)), + client=self.client, + ) + + def test_disabled_config_does_not_connect(self): + transport = mqtt_mod.MqttTransport({"enabled": False}, + client=self.client) + self.assertEqual(transport.start(), mqtt_mod.STATE_DISABLED) + self.assertEqual(self.client.calls, []) + + def test_missing_paho_degrades_with_hint(self): + original = mqtt_mod._import_paho + mqtt_mod._import_paho = lambda: None + self.addCleanup(setattr, mqtt_mod, "_import_paho", original) + transport = mqtt_mod.MqttTransport(config(), on_state=lambda *a: None) + self.assertEqual(transport.start(), mqtt_mod.STATE_MISSING) + self.assertIn("paho", transport.detail) + + def test_start_connects_subscribes_and_reports_state(self): + transport = self.transport() + self.assertEqual(transport.start(), mqtt_mod.STATE_CONNECTING) + self.assertIn(("connect_async", "127.0.0.1", 1883, 30), self.client.calls) + self.assertIn(("loop_start",), self.client.calls) + self.assertEqual(self.client.username_pw, ("alice", "tok")) + self.assertFalse(self.client.tls) # 非 TLS 不调 tls_set + self.assertEqual(self.client.reconnect_delay, + (mqtt_mod.RECONNECT_MIN_DELAY, mqtt_mod.RECONNECT_MAX_DELAY)) + + self.client.fire_connect(0) + self.assertTrue(transport.is_connected()) + # 订阅的是"自己的 + 组织广播"两条,不是整个组织 + self.assertEqual(self.client.subscriptions, + ["svfs/acme/user/alice/#", "svfs/acme/broadcast/#"]) + self.assertEqual(self.states[-1][0], mqtt_mod.STATE_CONNECTED) + + def test_reconnect_resubscribes(self): + transport = self.transport() + transport.start() + self.client.fire_connect(0) + self.client.subscriptions.clear() + # 掉线 → paho 自动重连 → 重新订阅(broker 不记得旧会话的订阅) + self.client.fire_disconnect(7) + self.assertEqual(transport.state, mqtt_mod.STATE_CONNECTING) + self.client.fire_connect(0) + self.assertEqual(len(self.client.subscriptions), 2) + self.assertEqual(transport.connected_count, 2) + + def test_refused_connection_reports_error(self): + transport = self.transport() + transport.start() + self.client.fire_connect(5) # 5 = 未授权 + self.assertEqual(transport.state, mqtt_mod.STATE_ERROR) + self.assertIn("拒绝", transport.detail) + + def test_message_callback_hands_payload_to_bus(self): + transport = self.transport() + transport.start() + self.client.fire_connect(0) + self.client.fire_message("svfs/acme/user/alice/message", '{"body": "hi"}') + self.assertEqual(self.messages, + [("svfs/acme/user/alice/message", b'{"body": "hi"}')]) + self.assertEqual(transport.received_count, 1) + + def test_stop_is_idempotent_and_marks_stopped(self): + transport = self.transport() + transport.start() + self.client.fire_connect(0) + transport.stop() + self.assertEqual(transport.state, mqtt_mod.STATE_STOPPED) + self.assertIn(("disconnect",), self.client.calls) + self.assertIn(("loop_stop",), self.client.calls) + # 主动断开后的 on_disconnect 不该被当成"掉线重连中" + self.client.fire_disconnect(0) + self.assertEqual(transport.state, mqtt_mod.STATE_STOPPED) + + def test_tls_config_sets_tls(self): + transport = self.transport(tls=True, port=8883) + transport.start() + self.assertIsNotNone(self.client.tls) + self.assertIn(("connect_async", "127.0.0.1", 8883, 30), self.client.calls) + + +class BrokerTest(VFSTestCase): + """端到端:真 paho ↔ 自建最小 broker ↔ 统一总线。""" + + ORG = "acme" + USER = "alice" + + def setUp(self): + super().setUp() + if not HAVE_PAHO: + self.skipTest("没装 paho-mqtt,跳过真实 MQTT 端到端") + self.broker = _MiniBroker() + self.addCleanup(self.broker.close) + self.bus = bus_mod.MessageBus(org=self.ORG, user=self.USER) + self.transport = mqtt_mod.MqttTransport( + config(host="127.0.0.1", port=self.broker.port, keepalive=5), + on_message=self.bus.deliver) + self.addCleanup(self.transport.stop) + self.assertEqual(self.transport.start(), mqtt_mod.STATE_CONNECTING) + self.assertTrue(wait_until(lambda: self.transport.is_connected()), + "客户端没能连上自建 broker") + + def test_server_push_lands_in_bus(self): + # broker 记录客户端订阅的主题(验证最小权限订阅) + self.assertTrue(wait_until(lambda: len(self.broker.subscriptions) >= 2)) + self.assertEqual(sorted(self.broker.subscriptions), + ["svfs/acme/broadcast/#", "svfs/acme/user/alice/#"]) + + # 服务端主动推一条主机消息 + 一条插件消息 + self.broker.publish("svfs/acme/user/alice/message", + json.dumps({"id": "m1", "title": "备份完成", + "body": "3 个包"})) + self.broker.publish("svfs/acme/user/alice/plugin/com.example.dash", + json.dumps({"cpu": 0.42})) + self.assertTrue(wait_until(lambda: len(self.bus.log) == 1)) + self.assertTrue(wait_until(lambda: self.bus.pending("com.example.dash") == 1)) + + message = self.bus.log.items()[0] + self.assertEqual((message["title"], message["body"]), ("备份完成", "3 个包")) + self.assertTrue(message["unread"]) + # 插件消息没有混进消息中心 + self.assertEqual(len(self.bus.log), 1) + self.assertEqual(self.bus.drain("com.example.dash")[0]["payload"], + json.dumps({"cpu": 0.42})) + + def test_qos1_style_redelivery_is_deduped_end_to_end(self): + payload = json.dumps({"id": "m2", "body": "同一条"}) + self.broker.publish("svfs/acme/user/alice/message", payload) + self.broker.publish("svfs/acme/user/alice/message", payload) + self.assertTrue(wait_until(lambda: len(self.bus.log) == 1)) + time.sleep(0.2) + self.assertEqual(len(self.bus.log), 1, "重投的两条应该去重成一条") + self.assertEqual(self.bus.stats()["duplicate"], 1) + + def test_other_users_message_is_dropped_by_bus(self): + """即使 broker 的 ACL 配错、把别人的消息推过来,总线也挡掉。 + + 正常情况根本到不了这一步:客户端只订阅 ``user/<自己>/#``,broker 不会 + 投别的用户的消息(上一条测试验证的正是这个)。这里用 publish_raw 模拟 + 配错的 broker,验证第二道防线。 + """ + self.broker.publish_raw("svfs/acme/user/bob/message", '{"body": "别人的"}') + self.assertTrue(wait_until(lambda: self.bus.stats().get("forbidden", 0) == 1)) + self.assertEqual(len(self.bus.log), 0) + + def test_other_orgs_message_is_dropped_by_bus(self): + self.broker.publish_raw("svfs/othercorp/user/alice/message", + '{"body": "别的租户"}') + self.assertTrue(wait_until(lambda: self.bus.stats().get("forbidden", 0) == 1)) + self.assertEqual(len(self.bus.log), 0) + + +# ---------------------------------------------------------------------- +# 最小 MQTT broker(只为测试:MQTT 3.1.1、QoS 0、CONNECT/SUBSCRIBE/PUBLISH/ +# PINGREQ/DISCONNECT)。故意不引第三方 broker —— 测试要能离线跑。 +# ---------------------------------------------------------------------- + + +class _MiniBroker: + """够用的 MQTT broker:接连接、记订阅、按通配符投递 PUBLISH。""" + + def __init__(self): + self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._server.bind(("127.0.0.1", 0)) + self._server.listen(4) + self.port = self._server.getsockname()[1] + self.subscriptions = [] # broker 收到的订阅(测试断言用) + self.published = [] # broker 收到的客户端 PUBLISH(本测试没用到) + self._clients = [] # [(sock, [topics])] + self._lock = threading.Lock() + self._closed = False + threading.Thread(target=self._accept_loop, daemon=True).start() + + # ---- 对外 ------------------------------------------------------- + + def publish(self, topic, payload): + """broker 主动推一条(QoS 0),按订阅投递。""" + self._send(topic, payload, ignore_subscriptions=False) + + def publish_raw(self, topic, payload): + """无视订阅、推给所有连接(模拟 ACL 配错的 broker)。 + + 正常 broker 不会这么干(订阅本身就是 ``user/<自己>/#``),但"broker + 配错"是真实存在的运维事故,总线的归属校验就是为它准备的兜底。 + """ + self._send(topic, payload, ignore_subscriptions=True) + + def _send(self, topic, payload, ignore_subscriptions): + body = payload.encode("utf-8") if isinstance(payload, str) else bytes(payload) + packet = _publish_packet(topic, body) + with self._lock: + if ignore_subscriptions: + targets = [sock for sock, _filters in self._clients] + else: + targets = [sock for sock, filters in self._clients + if any(_topic_matches(f, topic) for f in filters)] + for sock in targets: + try: + sock.sendall(packet) + except OSError: + pass + + def close(self): + self._closed = True + with self._lock: + for sock, _filters in self._clients: + try: + sock.close() + except OSError: + pass + self._clients = [] + try: + self._server.close() + except OSError: + pass + + # ---- 内部 ------------------------------------------------------- + + def _accept_loop(self): + while not self._closed: + try: + sock, _addr = self._server.accept() + except OSError: + return + with self._lock: + self._clients.append((sock, [])) + threading.Thread(target=self._client_loop, args=(sock,), + daemon=True).start() + + def _client_loop(self, sock): + try: + while not self._closed: + header = sock.recv(1) + if not header: + return + packet_type = header[0] >> 4 + length, _ = _read_length(sock) + body = _read_exact(sock, length) if length else b"" + if packet_type == 1: # CONNECT + sock.sendall(b"\x20\x02\x00\x00") + elif packet_type == 8: # SUBSCRIBE + packet_id = body[:2] + filters = _parse_subscribe(body[2:]) + with self._lock: + for entry in self._clients: + if entry[0] is sock: + entry[1].extend(filters) + self.subscriptions.extend(filters) + sock.sendall(b"\x90" + bytes([2 + len(filters)]) + packet_id + + b"\x00" * len(filters)) + elif packet_type == 3: # PUBLISH(客户端发的)→ 转投订阅者 + topic, payload = _parse_publish(body) + self.published.append(topic) + self._send(topic, payload, ignore_subscriptions=False) + elif packet_type == 12: # PINGREQ + sock.sendall(b"\xd0\x00") + elif packet_type == 14: # DISCONNECT + return + except OSError: + return + finally: + with self._lock: + self._clients = [entry for entry in self._clients if entry[0] is not sock] + try: + sock.close() + except OSError: + pass + + +def _read_length(sock): + """MQTT 的可变长度字段(剩下长度)。""" + multiplier = 1 + value = 0 + for _ in range(4): + byte = sock.recv(1) + if not byte: + raise OSError("连接已关闭") + digit = byte[0] + value += (digit & 0x7F) * multiplier + if not digit & 0x80: + break + multiplier *= 128 + return value, multiplier + + +def _read_exact(sock, count): + chunks = [] + remaining = count + while remaining > 0: + chunk = sock.recv(remaining) + if not chunk: + raise OSError("连接已关闭") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def _parse_subscribe(body): + """SUBSCRIBE 载荷:包 id(2) + 若干个 [主题长度(2) + 主题 + QoS(1)]。""" + filters = [] + offset = 0 + while offset + 2 <= len(body): + length = struct.unpack(">H", body[offset:offset + 2])[0] + start = offset + 2 + filters.append(body[start:start + length].decode("utf-8")) + offset = start + length + 1 # 跳过 QoS 字节(别忘了,否则后面全错位) + return filters + + +def _parse_publish(body): + length = struct.unpack(">H", body[:2])[0] + topic = body[2:2 + length].decode("utf-8") + return topic, body[2 + length:] + + +def _publish_packet(topic, payload): + encoded = topic.encode("utf-8") + body = struct.pack(">H", len(encoded)) + encoded + payload + return b"\x30" + _encode_length(len(body)) + body + + +def _encode_length(length): + out = bytearray() + while True: + digit = length % 128 + length //= 128 + if length: + digit |= 0x80 + out.append(digit) + if not length: + return bytes(out) + + +def _topic_matches(topic_filter, topic): + """主题过滤器匹配(支持 ``+`` 与末尾的 ``#``)。""" + filter_parts = topic_filter.split("/") + topic_parts = topic.split("/") + for index, part in enumerate(filter_parts): + if part == "#": + return True + if index >= len(topic_parts): + return False + if part != "+" and part != topic_parts[index]: + return False + return len(filter_parts) == len(topic_parts) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_client_ui.py b/tests/test_client_ui.py index 6dcc9bb..1411ba2 100644 --- a/tests/test_client_ui.py +++ b/tests/test_client_ui.py @@ -15,6 +15,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--no-sandbox --disable-gpu") import json +import gc import sys import tempfile import threading @@ -27,8 +28,8 @@ from helpers import VFSTestCase, make_tree, quiet from sqlite_vfs.core import SQLiteVFS try: - from PySide6.QtCore import QSettings, Qt, QUrl - from PySide6.QtWidgets import QApplication + from PySide6.QtCore import QCoreApplication, QEvent, QSettings, Qt, QTimer, QUrl + from PySide6.QtWidgets import QApplication, QDialog, QWidget from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401 - WebEngine 探测 HAVE_QT = True @@ -108,6 +109,36 @@ class _ApiHandler(BaseHTTPRequestHandler): pass +class _FakeLoginWindow: + """登录窗替身:``exec()`` 立刻返回,``session`` 就是预设的会话(None = 用户关掉了窗)。 + + ``visible_probe`` 在 exec() 期间被调用一次,用来断言"进登录门时主窗口已经 + 关掉了"这件事——真去驱动一个模态登录窗要绕一大圈(见 + test_client_login_ui.py 里的 QTimer 轮询写法),这里只需要登录门的时序。 + """ + + def __init__(self, session, visible_probe=None): + self.session = session + self.shown = False + self.raised = False + self._visible_probe = visible_probe + self.main_window_visible_during = None + + def show(self): + self.shown = True + + def raise_(self): + self.raised = True + + def activateWindow(self): # noqa: N802 - 模拟 Qt 命名 + pass + + def exec(self): + if self._visible_probe is not None: + self.main_window_visible_during = self._visible_probe() + return 0 + + def start_api_server(): server = ThreadingHTTPServer(("127.0.0.1", 0), _ApiHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) @@ -152,6 +183,30 @@ if HAVE_QT: _APP = QApplication.instance() or create_app([sys.argv[0]]) _APP.setQuitOnLastWindowClosed(False) + def tearDownModule(): + """退出前把 WebEngine 对象主动拆掉(否则进程会以段错误收场)。 + + 留着窗口不管,Chromium 的 profile / 渲染子进程就要等解释器退出时才拆, + 而 offscreen 下那条路径会以 access violation 结束——测试全绿、退出码却是 + 139,CI 照样判失败(本机 3.13 + PySide6 6.11.2 实测)。close() 走的是 + MainWindow.closeEvent 那条正常销毁顺序:收插件页面 → 停工作线程 → 删 + profile;这里再跑一轮 deferred delete 让 deleteLater 真正落地。 + """ + global _APP + # 收掉本模块残留的窗口,让各窗口自己的 WebEngine 资源在**这里**释放, + # 而不是全堆到解释器退出时一起析构。注意别在这里 gc.collect() 或 + # sendPostedEvents:那会把几十个 profile 的析构挤到同一时刻,实测直接崩。 + # + # 即便这样,解释器退出路径在 offscreen + WebEngine 下仍会段错误(与 + # 被测代码无关),所以测试统一走 tests/run_tests.py —— 它按 unittest 的 + # 结果 os._exit,绕开那条会崩的清理路径。 + for widget in list(QApplication.topLevelWidgets()): + widget.close() + widget.deleteLater() + for _ in range(30): + _APP.processEvents() + time.sleep(0.01) + class BridgeTest(VFSTestCase): """桥的权限模型与存储隔离(不经过 WebView)。""" @@ -517,8 +572,11 @@ if HAVE_QT: self.assertTrue(pump(_APP, predicate=lambda: self.window._startup_resolved)) def tearDown(self): - self.window._thread.quit() - self.window._thread.wait(5000) + # 必须走 close() 而不是直接 deleteLater():只有 closeEvent 会拆掉 + # WebEngine profile、协议层句柄与工作线程。不拆的话它们全留到 + # 解释器退出时一起析构,而 offscreen 下那条路径会崩(实测)。 + # 每个用例各拆各的,也避免了"几十个 profile 同时析构"。 + self.window.close() self.window.deleteLater() pump(_APP) super().tearDown() @@ -780,7 +838,8 @@ if HAVE_QT: self._install_extra_plugin(other) self.window.apply_payload(self._submit_and_wait("list_plugins", {})) - # 默认规则:无配置、无 last_plugin → 按 id 排序第一个启用的插件 + # 默认规则:无配置、无 last_plugin → 显示顺序里第一个启用的插件 + # (默认顺序 = 按名称:"Other" 在 "SVFS 桥调试台" 前面) self.assertEqual( self.window._resolve_startup_plugin(), min(pid, other) ) @@ -808,6 +867,151 @@ if HAVE_QT: self.assertIsNone(self.window.set_startup_plugin("")) _ui_settings().remove("ui/last_plugin") + def test_plugin_order_arrangement(self): + """编排插件顺序:↑/↓ 改显示顺序并落盘,侧栏/管理页/启动默认都跟随。""" + from svfs_client import settings as client_settings + from svfs_client.shell import _ui_settings + + _ui_settings().remove("ui/last_plugin") + self.window.set_startup_plugin("") + self.window.apply_payload(self._submit_and_wait("install_sample", {})) + other = "com.other.app" + self._install_extra_plugin(other) + self.window.apply_payload(self._submit_and_wait("list_plugins", {})) + + ids = [entry["id"] for entry in self.window._plugins] + self.assertEqual(len(ids), 2, "多插件场景没建起来,用例前提不成立") + self.assertEqual(client_settings.plugin_order(self.base), []) + self.assertFalse(self.window.manager.order_reset_button.isVisible()) + + top, bottom = ids[0], ids[-1] + # 边界:首位不能上移、末位不能下移 + self.assertFalse( + self.window.manager.cards[top].up_button.isEnabled()) + self.assertFalse( + self.window.manager.cards[bottom].down_button.isEnabled()) + self.assertTrue( + self.window.manager.cards[bottom].up_button.isEnabled()) + + # 上移一位:列表顺序、侧栏顺序、落盘的顺序三者一致 + self.window.manager.cards[bottom].up_button.click() + pump(_APP) + self.assertEqual([entry["id"] for entry in self.window._plugins], + [bottom, top]) + self.assertEqual(client_settings.plugin_order(self.base), + [bottom, top]) + sidebar = [ + self.window.plugin_list.item(row).data(Qt.ItemDataRole.UserRole) + for row in range(self.window.plugin_list.count()) + ] + self.assertEqual(sidebar, [bottom, top]) + # 编排过 → "恢复默认顺序"出现 + self.assertTrue(self.window.manager.order_reset_button.isVisible()) + + # 启动直显的默认规则跟着编排走(不再按 id/名称) + self.assertEqual(self.window._resolve_startup_plugin(), bottom) + + # worker 重新拉列表时也按编排顺序(不是只有界面上排了一下) + self.window.apply_payload(self._submit_and_wait("list_plugins", {})) + self.assertEqual([entry["id"] for entry in self.window._plugins], + [bottom, top]) + + # 恢复默认顺序:回到按名称("Other" 在前)、设置清空、按钮消失 + self.window.manager.order_reset_button.click() + pump(_APP) + self.assertEqual(client_settings.plugin_order(self.base), []) + self.assertEqual([entry["id"] for entry in self.window._plugins], + [other, client_sample.PLUGIN_ID]) + self.assertFalse(self.window.manager.order_reset_button.isVisible()) + + # 收尾:别把启动配置留给后续用例 + self.window.set_startup_plugin("") + _ui_settings().remove("ui/last_plugin") + + def test_settings_dialog_mqtt_config(self): + """设置里配 broker:落盘 + 立即重连(不用等下次登录)。""" + from svfs_client.dialogs import SettingsDialog + + self.window.set_session({"username": "admin", "token": "tok"}) + applied = [] + dialog = SettingsDialog( + self.base, apply_base_url=self.window.set_base_url, + session_provider=lambda: self.window._session, + on_mqtt_settings_changed=lambda: (applied.append(1), + self.window._restart_messaging()), + ) + self.addCleanup(dialog.deleteLater) + # 没配 broker、服务端也没下发 → 显示"未启用" + self.assertIn("未启用", dialog.mqtt_status.text()) + + dialog.mqtt_host_edit.setText("mqtts://broker.acme:8883") + dialog.mqtt_prefix_edit.setText("acme") + dialog.org_edit.setText("acme") + dialog.mqtt_user_edit.setText("svc-client") + dialog._save_mqtt_settings() + pump(_APP) + + # 落盘 + 回调(重连) + data = client_settings.load(self.base) + self.assertEqual(data["mqtt_host"], "mqtts://broker.acme:8883") + self.assertEqual(data["mqtt_prefix"], "acme") + self.assertEqual(data["org"], "acme") + self.assertEqual(data["mqtt_username"], "svc-client") + self.assertEqual(applied, [1]) + # 壳层确实按新配置重连了(tls 与端口由 URL 推出来) + self.assertEqual(self.window.mqtt.config["host"], "broker.acme") + self.assertEqual(self.window.mqtt.config["port"], 8883) + self.assertTrue(self.window.mqtt.config["tls"]) + # 状态行显示生效配置 + self.assertIn("broker.acme", dialog.mqtt_status.text()) + self.assertIn("已保存并重连", dialog.mqtt_status.text()) + + # 清空 broker 地址 = 回到"不启用推送"(同步仍然工作) + dialog.mqtt_host_edit.setText("") + dialog._save_mqtt_settings() + pump(_APP) + self.assertFalse(self.window.mqtt.config["enabled"]) + + def test_settings_dialog_shows_server_announced_broker(self): + """服务端下发的 broker 也算生效配置(设置里留空时状态行要说明来源)。""" + from svfs_client.dialogs import SettingsDialog + + session = {"username": "admin", "token": "tok", "org": "acme", + "mqtt": {"host": "broker.acme", "port": 8883, "tls": True}} + dialog = SettingsDialog( + self.base, apply_base_url=self.window.set_base_url, + session_provider=lambda: session, + ) + self.addCleanup(dialog.deleteLater) + self.assertIn("broker.acme", dialog.mqtt_status.text()) + self.assertIn("服务端下发", dialog.mqtt_status.text()) + + def test_settings_dialog_tabs_group_by_type(self): + """设置项按类型分页签,而不是堆成一长条。""" + from svfs_client.dialogs import SettingsDialog + + dialog = SettingsDialog(self.base, apply_base_url=self.window.set_base_url) + self.addCleanup(dialog.deleteLater) + self.assertEqual( + [dialog.tabs.tabText(i) for i in range(dialog.tabs.count())], + ["插件", "服务器", "存储", "网络", "诊断"]) + # 默认停在第一页:别的页上的控件此刻不可见 + self.assertTrue(dialog.startup_combo.isVisibleTo(dialog)) + self.assertFalse(dialog.server_edit.isVisibleTo(dialog)) + + # 各控件待在各自那一页 + for title, attr in (("服务器", "server_edit"), ("服务器", "mqtt_host_edit"), + ("服务器", "credentials_label"), ("存储", "cache_edit"), + ("存储", "cache_label"), ("网络", "proxy_edit"), + ("网络", "timeout_spin"), ("诊断", "log_check"), + ("插件", "startup_combo")): + self.assertTrue(dialog.show_tab(title), title) + widget = getattr(dialog, attr) + self.assertTrue(widget.isVisibleTo(dialog), + f"{attr} 不在「{title}」页签上") + # 找不到的页签:不炸,返回 False + self.assertFalse(dialog.show_tab("不存在的页")) + def test_settings_dialog_startup_and_cache(self): from svfs_client.dialogs import SettingsDialog @@ -1072,6 +1276,197 @@ if HAVE_QT: self.assertEqual(self.window.status_label.text(), "服务端返回 500") self.assertTrue(self.window.manager.banner.isVisible()) + @staticmethod + def _white_pixels(image, x0, y0, width, height): + """数一块区域里的近白像素(扫原始字节,比逐点 pixelColor 快很多)。""" + from PySide6.QtGui import QImage + + if image.format() != QImage.Format.Format_RGB32: + image = image.convertToFormat(QImage.Format.Format_RGB32) + raw = bytes(image.constBits()) + stride = image.bytesPerLine() + count = 0 + for y in range(y0, y0 + height): + start = y * stride + x0 * 4 + row = raw[start:start + width * 4] + for x in range(0, len(row), 4): + blue, green, red = row[x], row[x + 1], row[x + 2] + if red > 230 and green > 230 and blue > 230: + count += 1 + return count + + def test_collapsed_sidebar_leaves_no_white_gap(self): + """收缩侧栏后不该留下没重绘的白块。 + + 曾复现:主题只给 QMainWindow/QDialog 设了背景,页面栈是透明的, + 侧栏让出的那一条在重绘时序里露出白底——表现为页面左上角一块 + 44×30 的纯白方块(`#ffffff`)。修法是给 QStackedWidget 不透明底色 + (见 app.create_app 里的样式)。这条用例在图上一把尺子,两个页面都量。 + """ + self.window._set_sidebar_collapsed(True, animate=False) + for page in (self.window.manager, self.window.messages_page): + self.window.stack.setCurrentWidget(page) + pump(_APP) + image = self.window.grab().toImage() + # 量侧栏右侧那片(白块出现的地方)——竖着量满高,横向量 300px + whites = self._white_pixels(image, self.window.sidebar.width(), 0, + 300, image.height()) + self.assertEqual( + whites, 0, + f"{type(page).__name__} 在收缩侧栏后出现了 {whites} 个近白像素") + self.window._set_sidebar_collapsed(False, animate=False) + + def test_push_routes_host_and_plugin_messages(self): + """同一条总线:主机消息进消息中心,插件消息只进插件队列。""" + from svfs_client import topics + + self.window.set_session({"username": "admin", "token": "tok"}) + bus = self.window.bus + host_topic = topics.user_message_topic(bus.prefix, bus.org, "admin") + plugin_topic = topics.user_plugin_topic(bus.prefix, bus.org, "admin", + "com.example.dash") + + self.window._on_push(host_topic, '{"id": "m1", "title": "备份完成"}') + self.window._on_push(plugin_topic, '{"cpu": 0.5}') + pump(_APP) + + self.assertEqual(len(bus.log), 1) + self.assertEqual(self.window.messages_page.cards_box.count(), + 2) # 一张卡片 + 末尾 stretch + self.assertEqual(self.window.messages_button.text(), "✉1") + self.assertIn("1", self.window.messages_button.toolTip()) + # 插件消息没进消息中心,而是留在队列里等页面打开 + self.assertEqual(len(bus.log), 1) + self.assertEqual(bus.pending("com.example.dash"), 1) + self.assertEqual(self.window.messages_button.text(), "✉1") + + def test_messages_center_mark_read_and_clear(self): + from svfs_client import topics + + self.window.set_session({"username": "admin", "token": "tok"}) + topic = topics.user_message_topic(self.window.bus.prefix, + self.window.bus.org, "admin") + for index in range(2): + self.window._on_push(topic, '{"id": "m%d", "body": "x"}' % index) + pump(_APP) + self.assertEqual(self.window.messages_button.text(), "✉2") + + self.window._show_messages() + pump(_APP) + # 进消息中心即算读过(徽标不该一直挂着) + self.assertEqual(self.window.bus.log.unread(), 0) + self.assertEqual(self.window.messages_button.text(), "✉") + self.assertIs(self.window.stack.currentWidget(), + self.window.messages_page) + + self.window.messages_page.clear_button.click() + pump(_APP) + self.assertEqual(len(self.window.bus.log), 0) + self.assertTrue(self.window.messages_page.empty.isVisible()) + self.assertFalse(self.window.messages_page.read_button.isEnabled()) + + def test_plugin_push_is_buffered_until_page_opens(self): + """插件页面没开时消息先攒着,页面建好后一次性补投。""" + from svfs_client import topics + + self.window.set_session({"username": "admin", "token": "tok"}) + bus = self.window.bus + topic = topics.user_plugin_topic(bus.prefix, bus.org, "admin", "com.dash") + self.window._on_push(topic, '{"cpu": 1}') + self.assertEqual(bus.pending("com.dash"), 1) + + pushed = [] + + class _FakeView(QWidget): + def push(self, payload): + pushed.append(payload) + + def shutdown(self): # close_plugin 会调(见 tearDownModule) + pass + + self.window._views["com.dash"] = _FakeView() + self.window._flush_plugin_pushes("com.dash") + self.assertEqual(pushed, ['{"cpu": 1}']) + self.assertEqual(bus.pending("com.dash"), 0) + # 页面已经开着时是直接投递,不进队列 + self.window._on_push(topic, '{"cpu": 2}') + self.assertEqual(pushed, ['{"cpu": 1}', '{"cpu": 2}']) + self.assertEqual(bus.pending("com.dash"), 0) + + def test_login_sync_feeds_bus_and_dedupes_with_push(self): + """登录后先同步:补齐的消息与推送走同一条总线、按 id 去重。""" + from svfs_client import topics + + self.window.set_session({"username": "admin", "token": "tok"}) + topic = topics.user_message_topic(self.window.bus.prefix, + self.window.bus.org, "admin") + # 推送先到一条,补齐(HTTP)又把同一条送回来 → 只留一条 + self.window._on_push(topic, '{"id": "m1", "title": "备份完成"}') + self.window._on_messages_synced( + {"messages": [{"id": "m1", "title": "备份完成"}, + {"id": "m2", "title": "另一条"}], + "fetched": 2}, "admin") + pump(_APP) + self.assertEqual([m["id"] for m in self.window.bus.log.items()], + ["m1", "m2"]) + self.assertEqual(len(self.window.messages_page.cards_box.count() and + self.window.bus.log.items()), 2) + + # 没配服务器地址时同步不报错,只是拿不到东西(登录后的自动同步 + # 在测试环境里走的正是这条) + self.window._pending_sync = False + result = self.window.client.call_blocking("sync_messages", {}) + self.assertEqual(result["messages"], []) + self.assertFalse(result["fetched"]) + + def test_stale_sync_result_is_discarded(self): + """在途的补齐结果在退出登录/换账号后必须丢掉(不能喂给新会话)。""" + self.window.set_session({"username": "admin", "token": "tok"}) + self.window._on_messages_synced( + {"messages": [{"id": "m1", "body": "上一个人的消息"}]}, "admin") + self.assertEqual(len(self.window.bus.log), 1) + + # 退出登录:身份没了 → 在途结果丢弃(这里不抛异常也算断言的一部分) + self.window.logout() + pump(_APP) + self.window._on_messages_synced( + {"messages": [{"id": "m9", "body": "迟到的"}]}, "admin") + self.assertEqual(len(self.window.bus.log), 0) + + # 换了账号:同一条在途结果属于旧身份 → 也丢弃 + self.window.set_session({"username": "bob", "token": "tok2"}) + self.window._on_messages_synced( + {"messages": [{"id": "m9", "body": "迟到的"}]}, "admin") + self.assertEqual(len(self.window.bus.log), 0) + + def test_logout_stops_push_and_clears_messages(self): + from svfs_client import topics + + self.window.set_session({"username": "admin", "token": "tok"}) + topic = topics.user_message_topic(self.window.bus.prefix, + self.window.bus.org, "admin") + self.window._on_push(topic, '{"id": "m1", "body": "x"}') + self.assertEqual(len(self.window.bus.log), 1) + + self.window.logout() + pump(_APP) + # 上一个身份的消息不能留给下一个人看 + self.assertEqual(len(self.window.bus.log), 0) + self.assertEqual(self.window.messages_button.text(), "✉") + # 断开后推来的东西也进不来(总线身份已清空) + self.assertEqual( + self.window.bus.deliver(topic, '{"body": "x"}')["action"], + "forbidden") + + def test_mqtt_state_is_shown_in_message_center(self): + from svfs_client import mqtt + + # 走服务自己的状态机:它先更新状态、再发信号(生产路径就是这样) + self.window.mqtt._on_state(mqtt.STATE_ERROR, "认证失败") + pump(_APP) + self.assertIn("连接失败", self.window.messages_page.transport_label.text()) + self.assertIn("消息推送", self.window.status_label.text()) + def test_diagnostics_log_collects_errors(self): from svfs_client import diagnostics @@ -1124,10 +1519,12 @@ if HAVE_QT: self.window.manager.server_url_label.full_text(), "http://10.0.0.9:9000", ) - # 非法地址:报错、不落盘 + # 非法地址:报错、不落盘(服务器地址在"服务器"页签上) + dialog.show_tab("服务器") dialog.server_edit.setText("ftp://nope") dialog._apply_base_url_clicked() self.assertTrue(dialog.server_error.isVisibleTo(dialog)) + self.assertIn("http", dialog.server_error.text()) self.assertEqual( client_settings.load(self.base)["base_url"], "http://10.0.0.9:9000" ) @@ -1152,16 +1549,112 @@ if HAVE_QT: self.assertEqual(dialog.state_badge.text(), "已登录") self.assertTrue(dialog.logout_button.isEnabled()) self.assertTrue(dialog.relogin_button.objectName() != "Primary") - # 退出登录(真回调):会话清空、界面回到未登录态 + # 退出登录(真回调):凭据/会话清空,对话框关掉自己并把界面去向 + # 交给主窗口(登录门在那个模态循环外面跑) + self.assertFalse(dialog.logout_requested) dialog.logout_button.click() self.assertIsNone(self.window._session) - self.assertEqual(dialog.user_label.text(), "未登录") - self.assertFalse(dialog.logout_button.isEnabled()) - self.assertEqual(dialog.relogin_button.objectName(), "Primary") - self.assertEqual(dialog.state_badge.text(), "未登录") + self.assertTrue(dialog.logout_requested) + self.assertEqual(dialog.result(), QDialog.DialogCode.Accepted) # 侧栏头像同步回到「未登录」 self.assertEqual(self.window.user_button.text(), "未登录") + def test_logout_closes_window_and_returns_to_login(self): + """退出登录 = 关主窗口 + 回登录界面(强制登录下没有"未登录的主窗口")。""" + from svfs_client import credentials as client_credentials + + payload = self._submit_and_wait("install_sample", {}) + self.window.apply_payload(payload) + self.window.open_plugin(client_sample.PLUGIN_ID) + pump(_APP) + self.assertTrue(self.window._views, "插件页面没建起来,用例前提不成立") + self.window.set_session({"username": "admin", "token": "tok"}) + client_credentials.save(self.base, "tok", + base_url="http://127.0.0.1:1", + username="admin") + + fake = _FakeLoginWindow({"username": "bob", "token": "new"}, + visible_probe=lambda: self.window.isVisible()) + self._patch_login_window(fake) + self.window._logout_with_gate() + pump(_APP) + + # 凭据清干净、插件页面收掉(不该留着上一个账号的界面状态) + self.assertEqual(client_credentials.load(self.base), "") + self.assertEqual(self.window._views, {}) + # 登录期间主窗口是关掉的,登录成功后带着新会话回来 + self.assertFalse(fake.main_window_visible_during) + self.assertEqual(self.window._session["username"], "bob") + self.assertTrue(self.window.isVisible()) + self.assertEqual(self.window.user_button.text(), "bob") + + def test_logout_cancelled_login_exits_client(self): + """用户关掉登录窗:强制登录下没有可去的界面 → 关窗口并退出客户端。""" + fake = _FakeLoginWindow(None, + visible_probe=lambda: self.window.isVisible()) + self._patch_login_window(fake) + app = QApplication.instance() + quits = [] + original_quit = app.quit + app.quit = lambda: quits.append(1) + self.addCleanup(setattr, app, "quit", original_quit) + + self.window.set_session({"username": "admin", "token": "tok"}) + self.window._logout_with_gate() + pump(_APP) + + self.assertFalse(fake.main_window_visible_during) + self.assertFalse(self.window.isVisible()) + self.assertEqual(quits, [1], "没有请求退出客户端") + self.assertIsNone(self.window._session) + + def test_logout_with_real_login_window_cancelled(self): + """真登录窗(不填表,直接关掉):主窗口关掉、客户端请求退出。 + + 上面两条用替身验证登录门的时序;这条用真窗口走一遍 + show/exec/close,确认"隐藏主窗口 + 模态登录窗"在真 Qt 对象上不炸。 + """ + from svfs_client import login as client_login + + self.window.set_session({"username": "admin", "token": "tok"}) + app = QApplication.instance() + quits = [] + original_quit = app.quit + app.quit = lambda: quits.append(1) + self.addCleanup(setattr, app, "quit", original_quit) + + # 登录门是模态的(exec 阻塞),只能在嵌套循环里用定时器关掉它 + seen = {"login": False} + + def close_login(): + for widget in QApplication.topLevelWidgets(): + if isinstance(widget, client_login.LoginWindow): + seen["login"] = True + widget.reject() + return + + timer = QTimer() + timer.setInterval(50) + timer.timeout.connect(close_login) + timer.start() + self.addCleanup(timer.stop) + + self.window._logout_with_gate() + pump(_APP) + + self.assertTrue(seen["login"], "退出登录后没有出现登录窗") + self.assertFalse(self.window.isVisible(), "主窗口没关掉") + self.assertEqual(quits, [1], "没有请求退出客户端") + self.assertIsNone(self.window._session) + + def _patch_login_window(self, fake): + """把登录门里的登录窗换成替身(登录门内部才 import,patch 源头即可)。""" + from svfs_client import login as client_login + + original = client_login.LoginWindow + client_login.LoginWindow = lambda *args, **kwargs: fake + self.addCleanup(setattr, client_login, "LoginWindow", original) + if HAVE_QT and os.environ.get("SVFS_CLIENT_WEBENGINE_SMOKE", "1") != "0": @@ -1323,6 +1816,39 @@ if HAVE_QT and os.environ.get("SVFS_CLIENT_WEBENGINE_SMOKE", "1") != "0": self.assertIn("pong", net_result["body"]) self.assertIsNone(window.set_base_url("")) + # 消息通道端到端(页面级):插件订阅 → 宿主推送 → 回调收到。 + # 这条覆盖 v5 的 messages 能力(页面里的 svfs.messages 与 + # window.__svfsPush 的投递路径) + msgs = {} + page.runJavaScript( + "(function(){ window.__msg=null;" + " if (!window.svfs || !window.svfs.messages) { return 'no-api'; }" + " window.svfs.messages.subscribe(function (m) {" + " window.__msg = m.text; });" + " return 'ok'; })()", + 0, lambda r: msgs.update(api=r), + ) + self.assertTrue( + pump(_APP, predicate=lambda: msgs.get("api") is not None, + timeout=15), + "messages 桥探针没有拿到结果", + ) + self.assertEqual(msgs.get("api"), "ok", + "示例插件声明了 messages 权限,却拿不到 svfs.messages") + + view.push('{"kind": "dash", "value": 7}') + + def pushed_ready(): + page.runJavaScript("window.__msg", 0, + lambda r: msgs.update(received=r)) + return msgs.get("received") is not None + + self.assertTrue( + pump(_APP, predicate=pushed_ready, timeout=15), + f"推送没有到达插件页面: {msgs}", + ) + self.assertIn("dash", msgs["received"]) + # 渲染进程崩溃 → 崩溃占位页(而不是永久白屏) from svfs_client import diagnostics diff --git a/tests/test_plugin_server.py b/tests/test_plugin_server.py index 7695763..de2e19d 100644 --- a/tests/test_plugin_server.py +++ b/tests/test_plugin_server.py @@ -34,6 +34,14 @@ if HAVE_FASTAPI: from svfs_client import paths as client_paths from svfs_client import sample as client_sample + try: + # 自建最小 broker(见 test_client_mqtt)——端到端用例要一个真 broker, + # 但测试不应该依赖装 mosquitto + from test_client_mqtt import _MiniBroker, wait_until + HAVE_BROKER = True + except Exception: + HAVE_BROKER = False + _load_seq = [0] def _load_example(plugins_dir): @@ -104,6 +112,181 @@ if HAVE_FASTAPI: self.assertEqual(response.status_code, 401) self.assertIn("不正确", response.json()["detail"]) + def test_message_push_and_sync(self): + """服务端主动推消息:进该用户的待收列表,客户端登录后能补齐。""" + token = self._login().json()["token"] + headers = {"Authorization": f"Bearer {token}"} + + response = self.client.post("/api/push", headers=headers, json={ + "kind": "message", "title": "备份完成", "body": "3 个包", + "level": "warn"}) + self.assertEqual(response.status_code, 200, response.text) + body = response.json() + # 主题按方案拼(组织位在第一段之后,多租户就靠它隔离) + self.assertEqual(body["topic"], "svfs/default/user/admin/message") + self.assertEqual(body["message"]["title"], "备份完成") + self.assertTrue(body["message"]["id"]) + self.assertTrue(body["stored"]) + # 没配 broker:如实说明是跳过了,不是假装发成功 + self.assertIn("skipped", body["mqtt"]) + + # 补齐:默认全给;给了 since 只给更新的 + response = self.client.get("/api/messages", headers=headers) + self.assertEqual(len(response.json()["messages"]), 1) + newest = response.json()["messages"][0]["time"] + response = self.client.get(f"/api/messages?since={newest}", + headers=headers) + self.assertEqual(response.json()["messages"], []) + + # 发给别人的消息进别人的待收列表(自己的列表不受影响) + response = self.client.post("/api/push", headers=headers, + json={"title": "给别人的", "user": "bob"}) + self.assertEqual(response.json()["topic"], + "svfs/default/user/bob/message") + self.assertEqual( + len(self.client.get("/api/messages", headers=headers) + .json()["messages"]), 1) + + def test_plugin_push_goes_to_plugin_topic_and_not_backlog(self): + """插件消息走插件主题、**不进**待收列表(仪表盘数据补历史没意义)。""" + token = self._login().json()["token"] + headers = {"Authorization": f"Bearer {token}"} + response = self.client.post("/api/push", headers=headers, json={ + "kind": "plugin", "plugin": "com.svfs.demo", "data": {"cpu": 0.42}}) + self.assertEqual(response.status_code, 200, response.text) + body = response.json() + self.assertEqual(body["topic"], + "svfs/default/user/admin/plugin/com.svfs.demo") + self.assertFalse(body["stored"]) + self.assertEqual( + self.client.get("/api/messages", headers=headers).json()["messages"], + []) + + # 少了 plugin 字段:400,别猜 + response = self.client.post("/api/push", headers=headers, + json={"kind": "plugin"}) + self.assertEqual(response.status_code, 400) + + def test_broadcast_and_org_announcement(self): + """广播走组织主题;登录响应下发组织标识(多租户预留位)。""" + body = self._login().json() + self.assertEqual(body["org"], "default") + self.assertNotIn("mqtt", body) # 没配 broker 就不下发 + + headers = {"Authorization": f"Bearer {body['token']}"} + response = self.client.post("/api/broadcast", headers=headers, + json={"title": "全员通知", "body": "维护"}) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["topic"], + "svfs/default/broadcast/message") + + def test_org_and_broker_announcement_follows_env(self): + """服务端配了组织/broker:登录与 /api/me 都下发(客户端据此订阅)。""" + keys = ("SVFS_ORG", "SVFS_MQTT_HOST", "SVFS_MQTT_PORT", "SVFS_MQTT_TLS") + old = {key: os.environ.get(key) for key in keys} + + def restore(): + for key, value in old.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + self.addCleanup(restore) + os.environ.update({"SVFS_ORG": "acme", "SVFS_MQTT_HOST": "broker.acme", + "SVFS_MQTT_PORT": "8883", "SVFS_MQTT_TLS": "true"}) + + body = self._login().json() + self.assertEqual(body["org"], "acme") + self.assertEqual(body["mqtt"], {"host": "broker.acme", "tls": True, + "port": 8883, "prefix": "svfs", + "org": "acme"}) + headers = {"Authorization": f"Bearer {body['token']}"} + self.assertEqual( + self.client.get("/api/me", headers=headers).json()["org"], "acme") + # 主题里的组织段跟着变(多租户隔离就靠它) + response = self.client.post("/api/push", headers=headers, + json={"title": "x"}) + self.assertEqual(response.json()["topic"], + "svfs/acme/user/admin/message") + + def test_push_requires_auth(self): + self.assertEqual( + self.client.post("/api/push", json={"title": "x"}).status_code, 401) + self.assertEqual(self.client.get("/api/messages").status_code, 401) + + @unittest.skipUnless(HAVE_BROKER, "需要 paho-mqtt 与自建 broker 助手") + def test_server_push_reaches_client_over_mqtt(self): + """端到端:``/api/push`` → broker → 客户端 MQTT 通道 → 消息中心。 + + 这条链路把三方接在一起验证:**broker 地址由登录响应下发**(客户端 + 不用手配)、服务端按主题方案发布、客户端用同一套主题方案订阅并让 + 消息落进总线。 + """ + from svfs_client import settings as client_settings + from svfs_client.bus import MessageBus + from svfs_client.mqtt import MqttTransport + + broker = _MiniBroker() + self.addCleanup(broker.close) + keys = ("SVFS_MQTT_HOST", "SVFS_MQTT_PORT", "SVFS_ORG") + old = {key: os.environ.get(key) for key in keys} + + def restore(): + for key, value in old.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + self.addCleanup(restore) + os.environ.update({"SVFS_MQTT_HOST": "127.0.0.1", + "SVFS_MQTT_PORT": str(broker.port), + "SVFS_ORG": "acme"}) + + session = self._login().json() + self.assertEqual(session["mqtt"]["host"], "127.0.0.1") + self.assertEqual(session["org"], "acme") + + # 客户端侧:与服务端同一套主题方案 + 同一个解析器 + base = client_paths.ensure_layout(self.path("data")) + config = client_settings.mqtt_config(base, session) + self.assertTrue(config["enabled"]) + self.assertEqual(config["org"], "acme") + bus = MessageBus(org=config["org"], user=session["username"]) + transport = MqttTransport(config, on_message=bus.deliver) + self.addCleanup(transport.stop) + transport.start() + self.assertTrue(wait_until(lambda: transport.is_connected()), + "客户端没有连上 broker") + + # 服务端推一条 → 经 broker 到客户端 + headers = {"Authorization": f"Bearer {session['token']}"} + response = self.client.post("/api/push", headers=headers, json={ + "kind": "message", "title": "备份完成", "body": "3 个包"}) + self.assertEqual(response.json()["mqtt"], "published") + self.assertTrue(wait_until(lambda: len(bus.log) == 1), + "推送没有到达客户端,消息中心是空的") + message = bus.log.items()[0] + self.assertEqual(message["title"], "备份完成") + self.assertEqual(message["scope"], "user") + + # 插件消息走另一条通道:不进消息中心,只进该插件的队列 + response = self.client.post("/api/push", headers=headers, json={ + "kind": "plugin", "plugin": "com.svfs.demo", "data": {"cpu": 0.5}}) + self.assertEqual(response.json()["mqtt"], "published") + self.assertTrue( + wait_until(lambda: bus.pending("com.svfs.demo") == 1), + "插件消息没有到达插件队列") + self.assertEqual(len(bus.log), 1) + + # 组织广播:进消息中心并带 broadcast 标记 + response = self.client.post("/api/broadcast", headers=headers, + json={"title": "全员通知"}) + self.assertEqual(response.json()["mqtt"], "published") + self.assertTrue(wait_until(lambda: len(bus.log) == 2)) + self.assertEqual(bus.log.items()[1]["scope"], "broadcast") + def test_me_and_logout(self): token = self._login().json()["token"] headers = {"Authorization": f"Bearer {token}"} diff --git a/uv.lock b/uv.lock index 445ca08..b2fae3a 100644 --- a/uv.lock +++ b/uv.lock @@ -940,6 +940,15 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "paho-mqtt" +version = "2.1.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, +] + [[package]] name = "pycparser" version = "2.23" @@ -1477,6 +1486,9 @@ gui = [ { name = "pyside6", version = "6.10.3", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.10'" }, { name = "pyside6", version = "6.11.2", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10'" }, ] +mqtt = [ + { name = "paho-mqtt" }, +] studio = [ { name = "pyside6-essentials", version = "6.10.3", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.10'" }, { name = "pyside6-essentials", version = "6.11.2", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10'" }, @@ -1486,6 +1498,7 @@ studio = [ dev = [ { name = "httpx2", version = "2.0.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.10'" }, { name = "httpx2", version = "2.13.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "paho-mqtt" }, { name = "twine", version = "6.2.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.10'" }, { name = "twine", version = "7.0.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10'" }, ] @@ -1493,6 +1506,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100" }, + { name = "paho-mqtt", marker = "extra == 'mqtt'", specifier = ">=2.0" }, { name = "pyside6", marker = "extra == 'client'", specifier = ">=6.6" }, { name = "pyside6", marker = "extra == 'gui'", specifier = ">=6.6" }, { name = "pyside6-essentials", marker = "extra == 'studio'", specifier = ">=6.6" }, @@ -1500,11 +1514,12 @@ requires-dist = [ { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27" }, { name = "uvicorn", marker = "extra == 'fastapi'", specifier = ">=0.23" }, ] -provides-extras = ["fastapi", "studio", "client", "gui"] +provides-extras = ["fastapi", "mqtt", "studio", "client", "gui"] [package.metadata.requires-dev] dev = [ { name = "httpx2", specifier = ">=0.1" }, + { name = "paho-mqtt", specifier = ">=2.0" }, { name = "twine", specifier = ">=4.0.2" }, ]