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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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))}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 客户端自更新信息(可选)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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`` 的内容解析成客户端更新检查结果。
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
import os
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
@@ -28,7 +29,7 @@ import urllib.request
|
||||
from . import diagnostics
|
||||
|
||||
__all__ = ["is_loopback", "configure", "apply_settings", "current", "urlopen",
|
||||
"open_with_retry"]
|
||||
"open_with_retry", "prepare_ssl"]
|
||||
|
||||
#: 触发重试的服务器状态码(临时不可用/网关抖动;其它 4xx/5xx 重试没意义)
|
||||
RETRY_STATUS = (502, 503, 504)
|
||||
@@ -46,6 +47,40 @@ _ca_bundle = ""
|
||||
#: opener 缓存:key = (loopback, proxy, ca_bundle)
|
||||
_openers = {}
|
||||
|
||||
#: 默认 SSL context 只建一次(见 _ssl_context)
|
||||
_default_context = None
|
||||
_context_lock = threading.Lock()
|
||||
|
||||
|
||||
def prepare_ssl():
|
||||
"""在主线程提前把默认 SSL context 建好(启动时调一次)。
|
||||
|
||||
为什么不在用的时候现建:``ssl.create_default_context()`` 在 Windows 上要
|
||||
枚举**系统证书库**,这件事(实测)又慢又脆——全量测试跑出几百次以后会以
|
||||
Windows 断点异常(0x80000003)崩在证书枚举里。所以:
|
||||
|
||||
* 进程内只建一次、加锁复用(见 :func:`_ssl_context`);
|
||||
* 启动时在**主线程**建好,别让 worker 线程去碰系统证书库。
|
||||
|
||||
返回新建的 context(已建过则返回缓存的那个);失败时返回 None(调用方照常
|
||||
走系统默认行为,不至于因为预建失败就不能联网)。
|
||||
"""
|
||||
try:
|
||||
return _ssl_context()
|
||||
except Exception as exc: # pragma: no cover - 环境级异常
|
||||
diagnostics.log(f"预建默认 SSL context 失败(忽略): {exc}", "net")
|
||||
return None
|
||||
|
||||
|
||||
def _ssl_context():
|
||||
"""进程内共享的默认 SSL context(懒建 + 加锁)。"""
|
||||
global _default_context
|
||||
if _default_context is None:
|
||||
with _context_lock:
|
||||
if _default_context is None:
|
||||
_default_context = ssl.create_default_context()
|
||||
return _default_context
|
||||
|
||||
|
||||
def is_loopback(url):
|
||||
"""目标是否指向本机(host 为 localhost/127.0.0.1/::1 或 *.localhost)。"""
|
||||
@@ -113,14 +148,21 @@ def _opener(loopback):
|
||||
elif _proxy:
|
||||
handlers.append(urllib.request.ProxyHandler(
|
||||
{"http": _proxy, "https": _proxy}))
|
||||
# 永远显式给 HTTPSHandler:不给的话 build_opener 会自己补一个,而它补的那个
|
||||
# 每次都要现建 SSL context(Windows 上 = 枚举系统证书库,见 prepare_ssl)
|
||||
context = None
|
||||
if _ca_bundle:
|
||||
try:
|
||||
context = ssl.create_default_context(cafile=_ca_bundle)
|
||||
except (OSError, ssl.SSLError) as exc:
|
||||
diagnostics.log(f"CA 证书无法加载({_ca_bundle}): {exc}", "net")
|
||||
context = None
|
||||
if context is not None:
|
||||
handlers.append(urllib.request.HTTPSHandler(context=context))
|
||||
if context is None:
|
||||
try:
|
||||
context = _ssl_context()
|
||||
except Exception as exc: # 建不出来就退回系统默认行为
|
||||
diagnostics.log(f"默认 SSL context 不可用(忽略): {exc}", "net")
|
||||
if context is not None:
|
||||
handlers.append(urllib.request.HTTPSHandler(context=context))
|
||||
opener = urllib.request.build_opener(*handlers)
|
||||
_openers[key] = opener
|
||||
return opener
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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 "")))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
"""诊断日志:环形缓冲、报告、自身绝不抛异常。"""
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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}"}
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user