feat: 消息模块(MQTT 推送 + 统一总线),并补退出登录/顺序编排/设置分页签

一、消息模块:服务端主动推送 + 插件定向分发
* topics.py:主题方案 <prefix>/<org>/user/<user>/message、.../user/<user>/plugin/<id>、
  .../broadcast/{message,plugin/<id>}。org 是租户位(多租户上线不用改格式,broker
  按 <prefix>/<org>/# 下发 ACL 即可隔离);用户名/插件 id 经**可逆**百分号编码占
  一个层级,客户端与示例服务端共用同一套拼主题函数;
* bus.py:统一消息总线——按**主题**分类(主机消息进消息中心、插件消息不进,仪表盘
  数据那类业务载荷永远不会混进给人看的列表)、归属校验(org/user 不符即丢弃,broker
  ACL 配错也挡得住)、按 id 去重(QoS 1 重投与"推送/补齐重叠"都不会重复显示)、
  插件消息按插件缓存 50 条,等页面打开后一次性补投;
* mqtt.py + mqtt_service.py:paho 适配(可选依赖 [mqtt])+ Qt 适配(paho 的网络
  线程经排队信号把消息交给主线程的总线);重连交给 paho(指数退避),重连成功
  自动重新订阅;没装 paho / 没配 broker 都只是"没有实时推送",HTTP 补齐照常;
* worker.py:登录后**先**补齐(GET /api/messages?since=)再连 MQTT,两条路进同一
  总线;在途结果带身份快照,退出登录/换账号后丢弃;
* 界面:侧栏 ✉ 未读徽标 + 消息中心(级别/标题/时间/来源/广播标记、立即同步、
  全部已读、清空、通道状态灯);warn/error 顺手发系统托盘通知;
* 插件侧:新权限 messages(PLUGIN_API_VERSION→5),页面里 svfs.messages.subscribe();
  宿主经 window.__svfsPush 投递,早到的先排队、订阅时补发(桥调试台已接上演示);
* examples/plugin_server.py:/api/messages、/api/push(kind=message|plugin)、
  /api/broadcast;配了 SVFS_MQTT_HOST 就真发布到 broker,并把 broker 地址随登录
  响应下发(/api/login 与 /api/me 都带),客户端不用手填。

二、退出登录 = 关掉主窗口 + 回到登录界面
* 强制登录下"未登录的主窗口"本就不该存在:重新登录成功用新会话开回主窗口,关掉
  登录窗即退出客户端;退出时断开推送、清掉该账号收到的消息与总线身份(下一个人
  不该看到上一个人的消息),并收掉已打开的插件页面;
* 用户中心的"退出登录"先关掉自己再进登录门,避免模态窗口叠模态。

三、插件顺序编排
* installer.arrange():编排过的在前、未编排的按名称;管理页卡片加 ↑/↓,侧栏/图标栏/
  管理页三者一致;存 settings 的 plugin_order,只影响显示(不动包与注册表);
* "启动时打开"的默认规则改为"显示顺序里第一个启用的插件"(原来是按 id 排);
* 新增「恢复默认顺序」(编排过才出现)。

四、修复
* 收缩侧栏后页面左上角露出一块纯白方块:页面栈对 Qt 是透明的(主题只给
  QMainWindow/QDialog 设了背景),侧栏让出的那条在重绘时序里露白。给 QStackedWidget
  不透明底色,并加像素级回归用例(撤掉修复即 FAIL,实测 1237 个白像素);
* build_opener() 每次都要隐式枚举 Windows 证书库:全量测试跑几百次后以断点异常
  崩在证书枚举里(faulthandler 定位在 _load_windows_store_certs)。改成进程内只建
  一次 SSL context、启动时在主线程预建;
* 在途的消息补齐结果会喂给新会话(退出登录时用户名已空 → 拼主题抛异常,被 Qt 回调
  静默吞掉):带身份快照,不匹配即丢弃。

五、设置界面按类型分页签(插件 / 服务器 / 存储 / 网络 / 诊断)
* 每页内容在滚动区里(窗口拉小或以后再加项是滚动条,而不是把控件压扁);
* 新增「消息推送(MQTT)」一节:broker(host / host:port / mqtts://host:8883)、组织、
  端口、TLS、主题前缀、broker 账号,保存即重连;状态行显示**生效**配置与来源
  (手配 / 服务端下发)。broker 密码不进界面:默认用登录 token(已加密存储),需要
  单独的密码时由服务端下发——与"凭据不明文落盘"保持一致。

测试与验证
* 新增 tests/test_client_messages.py(主题/封皮/总线,纯层)、tests/test_client_mqtt.py
  (装配层 + 自建最小 broker 的端到端,不依赖 mosquitto)、test_plugin_server.py 的
  消息接口与"/api/push → broker → 客户端总线"闭环;WebEngine 冒烟里加了页面级
  messages 探针;
* **测试入口改为 tests/run_tests.py**:QtWebEngine 在 offscreen 下的解释器退出路径会
  崩(测试全绿但退出码 139,CI 只看退出码就会判失败),runner 按 unittest 的结果
  os._exit 绕开那段清理;测试真崩在中途时退出码照样非 0(用故意失败的用例验证过)。
  README 与 CI 都切到这个入口;
* 全量 720 条通过(6 skip),exit 0。
This commit is contained in:
2026-09-17 01:06:02 +08:00
parent ae9fc8dbe4
commit e0d5bad7b2
29 changed files with 4105 additions and 105 deletions
+3 -1
View File
@@ -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
+151 -23
View File
@@ -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() 取消订阅
</script>
```
`host.info` 不需要权限——它只包含插件自己的元数据、宿主版本号与配置的服务器
地址,不含用户数据。
### 消息模块:服务端主动推送(MQTT)
服务端要能随时把消息推给某个用户(或某个插件),走的是**统一消息总线**:
所有通道的消息先进总线,由总线按主题分类、去重、再分发。两条通道:
| 通道 | 时机 | 说明 |
|---|---|---|
| MQTT 推送 | 实时 | 服务端主动推;broker 地址由登录响应下发(也可在设置里手配) |
| HTTP 补齐 | **登录成功后先做一次**,也可点「立即同步」 | `GET /api/messages?since=…` 把离线期间攒下的消息补回来 |
两条路的消息都喂进同一个总线、按 id 去重,所以"推送"与"补齐"重叠、QoS 1
重投都不会重复显示。
**主题方案**(组织 `org` 是租户位,从第一版就占好——多租户上线不用改格式,
broker 侧按 `<prefix>/<org>/#` 下发 ACL 即可隔离):
```text
<prefix>/<org>/user/<user>/message 主机消息 → 消息中心
<prefix>/<org>/user/<user>/plugin/<plugin_id> 插件消息 → 只给该插件
<prefix>/<org>/broadcast/message 组织内广播 → 消息中心
<prefix>/<org>/broadcast/plugin/<plugin_id> 组织内广播 → 只给该插件
```
* 登录后客户端只订阅两条(最小权限,不是整个组织):`<prefix>/<org>/user/<user>/#`
与 `<prefix>/<org>/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
+190 -4
View File
@@ -39,12 +39,15 @@
└── blobs/<hash 前 2 位>/<hash>
"""
import json
import os
import secrets
import sqlite3
import tempfile
import threading
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import Depends, FastAPI, File, Header, HTTPException, Request, UploadFile
from fastapi.staticfiles import StaticFiles
@@ -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),登录后客户端先同步一次补齐
# ----------------------------------------------------------------------
#
# 主题方案(组织标识在第一版就占好位子,多租户上线不用改格式):
#
# <prefix>/<org>/user/<用户名>/message 主机消息 → 客户端的消息中心
# <prefix>/<org>/user/<用户名>/plugin/<插件id> 插件消息 → 只给该插件
# <prefix>/<org>/broadcast/message 组织广播 → 消息中心
#
# 服务端与客户端用同一套拼主题的函数(直接 import svfs_client.topics),
# 用户名里的特殊字符两端编码一致,不会错位。
#
# 演示跑法(需要一个 broker,比如 mosquitto):
#
# SVFS_MQTT_HOST=127.0.0.1 SVFS_ORG=acme uv run python examples/plugin_server.py
# # 客户端登录后会自动连上(broker 地址由登录响应下发),然后:
# curl -X POST http://127.0.0.1:8000/api/push -H "Authorization: Bearer $token" \
# -H "Content-Type: application/json" \
# -d '{"kind":"message","title":"备份完成","body":"3 个包","level":"info"}'
# # 插件数据(不进消息中心):
# curl -X POST http://127.0.0.1:8000/api/push -H "Authorization: Bearer $token" \
# -H "Content-Type: application/json" \
# -d '{"kind":"plugin","plugin":"com.svfs.demo","data":{"cpu":0.42}}'
#
# 没配 broker 时 /api/push 只写待收列表(响应里如实说明 mqtt=skipped),
# 客户端仍能靠登录后的同步看到消息——这条降级路径让演示不依赖 broker。
#: 每个用户的待收消息上限(演示用;生产该入库)
MESSAGE_BACKLOG = 100
#: 待收列表:username → [消息, …](内存,重启即清空)
MESSAGES: dict = {}
#: 同一条消息可能被多个客户端(同账号多端)各收一次,所以按用户存
_mqtt_lock = threading.Lock()
def _mqtt_prefix():
return os.environ.get("SVFS_MQTT_PREFIX", "svfs").strip() or "svfs"
def _org():
return os.environ.get("SVFS_ORG", "").strip() or "default"
def _mqtt_announcement():
"""登录响应里下发 broker 信息(客户端据此自动订阅,不用用户手填)。
没配 ``SVFS_MQTT_HOST`` 时返回 None——客户端就走"只同步、不推送"的降级。
"""
host = os.environ.get("SVFS_MQTT_HOST", "").strip()
if not host:
return None
tls = os.environ.get("SVFS_MQTT_TLS", "").strip().lower() in ("1", "true", "yes")
announcement = {"host": host, "tls": tls,
"prefix": _mqtt_prefix(), "org": _org()}
port = os.environ.get("SVFS_MQTT_PORT", "").strip()
if port.isdigit():
announcement["port"] = int(port)
return announcement
def _mqtt_publish(topic, payload):
"""发一条到 broker;没配 broker / 没装 paho 都返回说明字符串(不抛)。"""
host = os.environ.get("SVFS_MQTT_HOST", "").strip()
if not host:
return "skipped: 未配置 SVFS_MQTT_HOST(消息只进了待收列表)"
try:
import paho.mqtt.client as mqtt
except Exception:
return "skipped: 服务端没装 paho-mqtt(pip install paho-mqtt)"
tls = os.environ.get("SVFS_MQTT_TLS", "").strip().lower() in ("1", "true", "yes")
port = int(os.environ.get("SVFS_MQTT_PORT", "") or (8883 if tls else 1883))
kwargs = {"client_id": f"svfs-server-{secrets.token_hex(4)}"}
api_version = getattr(mqtt, "CallbackAPIVersion", None)
if api_version is not None: # paho 2.x 要求显式声明回调 API 版本
kwargs["callback_api_version"] = api_version.VERSION2
client = mqtt.Client(**kwargs)
if tls:
client.tls_set()
try:
with _mqtt_lock: # 演示用:一次一条,连上→发→断开
client.connect(host, port, 10)
client.loop_start()
info = client.publish(topic, payload, qos=0)
info.wait_for_publish(timeout=5)
client.loop_stop()
client.disconnect()
except Exception as exc:
return f"error: {exc}"
return "published"
class PushBody(BaseModel):
"""推送请求:``kind`` 决定走哪条通道。"""
kind: str = "message" # message = 给人看的;plugin = 给插件的业务载荷
user: str = "" # 目标用户(空 = 发给调用者自己)
plugin: str = "" # kind=plugin 时必填:目标插件 id
title: str = ""
body: str = ""
level: str = "info"
data: Optional[dict] = None # kind=plugin 的载荷(原样发给插件)
id: str = ""
@app.get("/api/messages")
def list_messages(since: str = "", session: dict = Depends(require_auth)):
"""客户端登录后/点「立即同步」时补齐消息(``since`` = 本地最新一条的时间)。"""
items = list(MESSAGES.get(session["username"], []))
if since:
items = [item for item in items if str(item.get("time") or "") > since]
return {"ok": True, "messages": items}
@app.post("/api/push")
def push_message(body: PushBody, session: dict = Depends(require_auth)):
"""服务端主动推一条:写待收列表(离线也能补齐)+ 发到 MQTT(在线实时到)。"""
target = (body.user or session["username"]).strip()
if not target:
raise HTTPException(status_code=400, detail="缺少目标用户")
if body.kind == "plugin":
if not body.plugin:
raise HTTPException(status_code=400, detail="kind=plugin 需要 plugin 字段")
topic = user_plugin_topic(_mqtt_prefix(), _org(), target, body.plugin)
payload = json.dumps(body.data if body.data is not None else {},
ensure_ascii=False)
# 插件数据**不进**消息中心,也不进待收列表:它是实时的业务载荷,
# 补历史没有意义(仪表盘要的是当下)。
return {"ok": True, "topic": topic, "mqtt": _mqtt_publish(topic, payload),
"stored": False}
message = {
"id": body.id or secrets.token_hex(8),
"title": body.title,
"body": body.body,
"level": body.level if body.level in ("info", "warn", "error") else "info",
"time": datetime.now().isoformat(timespec="seconds"),
"source": "example-server",
}
backlog = MESSAGES.setdefault(target, [])
backlog.append(message)
del backlog[:-MESSAGE_BACKLOG]
topic = user_message_topic(_mqtt_prefix(), _org(), target)
return {"ok": True, "topic": topic, "message": message, "stored": True,
"mqtt": _mqtt_publish(topic, json.dumps(message, ensure_ascii=False))}
@app.post("/api/broadcast")
def broadcast_message(body: PushBody, session: dict = Depends(require_auth)):
"""组织广播:发给整个组织(消息中心显示,带"广播"标记)。"""
topic = broadcast_message_topic(_mqtt_prefix(), _org())
message = {
"id": body.id or secrets.token_hex(8),
"title": body.title or "组织通知",
"body": body.body,
"level": body.level if body.level in ("info", "warn", "error") else "info",
"time": datetime.now().isoformat(timespec="seconds"),
"source": f"broadcast@{session['username']}",
}
return {"ok": True, "topic": topic, "message": message, "stored": False,
"mqtt": _mqtt_publish(topic, json.dumps(message, ensure_ascii=False))}
# ----------------------------------------------------------------------
# 客户端自更新信息(可选)
# ----------------------------------------------------------------------
+9
View File
@@ -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]
+2 -1
View File
@@ -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
+5
View File
@@ -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; }}
+13
View File
@@ -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,
+192
View File
@@ -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")
+220 -34
View File
@@ -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()
+27
View File
@@ -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`` 的内容解析成客户端更新检查结果。
+3 -1
View File
@@ -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")
+185
View File
@@ -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()
+188
View File
@@ -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))
+234
View File
@@ -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"
+98
View File
@@ -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)
+44 -2
View File
@@ -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,12 +148,19 @@ 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 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)
+37 -2
View File
@@ -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 = """\
<pre id="net-out" class="note">未请求。相对 path 会拼在客户端配置的服务器地址后。</pre>
</section>
<section class="card">
<h2>消息(messages 权限,服务端主动推送)</h2>
<p class="note">服务端按插件 id 定向推送的消息;这类消息不进宿主的消息中心。</p>
<div class="row">
<button id="msg-listen">开始订阅</button>
<button id="msg-clear">清空记录</button>
<span class="note">已收 <b id="msg-count">0</b> 条</span>
</div>
<pre id="msg-out" class="note">未订阅。</pre>
</section>
<section class="card">
<h2>通知 / 标题(notify / title 权限)</h2>
<button id="notify">发一条原生通知</button>
@@ -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 + ")");
+181 -3
View File
@@ -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")
+378 -13
View File
@@ -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:
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""消息主题方案(纯标准库):把"谁、在哪个组织、发到哪条通道"编码进 MQTT 主题。
主题结构(``org`` 是**组织/租户标识**,从第一版就占好位子——多租户上线时
不用改主题格式,broker 侧按 ``<prefix>/<org>/#`` 下发 ACL 就能隔离)::
<prefix>/<org>/user/<user>/message 主机消息 → 消息中心
<prefix>/<org>/user/<user>/plugin/<plugin_id> 插件消息 → 只给该插件
<prefix>/<org>/broadcast/message 组织内广播 → 消息中心
<prefix>/<org>/broadcast/plugin/<plugin_id> 组织内广播 → 只给该插件
举例(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
两条通道泾渭分明——**插件消息不会进消息中心**(仪表盘数据之类的业务载荷
不该混进给人看的消息列表),主机消息也不投给插件:分类只由主题决定,不看
载荷内容,所以服务端不会"一不小心把调试数据推到用户的消息列表里"。
客户端登录后订阅两条(最小权限,不是 ``<org>/#`` 一把梭)::
<prefix>/<org>/user/<user>/# 自己的消息(主机 + 插件)
<prefix>/<org>/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/<user>/message | user/<user>/plugin/<plugin_id>
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/<plugin_id>
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
+61 -1
View File
@@ -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 "")))
+53 -2
View File
@@ -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 <base>/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):
+58
View File
@@ -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()
+73 -4
View File
@@ -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):
"""诊断日志:环形缓冲、报告、自身绝不抛异常。"""
+330
View File
@@ -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()
+473
View File
@@ -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()
+537 -11
View File
@@ -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
+183
View File
@@ -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}"}
Generated
+16 -1
View File
@@ -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" },
]