SteamOS 音乐插件设计方案
目标:为通用 SteamOS 游戏模式的 Decky Loader 写一个插件,集成 QQ 音乐 与 网易云音乐 的播放支持,不限定 Steam Deck 品牌。 核心思想:插件本体只做 UI + 通信桥,真正的业务逻辑与音频播放作为独立进程运行在 Decky 沙盒之外,以换取稳定性与不受 Decky 构建环境约束。 兼容范围:x86-64、glibc ≥ 2.39、用户会话 PipeWire + ALSA 兼容层。已有真机记录来自 Steam Deck; 非 Deck SteamOS 设备尚缺硬件验收,不能将下文历史 Deck 验证记录推广为所有设备已通过。
1. 背景与约束来源
Decky 插件的后端不是独立进程,而是被 loader 用 multiprocessing.Process fork 出来的子进程:
- 执行时复用 Decky 自己 PyInstaller 打包的冻结 Python 解释器(
backend/pyinstaller.spec),不是系统 Python,也不是插件自带的 venv。 - 启动时按 Decky 配置的非特权用户降权,除非
plugin.json的flags含root。插件不要求用户名为deck或 UID 为 1000。 - 只能通过
sys.path.append(.../py_modules)加载纯 Python 模块(sandboxed_plugin.py:84)。
推论:任何带编译扩展的库都不能直接进插件后端。
QQMusicApi(Python)依赖 cryptography / orjson / niquests[speedups] / pydantic(Rust core)/ paho-mqtt,全是需精确匹配 CPython ABI + glibc 的编译 wheel —— 塞进冻结解释器必然随 Decky 版本升级而崩。这正是"受 Decky 构建环境约束 + 容易崩溃"的根因。(现在两个音源都改用 Rust 库,合并进一个 provider 二进制,见 §7。)
结论:业务后端必须脱离插件进程,以独立二进制运行。 一旦 bridge subprocess 拉起外部二进制,该进程即带自己的运行时、脱离冻结解释器 —— 稳定性与"脱沙盒"由此达成,与二进制内部是一个还是多个无关。
2. 总体架构
三类角色、多进程,UI 的所有流量强制经过 bridge:
graph TB UI[前端 React UI<br/>只发事件·只收 emit] BR[Python bridge<br/>main.py Plugin·总线+进程管理] P[provider 进程<br/>Rust:qq + ncm 两个后端<br/>出元数据·歌词·可播 URL] PL[player 进程<br/>Rust<br/>拉流·解码·出声] UI <-->|① Decky RPC<br/>callable / emit| BR BR <-->|② UDS · NDJSON<br/>provider-EPOCH.sock| P BR <-->|② UDS · NDJSON<br/>player.sock| PL PL -.->|动态链 libasound| PW[(PipeWire 守护进程<br/>系统常驻)]
设计红线(用户明确要求):
- UI 只跟 bridge 说话,不知道 provider/player 存在,不直接碰后端。
- 不走 TCP,全部 unix domain socket。
- player 是独立进程,与 provider 分离,崩溃互不影响。
3. 组件职责
| 组件 | 语言/形态 | 职责 | 明确不做 |
|---|---|---|---|
| UI | React (TS) | 发控制事件、收状态推送、渲染 | 不碰音频流、不碰 URL、不直连后端 |
| bridge | Python (main.py 门面 + py_modules 实现) |
子进程/连接管理、请求与事件分发、账号与设置持久化;由 Playback 负责普通队列/电台、自动切歌、失败重试和播放态回灌 |
不实现音源 API/可播 URL 解析,不拉流、解码或输出音频 |
| provider | Rust(一个进程,qq / ncm 两个后端) | 搜索、歌单、歌词、解析可播 URL;按请求的音源标签路由 | 不播放音频 |
| player | Rust | 拿 URL:HTTP 拉流、解码、seek、音量、推 PipeWire;上报进度/结束 | 不查询音乐 API |
职责分离的意义:provider 崩了不影响正在播放的 player;player 崩了不影响查询;bridge 负责把崩掉的子进程重新拉起。
4. 进程模型与生命周期
- player:常驻。bridge
_main()时即 spawn。 - provider:一个常驻进程,同时承载
qq/ncm两个后端;选定音源后首次需要时拉起,之后常驻。 切换音源不换进程:bridge 只换音源令牌Selection(provider, connection),之后的请求顶层带新的provider标签路由;旧音源若有在跑的扫码登录,bridge 发cancel_login让它在进程内收尾。 切换时仍清空当前队列并停止播放,因为 QQ/NCM Song ID 体系不兼容。进程崩溃或判死后由下一条命令重开, 重连会换新令牌并重新注入当前音源凭证。 - socket 拓扑:bridge 作 server,子进程启动后主动连入。socket 文件放
DECKY_PLUGIN_RUNTIME_DIR(bridge 有写权)。好处:socket 由 bridge 先建好,崩溃重连逻辑集中在 bridge 侧。 - 卸载:Decky 卸载插件时调
_unload(),bridge 关闭两个子进程与 socket。 - 状态归属:bridge 是唯一真相源。 跨会话/跨 provider 的状态——provider 选择、音量、播放模式、播放队列 + "放完切下一首"决策、cookie/账号——全归 bridge 持有(它唯一常驻、不被替换、收得到 player 的
ended事件)。队列决策绝不放 UI:否则用户退出播放页去打游戏、UI 卸载 → 当前歌放完就停,不续播。player 只放音、provider 只查询(无状态)、UI 只显示。 - 持久化:bridge 落盘,单一来源。 上述状态由 bridge 存
DECKY_PLUGIN_SETTINGS_DIR,启动读回恢复。UI 通过callable读写、自己不落盘(Steam CEF 不能直接写文件);provider 保持无状态,凭证由 bridge 在选中音源 / 重连时注入,provider 不自存 → 重启 / 切换都不丢账号。 - 持久化格式:单文件
settings.json(stdlibjson)。 被 §5.5 约束锁定——冻结解释器只能用 stdlib,json又是全项目通用语,不引第二套序列化。stdlib 其余淘汰:tomllib只读无写入器、configparser表达不了队列 list、sqlite3对小 config 过重(留作未来存大量历史/缓存的升级路径)。四个必带细节:- 原子写:写临时文件 →
os.replace()换名。bridge 可能被 kill,半截写会损坏配置。 - schema 版本字段
{"version":1,...}:将来改结构可迁移。 - 不存可再生数据:绝不持久化解析出的歌曲 URL(限时签名会过期,§13.2)。普通队列只存
songId + 队列索引,恢复时重新解析;电台流不持久化推荐批次,只存queue_mode + radio_type,用户确认后重拉。 - cookie 私有:文件
chmod 0600(deck 用户私有,非世界可读)。 结构示意:{"version":1,"provider":"qq","volume":0.8,"play_mode":"list_loop","queue_mode":"normal","queue":{"ids":[...],"index":3},"radio_type":null,"accounts":{"qq":"<cookie>","ncm":"<cookie>"}}
- 原子写:写临时文件 →
sequenceDiagram participant D as Decky participant BR as bridge participant PL as player participant P as provider D->>BR: _main() BR->>BR: 建 player.sock (作 server) BR->>PL: spawn player --socket player.sock PL-->>BR: 连入 player.sock Note over BR: 等 UI 选音源 BR->>BR: 为本次拉起建 provider-EPOCH.sock (作 server) BR->>P: spawn provider --socket provider-EPOCH.sock P-->>BR: 连入本次会话的 socket BR->>P: set_credential(provider=当前音源) Note over BR,P: 切换音源:只换 Selection,后续请求带新 provider 标签 D->>BR: _unload() BR->>PL: 关闭 BR->>P: 关闭
5. 通信设计
5.1 UI ↔ bridge:Decky RPC
Decky 只提供两种原语,足够:
callable(route)— 前端 → bridge 的 RPC(底层 websocket,frontend/src/wsrouter.ts:193)。前端@decky/api的callable("method")直接调用 bridgePlugin类的同名 async 方法。emit/addEventListener— bridge → 前端主动推送(backend/decky_loader/plugin/imports/decky.py的emit)。
5.2 bridge ↔ 子进程:UDS + NDJSON + 协议 v1
传输帧仍然照抄 Decky 自己的内部传输(backend/decky_loader/localplatform/localsocket.py):
- 换行分隔 JSON(NDJSON):每条消息一行
{json}\n,UTF-8。编码后的 JSON 不得超过 1 MiB(与 DeckyBUFFER_LIMIT = 2**20对齐)。
协议当前只保留 v1。四种消息:
| 方向 | 类型 | 形状 |
|---|---|---|
| bridge → child | Request | {"id":N,"cmd":C,"args":{...}};发给 provider 时另带顶层 `"provider":"qq" |
| child → bridge | Response ok | {"id":N,"ok":true,"data":{...}} |
| child → bridge | Response error | {"id":N,"ok":false,"error":{"code":"...","message":"..."}} |
| child → bridge | Domain Event | `{"ev":"player" |
| child → bridge | Log Event | `{"ev":"log","level":"debug |
实现约束:
- 构造 / 解码集中在协议模块:bridge
py_modules/protocol.py;player 与 provider 共用wirecrate(通用部分),player/src/protocol.rs、provider/src/ncm/protocol.rs只留命令 args struct,QQ 后端参数校验在provider/src/qq/args.rs。 - provider 进程按请求顶层
provider分发到 qq / ncm 后端,缺失或未知音源一律invalid_request;两个后端各自持有凭证与登录任务,共用单一写出通道。 - request id 由 bridge 递增生成。当前
Conn.request/_read_loop允许多请求同时在途, 用pending[id] -> Future匹配响应,不依赖响应顺序,无主的迟到响应丢弃;写锁只保护单帧写入。 domain 事件另由_events/_pump_events按到达顺序消费,避免事件处理中的回调请求堵住读循环。 子进程的响应/事件写回仍经单一 out queue 串行写帧;这不等于把整个请求生命周期串行化。 - 连接来源通过内部
ConnectionOrigin(epoch, provider)绑定:监听会话与每条接入连接都分配进程内不复用的身份。 provider 每次拉起使用独立provider-EPOCH.sock(通过--socket传入),迟迟未退出的旧进程连不进新会话; 诊断脚本不得假定固定路径。旧 EOF 不得拆除新连接。 音源另由Selection(provider, connection)令牌表示:切换音源或重连都会换新令牌。登出、凭证刷新、 红心种子等跨 await 的流程只在令牌仍是当前时提交;事件只接收当前令牌所在连接、且音源标签一致的, 切源时取消正在等待的旧事件回调。登录凭证写入令牌对应的账号槽位,不按消费时的settings.provider猜测。 - 播放侧通过内部
is_current守卫把意图代次带到Conn.request的按需启动之后、写锁内实际 write 之前及超时判死之前。 过期请求在本地取消,不新增 wire 错误码;已写出请求的旧响应不得回灌状态或无条件停止新播放。 source 选择另有意图代次,旧清空完成后不能覆盖更新的选择;清空等待期间不提前改写旧 provider 的账号槽位。 - 失败响应必须带稳定
error.code,message只使用固定安全文案;前端已知码本地化,未知值显示通用错误,不显示或记录不受控异常原文。 - 超时分两级:
timeout由 bridge 产出,表示通道不可用或等待子进程响应超时(请求等待上限 30s), 在播放编排中硬熔断,不逐曲顺延。upstream_timeout由 provider 产出,表示上游或完整命令预算耗尽;NCM 完整命令 25s,单段最多 15s 且不超过剩余预算。Playback._play_index对song_url首次上游超时退避 0.5s, 原地重试同一首一次;若仍为upstream_timeout,按FUSE_ERRORS硬熔断并报错,不跳到其他歌曲。 重试结果若变为其他错误码,按对应分类处理;跨歌曲连续两次的软熔断只适用于SOFT_FUSE_ERRORS中的fetch_failed。 - 子进程诊断走
Log Event,producer 不输出秘密;bridge 对不受控日志正文和 stderr 只保留受控类别/错误码或固定摘要,不原样落盘。 - 每端在 JSON 解码前以字节数强制 1 MiB 上限:超限入站帧立即断开,bridge 拒绝超限 request(
invalid_request),子进程停止向该连接写入。不得为诊断把原始帧写日志。
关于 provider 包裹:ncm-api-rs 与 QQMusicApi-rs 都作为库使用,在同一个 Rust 二进制里各写一层后端,暴露上述 NDJSON-over-UDS 协议(不用 ncm-api-rs 自带的 axum HTTP server)。两个后端协议一致,bridge 统一对待。
5.3 一次"播放"的完整流转(协议 v1)
sequenceDiagram
participant UI
participant BR as bridge
participant P as provider
participant PL as player
UI->>BR: callable("play_queue", items, startIndex)
BR->>P: {"id":1,"cmd":"song_url","args":{"id":"...","media_mid":"..."}}
P-->>BR: {"id":1,"ok":true,"data":{"url":"https://..."}}
BR->>PL: {"id":2,"cmd":"load","args":{"url":"https://..."}}
PL-->>BR: {"id":2,"ok":true,"data":{}}
BR-->>UI: emit("player", {"ev":"player","type":"track","data":{"index":0,"song":{...}}})
PL-->>BR: {"ev":"player","type":"playing","data":{"pos":0.0,"wall_ms":...}}
BR-->>UI: emit("player", {"ev":"player","type":"playing","data":{"pos":0.0,"wall_ms":...}})
Note over PL,UI: 稳态播放期间不逐帧上报<br/>UI 按 wall_ms 本地插值进度(见 §5.5)
PL-->>BR: {"ev":"player","type":"ended","data":{}}
BR-->>UI: emit("player", {"ev":"player","type":"ended","data":{}})
BR->>BR: bridge 根据队列/play_mode 决定是否自动切下一首
UI 全程拿不到 URL、碰不到音频流,一切经 bridge。
5.4 为什么 UDS 而非 TCP(附带收益)
- 无端口占用问题:Decky 自身用 1337/8080(README「Common Issues」),UDS 完全绕开端口冲突。
- 本机进程间通信,天然不暴露网络监听面。
- 与 Decky 自身传输范式一致。
5.5 编码格式与"打游戏时播放"的性能设计
前提:音频 PCM 不经过 bridge。 player 直接拉流、直接推 PipeWire(§5.3),bridge 只承载控制面——控制命令 + 状态事件。因此"高性能"的着力点不在序列化格式,而在减少 CPU 唤醒与抖动(游戏正在抢 CPU/GPU)。
实际流量(全是小消息、低频):
| 链路 | 消息 | 频率 | 大小 |
|---|---|---|---|
| UI→bridge | play/pause/seek/volume/选 provider | 人手触发,几次/分钟 | ~30B |
| bridge→provider | song_url/搜索/歌词 | 人手触发 | 请求 ~30B,搜索结果 5-50KB |
| player→bridge | playing/ended/error 事件 | 状态变化时 | ~30B |
{"ev":"player","type":"playing","data":{"pos":42,"wall_ms":...}} 也只是百字节级,stdlib json.loads 解它约 1-3 微秒;50KB 搜索结果约 0.5-1ms 且每次人手搜索才发生一次。这点量 JSON 绰绰有余,序列化不是瓶颈。
编码决策(逐跳):
| 链路 | 编码 | 库 | 理由 |
|---|---|---|---|
| UI ↔ bridge | JSON | Decky 底层已定 | callable/emit 走 websocket JSON,无选择权 |
| bridge ↔ 子进程 | NDJSON | Python: stdlib json |
硬约束:bridge 跑在冻结解释器,orjson/msgpack/protobuf 皆编译扩展,不能可靠加载(同 §1 的 C 扩展坑)。bridge 必须零第三方依赖 |
| 子进程侧 | NDJSON | Rust: serde_json |
player 与 provider 都是纯 Rust,serde_json 随依赖进来,静态无碍 |
性能真正的着力点(与序列化无关):
- 进度本地插值(最大一笔)。 player 只在状态变化时发一次
{"ev":"playing","pos":42.0,"wall_ms":<epoch>};UI 本地用pos + (now - wall_ms)自己算进度、靠requestAnimationFrame刷新。稳态播放期间控制面 IPC 趋近于零。 只在 play/pause/seek/ended 时重新同步。→ 把进度流量从 N Hz 砍到 ~0,这才是省 CPU 的关键。YAGNI:低频 resync(定时纠偏)先不做——插值在无缓冲卡顿时精确;等真观测到漂移再加。
- 全链路 event-driven,禁止轮询忙等。 bridge 阻塞在
asyncioreadline上,无消息即睡。 - 解码效率交给 rodio/symphonia(原生码,压缩流解码 ~1-2% 单核),已由 §7.3 解决。
- 播放器 store 有明确生命周期。 插件入口调用
startPlayer,卸载调用幂等stopPlayer; 保存并执行事件退订,清除音量 debounce timer,以代次/修订号拒绝旧 hydrate 和异步错误。宿主替换 listener map 不等于清理这些 timer/promise。 - 窗口化几何一致。 歌曲内容行高 64px(48px 封面 + 上下各 8px)、行间距 6.4px,使用统一 stride;外层不再额外施加 gap。 首尾 spacer 与可见行共用同一总高度模型,包含底部部分可见行;布局变化时测量实际 stride,避免 CEF 缩放下分数间距的累计取整偏差。
何时才换二进制格式(YAGNI 闸门): 仅当 profiling 实测 JSON 成为瓶颈——对纯控制面流量不会发生。现在上 msgpack/protobuf 是纯负债:bridge 侧还用不了(编译扩展),白白牺牲可调试性。
6. 前端 UI 设计
UI 分两个面,职责严格分离:右侧 QAM 面板做全局控制(provider 选择、登录入口、设置),大屏路由页做 provider 内容浏览。全部基于 Decky/Steam 的 @decky/ui 原语构建。两条硬约束,优先级明确:① 绝不拖垮宿主 Steam UI(首要,§6.5);② 手柄可导航(Steam Deck 无鼠标,§6.4)。
P3 大屏 UI 规格已整理到
docs/ui-design/:共享 Steam Deck 视觉/手柄规则、QQ/NCM 页面规格、API 缺口与本地渲染图索引。最新设计采用 SteamOS 官方库范式:顶层横向 Tabs + 全宽内容区 + Tab 状态徽章,不再使用常驻左侧栏或底部固定 MiniPlayer。设计图 PNG 位于docs/ui-design/assets/,通过 Git LFS 管理。
6.1 两个 UI 面概览
graph TB
subgraph QAM["右侧 QAM 面板(provider 无关)"]
SEL[provider 选择:无/QQ/网易云]
LOGIN[登录/账号入口]
URL[项目地址]
UPD[检查更新]
end
subgraph FS["大屏路由页(provider 相关)"]
ROUTE["注入路由 /music<br/>标题=QQ音乐 或 网易云音乐"]
TABS["顶层 Tabs<br/>L1/R1 切页"]
CONTENT["全宽内容区<br/>推荐/搜索/我的/播放"]
BADGE["Tab 状态徽章<br/>当前播放纯展示"]
end
SEL -->|选中→addRoute<br/>选无→removeRoute| ROUTE
SEL -->|callable set_provider| BR[bridge]
ROUTE --> TABS
TABS --> CONTENT
TABS --> BADGE
6.2 右侧 QAM 面板(provider 无关)
插件在 Decky QAM tab 的默认内容,用 PanelSection / PanelSectionRow 承载。只做全局控制,不含任何音乐业务:
| 元素 | 组件 | 行为 |
|---|---|---|
| provider 选择 | Dropdown(选项:无 / QQ音乐 / 网易云音乐) |
改选 → callable("set_provider") + 条件注入/移除大屏路由(§6.3) |
| 登录/账号入口 | ButtonItem / 状态行 |
跳转扫码登录或账号设置;credential 仍由 bridge 持久化 |
| 项目地址 | ButtonItem → Navigation.NavigateToExternalWeb(url) |
打开 GitHub(实证 ExternalLink.tsx) |
| 检查更新 | ButtonItem → callable("check_update") |
走 Decky 插件更新流(§8 分发) |
"无 provider"是合法初始态:未选 provider 时不注入大屏路由(§6.3),QAM 只显示介绍/地址/更新。这是设计红线——路由存在性由 provider 选择驱动。
6.3 大屏路由页(provider 相关,条件注入)
注入机制(实证 API):
- 选中 provider →
routerHook.addRoute('/music', <ProviderPage/>)(实证plugin-loader.tsx:129);路由标题随 provider 显示 "QQ音乐" 或 "网易云音乐"。 - 选"无" →
routerHook.removeRoute('/music'),大屏入口消失。 - 进入:
Navigation.Navigate('/music')+Navigation.CloseSideMenus()(实证TitleView.tsx:21-27)。触发点为 QAM 内一个"打开播放器"按钮或已验证的左侧菜单注入路径。 - 路由 patch 进 gamepad 路由(
EUIMode.GamePad,实证router-hook.tsx:81-83)= Steam Deck 大屏全屏页。
SteamOS 官方范式:
- 顶栏分层:右上角搜索/通知/Wi-Fi/电量/时间/头像是 SteamOS 全局 chrome,插件不绘制、不聚焦、不修改。插件 Logo 与 provider Tabs 放在内容区首行。
- 顶层横向 Tabs:
L1/R1切 provider 页面。QQ:推荐/搜索/我的音乐/智能电台/正在播放;NCM:发现/私人FM/搜索/我的/正在播放。 - 全宽内容区:个人资产页采用官方库范式(二级 Tab 行 + 全宽列表/网格),不使用常驻左侧栏。
- 去常驻播放条:SteamOS 软键盘会压缩 CEF 视口,底部固定 MiniPlayer 会被顶起并挤压搜索结果。P3 目标改用 Tab 状态徽章(纯展示)、
Start全局播放/暂停、Y队列浮层、沉浸播放页承载完整控制。 - Footer Legend:插件只通过
Focusable的actionDescriptionMap/onOKActionDescription声明文案;白圈图标、位置、左侧STEAM菜单由系统渲染。
封面图处理(P3 大屏 UI 才出现):
封面是二进制大块(每张几十 KB),绝不走 bridge 的 NDJSON/callable 控制面——一屏 50 张 base64 塞进 RPC 是灾难。取图三选一:
| 方案 | 怎么回事 | 取舍 |
|---|---|---|
| A 直连 CDN(选它) | provider 只回封面 URL;UI <img src={缩略图URL}>,浏览器原生 GET + 缓存 + onError 兜底 |
最简、图裂不崩溃;代价:静态图绕过 bridge |
B decky /fetch 代理 |
走 Decky 自带 /fetch(§5.1) |
不直连但要处理 auth token |
| C 后端下载缓存 | provider/bridge 下到 DECKY_PLUGIN_RUNTIME_DIR,UI 读本地 |
最合红线但最重;Steam CEF 加载 file:// 权限待验证 |
红线精确化:"UI 所有消息经 bridge"的本意是音乐控制/数据/音频不泄漏、可隔离。封面是静态展示资源,非业务数据;
<img>拉图失败只图裂(onError占位),不威胁 §6.5 崩溃隔离。故封面等静态图片允许浏览器直取 CDN 缩略图(方案 A),音乐控制/数据/音频仍必经 bridge。
性能动作见 §13.2(缩略图尺寸、虚拟列表只渲染视口内、缓存)。
6.4 手柄控制支持(横切所有 UI,硬约束)
Steam Deck 无鼠标,每个可交互元素必须可被手柄焦点树导航,否则不可用:
- 一切可交互元素包
Focusable(实证遍布@decky/ui):onActivate/onOKButton(A)/onCancelButton(B,返回上一层或退出页)/onSecondaryButton(X/Y)。 - 焦点走位:方向键遍历焦点树;网格/横向布局用
NavEntryPositionPreferences.MAINTAIN_X(实证PluginCard.tsx:152)保持列对齐,避免焦点乱跳。 - 搜索输入:
TextField聚焦即唤起 Steam 手柄软键盘(实证Store.tsx搜索框),不依赖物理键盘。 - 上下文操作:长列表项用
showContextMenu+Menu/MenuItem(实证plugin_list/index.tsx:63),而非 hover 菜单。 - 禁止项:无纯 hover 交互、无纯鼠标点击、无不可聚焦的可点区域;焦点环必须可见。
- 验收并入各 UI 阶段:纯手柄从 QAM 选 provider → 进大屏 → 搜索 → 选歌 → 播放 → 控制,全程不接鼠标键盘可完成。
6.5 【首要约束】UI 崩溃隔离:绝不拖垮宿主 Steam UI
风险根源:我们是侵入式插件,前端与 Steam Gamepad UI 跑在同一 Chromium 上下文 / 同一 JS 堆 / 同一主线程(SharedJSContext)。 后端多进程隔离(§2)救不了前端——前端天生同域。这是压倒一切的第一约束:宁可我们的 UI 自己不可用,也绝不能让 Steam 界面崩溃/冻结。
Decky 已给的兜底(实证,必要但不充分):
- 每条注入路由套
<ErrorBoundary>(router-hook.tsx:294),QAM 内容也套(PluginView.tsx:41)。渲染期/生命周期抛异常 → 显示 Decky 错误页,Steam 存活,可_deckyForceRerender恢复(errorboundary-hook.tsx:104)。 - 全局错误归因区分插件错误 vs Valve 错误(
getLikelyErrorSourceFromValveError)。
React ErrorBoundary 抓不到、必须我们自己防的逃逸路径:
| # | 逃逸风险 | 后果 | 我们的规避(强制) |
|---|---|---|---|
| 1 | 异步/事件回调抛错(callable() promise、onClick、setTimeout) |
逃逸边界→全局未处理拒绝 | 每个 callable、事件处理、promise 一律 try/catch;失败落到 UI 内的错误态,不外抛 |
| 2 | 阻塞主线程(同步死循环/重计算/超大同步解析) | 冻结整个 Steam UI(单线程) | 前端零重计算:解码/拉流/加密全在后端进程(架构红利);UI 只做展示+IPC |
| 3 | 未清理副作用(定时器、监听器、addRoute 未在卸载移除) |
跨插件 reload 在共享上下文累积泄漏 | 组件卸载/_unload 时清空所有 timer、removeEventListener、removeRoute |
| 4 | 篡改宿主(monkey-patch Steam 内部 / 改全局原型 / 污染全局命名空间) | 破坏宿主状态 | 只用文档化的 @decky/ui+@decky/api;绝不 patch Steam 内部,绝不写全局变量 |
| 5 | 无界渲染(上万搜索结果/歌单节点灌进 Steam DOM) | 卡顿/内存膨胀 | 列表虚拟化/分页;搜索输入 debounce;绝不一次渲染巨量节点 |
| 6 | IPC 永不返回(后端 hung/崩溃,callable 不 resolve) |
UI 永久挂起 | 所有 IPC 带超时;超时→graceful 状态,不空转 |
| 7 | 后端数据缺失/畸形(后端是独立进程,可能崩) | 渲染期解引用 undefined 抛错 | 防御式渲染:null-check、默认值;缺字段绝不在渲染期抛 |
纵深防御:自套 ErrorBoundary。 除 Decky 外层边界,我们在自己两个 UI 面(QAM、大屏页)顶层各再套一层 ErrorBoundary,子树出错时显示我们自己的降级 UI(“暂时不可用/重试”),连 Decky 兜底都尽量不触发。
验收(每个含 UI 阶段的硬门槛): 注入错误、杀死后端进程、灌畸形数据、断网——Steam UI 始终不崩不冻,最坏只是我们的面板显示错误态并可恢复。这是比功能更优先的验收项。
7. 技术选型
7.0 统一 provider — 一个 Rust 二进制承载两个音源
provider 是一个 Rust 二进制(provider/):src/main.rs 只做连 socket、单写出和按请求 provider
标签分发;src/ncm/、src/qq/ 是两个后端,src/lyric/ 是两家共用的歌词解析。
- 一个进程、一次启动:切换音源只是换路由,不再停旧进程、起新进程(以前 QQ 端还要冷启动 Nuitka 打包的 CPython)。两个后端的凭证、设备身份、登录任务同时在内存里,互不干扰。
- 包体:release 二进制约 5.3 MB(体积优化见 §13.1),取代原来 ~4 MB 的 ncm-provider + ~58 MB 的 QQ Nuitka 压缩包 (解压后更大),也不再需要 bridge 首次运行时自解包。
- 依赖边界:两个后端都走 rustls(QQ 用纯 Rust 的 graviola provider),NEEDED 只有基础 libc
运行库,
scripts/check-binaries.py对 provider 执行与 player 同样严格的动态依赖白名单。 - 兼容:QQ 凭证仍是原来的 snake_case JSON(QQMusicApi-rs 兼容同名字段与 camelCase 别名),
设备档案仍存
qq-device.json且字段一致,升级后不用重新登录、也不会被当成新设备。
7.1 网易云后端 — ncm-api-rs(Rust)
选它而非 Node 版 api-enhanced 的理由:
| 维度 | api-enhanced (Node) | ncm-api-rs (Rust) |
|---|---|---|
| 运行时 | 需 Node runtime | 纯静态二进制,零运行时 |
| 内存 | ~50-100MB | ~5MB |
| TLS | — | rustls-tls,不碰系统 OpenSSL(Cargo.toml:16) |
| 接口 | 基准 | 与 Node 版 1:1 |
作为库使用:create_client(cookie) + Query 链式传参(README 示例:cloudsearch / song_detail / lyric / song_url_v1)。
依赖 fork 的 feat/sync-upstream-2026-10 分支(jinzhongjia/ncm-api-rs),由 Cargo.lock 固定提交。
7.2 QQ 音乐后端 — QQMusicApi-rs(Rust)
- QQMusicApi-rs 是 L-1124/QQMusicApi 的 Rust 实现, 协议与上游一致(zzc 签名、QIMEI 设备指纹、扫码登录、QRC 解密等),作为库使用。
- 取播放 URL 用库的 bypass 音质阶梯:一次请求问所选上限及以下全部档位,
ct由设备 guid 稳定派生 (同设备稳定、不同安装分散),等价于原 Python 版的做法;URL / vkey 不进日志。 - 历史:此前 QQ 端是 Python QQMusicApi + Nuitka
--standalone目录包(tar.gz 分发、bridge 首用自解), 每次切换音源都要冷启动一个 CPython 运行时;改用 Rust 库后与网易云合并为同一个二进制。
7.3 player — Rust,rodio + reqwest
音频前提:目标 SteamOS 会话必须提供 PipeWire、ALSA 兼容层与
libasound。采用默认设备输出, 不按设备品牌或声卡型号选择硬件;实际出声、音频设备切换和睡眠恢复仍须逐设备验收。
graph LR URL[歌曲 URL] --> H[reqwest + rustls-tls<br/>HTTP 拉流·纯 Rust] H --> RO[rodio<br/>bundled symphonia 解码 + cpal 输出] RO -->|动态链 libasound| A[ALSA] A -->|pipewire-alsa 兼容层| PW[(PipeWire 守护进程)]
- 拉流:
reqwest,只开rustls-tls,不碰 OpenSSL。 - 解码+输出:
rodio(内置symphonia解码 +cpal输出),一个 crate 搞定解码/播放/seek/音量。核心逻辑约 30 行。 - 构建目标:
x86_64-unknown-linux-gnu(默认)。不用 musl。 - 依赖形态:除系统基线(
libc/libm/libgcc_s/动态加载器)外,player 只动态依赖libasound.so.2→ ALSA 兼容层 → 用户 PipeWire,走系统混音/音量,不依赖 C OpenSSL 或 libpulse。 - 可重复的兼容边界:构建基础镜像固定 digest;
scripts/check-binaries.py检查产物的 x86-64 架构、glibc 符号版本上限 2.39 和 Rust 动态依赖。QQ 包内 ELF 同样检查。镜像或依赖升级 后仍须过检查,不得因构建机较新而静默提高运行门槛。 - 会话环境:
py_modules/session_env.py保留绝对路径、有效 UID 所有且权限 0700 的XDG_RUNTIME_DIR;无效时查找/run/user/<effective-uid>,仍不可用则移除错误值。 不创建假 runtime、不使用其他用户会话、不改写 D-Bus 等其他继承设置。 - 部署目录:安装路径由 Decky 配置决定,运行时使用
DECKY_PLUGIN_*;侧载先从远端plugin_loader配置发现插件目录,无法可靠发现则要求显式DECK_PLUGIN_PATH,不猜/home/deck。
被否方案记录:
cpal/ALSA 静态链接:libasound靠snd_dlopen()运行时 dlopen 插件,静态链接崩(_snd_ctl_hw_open not defined),alsa-sys干脆 hardwirestatic(false)→ 淘汰。libpulse-binding:链 Clibpulse.so,非纯 Rust → 约束放开后无必要。pipewire-rs/pipewire-native:前者 C bindings;后者纯 Rust 但音频处理尚未实现,不能播 → 淘汰。pulseaudiocrate(纯 Rust PA 协议 over socket):零系统库耦合,但需手搓 PA stream/格式协商/共享内存,代码量大。仅在"必须零耦合 + musl 全静态"时才需要;约束已放开,不选。
7.5 桌面/系统媒体集成 — MPRIS2(player 侧)
player 额外托管标准 MPRIS2 D-Bus 服务(org.mpris.MediaPlayer2 + .Player),让桌面媒体控件、playerctl、蓝牙耳机 AVRCP 按键等消费者显示 now-playing 并控制播放。
- 归属:player(Rust)而非 bridge。 bridge 跑在 Decky 冻结解释器只能 stdlib,手写 D-Bus 线协议代价大;player 是 Rust、常驻、且本就持有实时播放态(状态/进度/音量)。用
mpris-server(基于zbus,纯 Rust,直接走 session bus 线协议,不链 Clibdbus) —— 维持"ldd 只动态依赖 libasound"。 - 控制回路:MPRIS 是又一个汇入 bridge 的控制面。 所有控制动作(play/pause/next/prev/seek/volume/loop/shuffle)不在 player 本地执行,而是上送
{"ev":"player","type":"control","data":{"action",...}}事件;bridge 按 UI callable 同一套方法处理(§4 唯一真相源),再把常规命令下发 player + emit UI,MPRIS 与 UI 永不分叉。 - 展示回路: 状态/进度/音量由 player 自身音频态直接渲染(不往返);曲目元数据经新增
meta命令(bridge→player,随切歌下发 title/artist/cover/时长 + 可否上下曲 + 播放模式)。 - 失败降级: 连不上 session bus(无 D-Bus / 无权限)→ 记 warn 跳过,绝不阻塞出声(呼应 §6.5 崩溃隔离精神)。
- 消费者验证: Desktop Mode(KDE)开箱可用;Game Mode(gamescope)是否有消费者(如蓝牙 AVRCP 经 mpris-proxy)需按会话实测 —— 服务注册本身与会话无关。
7.4 登录与不可用歌曲处理(provider 通用)
- 登录:统一扫码(QR)。 两 provider 都走二维码登录,不做手机号/密码。bridge 向 provider 要登录二维码 →
emit给 UI 在大屏显示 → 用户手机扫 → provider 轮询到 cookie → 回传 bridge → bridge 存DECKY_PLUGIN_SETTINGS_DIR(§4 持久化)。后续 spawn provider 时由 bridge 注入 cookie,provider 无状态。- 手柄友好:扫码无需任何文本输入,天然适配无鼠标/键盘环境。cookie 过期 → 报错并提示重新扫码。
- 修订(2026-07-04):登录是 QQ 基本播放的前置,非 VIP/高音质专属。 实测 QQ 即使免费歌,song_url(vkey)匿名请求也返
104003(无版权/需登录),必须先扫码登录。故登录已提前到 P1 实现(原计划放 P4)。QQ 用QRLoginType.QQ(手机 QQ 扫)。ncm 相反(2026-07-05 P2 实测):免费歌song_url_v1匿名即返code=200+ 可播 URL,基本播放无需登录;登录仅为 VIP/高音质。 - QQ 认证取消(#60):
login/logout/set_credential在处理入口同步登记认证代次,再取消并等待旧扫码任务。 退出先清本地凭证,不让迟到的服务端 logout 清掉新账号;二维码、轮询状态、错误、成功与凭证刷新提交前均复核代次。 task cancellation 用于回收,代次才决定是否允许提交;第三方吞掉取消也不能发布旧结果。协议字段不变。 上游 await 在独立任务中运行,隔离urllib3-future内部超时遗留的取消计数;不能把库内部已处理的取消 当成用户退出而静默丢弃二维码或刷新结果,认证任务自身的外部取消仍须传播。
- 不可用歌曲:直接报告用户,不绕。 版权下架、VIP-only、区域限制等返回稳定错误码,UI 本地化原因;未知诊断使用安全通用文案,不转发第三方原文。不支持配置代理 / real_ip。海外/受限网络下的可用性不是本项目目标。错误态是正常分支,不崩不冻。
8. 分发方案
用 Decky 原生的 remote_binary 机制(backend/decky_loader/browser.py:66-96):在插件 package.json 声明二进制的下载 URL + sha256,安装时自动下载到插件 bin/ 并 chmod。
{
"remote_binary": [
{ "name": "player", "url": "https://.../player-linux-x64", "sha256hash": "..." },
{ "name": "provider", "url": "https://.../provider-linux-x64", "sha256hash": "..." }
]
}
- 对外分发物是插件包 + 两个从 Release 拉取的二进制,各自带 hash 校验。
- 若坚持"单一文件"分发,可另写 launcher 用
include_bytes!嵌入 —— 但当前不需要,remote_binary数组已够。
8.1 双通道:普通版(GitHub)+ CN 版(Cloudflare R2)
普通版依赖走 GitHub Release(现状不变)。CN 版把两个二进制与插件 zip 全部镜像到作者的
Cloudflare R2(自定义域名 dl.nvimer.org),给国内用户更稳的下载。
两个 zip 唯一差异 = remote_binary[].url 的主机名;二进制字节一致、sha256 不变。CN 版
URL:https://dl.nvimer.org/decky_music/<tag>/<filename>,稳定安装入口
https://dl.nvimer.org/decky_music/decky-music-cn.zip。
CI(release.yml)在普通版出包后追加:镜像二进制到 R2 → scripts/cn-package.sh 换 URL
重打 CN zip → 校验 → scripts/release-cn.sh 上传 R2 + GitHub 存档。设计细节见
docs/superpowers/specs/2026-07-18-cn-distribution-design.md。
9. 当前实现索引
当前以源码模块作为协议与行为的真相源:
| 文件 | 职责 |
|---|---|
main.py |
Decky Plugin 门面,只把 callable 转发给 bridge |
py_modules/bridge.py |
生命周期门面与任务/持久化回调,组合监督与 RPC 职责 |
py_modules/ipc.py |
Conn 并发 demux、来源代次、事件顺序消费及连接清理 |
py_modules/music_settings.py |
配置归一化、队列白名单、原子写与 0600 权限;使用专用名称避免与冻结宿主的 settings 冲突 |
child_process.py / supervision.py |
环境与启动、子进程自愈、音源令牌 Selection 与凭证注入 |
provider_rpc.py / playback_rpc.py |
内容/账号与播放控制的外部 callable,保持唯一契约 |
py_modules/protocol.py |
协议 v1 request 构造、response/event/log 严格解码与消息分类;不管理在途请求 |
playback.py / playback_queue.py / playback_radio.py |
播放意图、同曲重试与恢复;普通队列与电台职责分离,仍由 bridge 持有真相 |
src/api.ts |
前端唯一接口层:callable 声明、事件类型、运行时 guard |
wire/src/lib.rs |
协议 v1 Rust 侧共用:错误码 / 日志 / 请求解析(含 provider 路由字段)/ 响应·事件构造 |
provider/src/main.rs |
统一 provider:连 socket、单写出、按请求音源分发到 qq / ncm 后端 |
provider/src/ncm/protocol.rs |
网易云后端的命令 args struct |
player/src/protocol.rs |
player 的命令 args struct |
provider/src/qq/ |
QQ 后端:认证代次、按职责分组命令;paging.rs 固定 50 条上游窗口、跨页合并裁剪 |
provider/src/ncm/deadline.rs / provider_commands/playlists.rs |
25s 命令预算;按会话/uid/变更版本隔离的有界歌单元数据缓存 |
provider/src/lyric/parse.rs |
两个音源共用的 LRC / YRC 解析、间奏 end_ms 与译文对齐 |
player/src/loading.rs / stream/{buffer,http}.rs |
latest-wins 加载所有权、可取消 HTTP 与有界同步读缓冲 |
player/src/mpris.rs |
player 侧 MPRIS2 D-Bus 服务(now-playing 展示 + 控制上送 bridge;zbus 纯 Rust,§7.5) |
三项契约的实现与现有回归(同步说明见 issue #72):
| 契约 | 实现 | 相关回归与覆盖范围 |
|---|---|---|
| 请求按 id 匹配,事件独立顺序消费 | Conn.request / _read_loop / _pump_events |
test_child_death.py:TestConnDeath.test_disconnect_fails_inflight_requests_fast、TestStaleDisconnect.test_old_connection_eof_does_not_kill_new_one,覆盖在途请求与连接替换边界 |
| 上游超时重试同一首,仍超时不顺延 | Playback._play_index / _fuse_check |
test_playback_retries.py:TestUpstreamTimeoutRetriesSameSong.test_transient_timeout_plays_the_intended_song、test_persistent_timeout_never_skips_to_another_song、test_radio_advance_retries_then_reports |
| 队列与电台决策留在 bridge | Playback,由 Bridge 持有 |
test_playback.py:TestQueueEdit.test_clear_and_snapshot、test_provider_switch_clears_radio_state、test_radio_ended_refills_near_tail_and_advances |
test_protocol.py::TestDemux 验证的是 response/event/log 的消息分类,
不是多请求乱序响应测试。不能把现有连接边界回归描述为所有并发业务竞态都已覆盖。
跨层改动规则:
- 改 bridge callable 或 emit 事件 → 同步改
src/api.ts。 - 改 bridge ↔ child 协议字段 → 同步改 bridge、
wire、player、provider 的 protocol 模块与测试。 - 子进程错误必须返回稳定
error.code;日志和错误消息不得包含 URL/cookie/credential。
10. 关键风险与待验证项(按优先级)
- 【最高】player 在 gamescope 会话下能否出声。
- 确认
rodio能开 ALSA 默认设备并路由到 PipeWire。 - 确认 bridge 以实际 Decky 非特权用户启动 player,
XDG_RUNTIME_DIR指向该用户有效会话; 非deck用户、非 1000 UID 和自定义安装目录不得改变播放与设置隔离语义。 - 这是整个播放链路的命门,应第一个做 spike。
- 探测记录(2026-07-04,桌面模式零代码探测):
libasound.so.2位于/usr/lib/、动态可用;speaker-test(ALSA 路,等同 rodio 的 cpal→libasound→pipewire-alsa 链)以 48000Hz / S16_LE / 2ch 正常出声;pw-play亦正常。→ libasound 路 + 默认格式协商已验证通过,命门的架构风险基本消除。 - 构建记录(2026-07-04): player P0 二进制在官方
holo-toolchain-rust(+pkgconf/alsa-lib)镜像内构建,glibc 随 SteamOS。readelf -d确认 NEEDED 仅libasound.so.2+ 系统基线(libgcc_s/libm/libc),无libssl/libcrypto(rustls 生效)—— 满足"ldd 只动态依赖 libasound"验收。 - 播放记录(2026-07-04,桌面模式实测): 二进制 scp 到 Deck,
ldd全部解析、无 GLIBC 缺失;player --play <mp3>拉流成功 →OutputStream::try_default()开默认设备无错 → symphonia 解码 → 实际听到声音、播完exit 0。→ 待验证项② rodio/cpal 格式协商已通过(桌面模式)。 - 游戏模式验证(2026-07-04,整链实测): 插件部署上 Deck,bridge 以 uid 1000(deck)+ 注入
XDG_RUNTIME_DIR=/run/user/1000spawn player,UDS 连接建立。游戏模式(gamescope 会话)下经 UI「测试播放」→ bridgeplay_url→ playerload出声正常,暂停/继续可用。→ 命门彻底关闭:整条 UI→bridge→player 在真机游戏模式跑通。 - 通用 SteamOS 待验收:以上是历史 Steam Deck 记录,不是当前所有设备的支持证明。非 Deck 设备暂缺硬件,后续须记录系统/Steam/Decky 版本、用户名/UID、安装路径、屏幕比例/缩放、 外接控制器、默认音频与设备切换、睡眠唤醒、故障后宿主存活。Steam Deck 回归与该矩阵分开记录。
- 确认
- 子进程崩溃恢复。 连接失效由
ipc.py通知,supervision.py负责按需重启和重新注入状态;player 不在时保留断点,provider 整体超时后回收并在下一请求重建。继续用真机故障注入验证,不把架构说明当作所有恢复路径已验收。 - NDJSON 乱序并发。 已完成:bridge
Conn支持 request-id demux;QQ/NCM provider 命令处理后台化,慢上游不堵读循环;playerload后台化,慢 CDN 不堵控制命令。请求语义按 id 匹配响应,互不等待。 - 二进制执行位。
remote_binary下载后确认bin/下文件有+x;缺失则 bridge 里os.chmod。 - provider 端口/环境差异消除。 因统一走 UDS + 自写 wrapper,原库的 HTTP server / 端口配置不再使用。
- 音频格式覆盖。 确认 symphonia 覆盖网易云/QQ 实际下发的编码(mp3/flac/aac/ogg);缺项时补
rubato重采样或换解码路径。 - 原生左栏菜单注入(UI 开放项)。 注入 Steam 原生左侧菜单项非已验证的 Decky 一等能力(§6.3)。P3 前 spike;未通则回退到"QAM 按钮
Navigation.Navigate进大屏"这条已验证路径。
11. 实现分阶段规划
以下保留早期阶段顺序与当时的范围,不作为未完成事项清单;当前协议、并发和播放编排契约以 §4、§5.2、§9 为准。
排序原则:先 gate 出声命门,再按 provider 逐个做"基本播放",最后才做差异化接口与 UI 微调。 "基本播放" = 选歌 → 出声 → 基本传输控制(play/pause,能则含 seek/volume)。每阶段有可观测验收,未过不进下一阶段;与 §5.5/§10/§13 的 YAGNI 一致。
进度:P0、P1、P2 已完成并真机验证(P0/P1 2026-07-04,P2 2026-07-05)。
- P0:player 在游戏模式出声(见 §10.1)。
- P1:QQ 端"扫码登录 → 搜索 → 选歌 → 出声 → 暂停/继续"整链在真机游戏模式跑通。
- qq-provider(QQMusicApi 库 + curl_cffi vkey bypass)以 Nuitka
--standalone在 manylinux_2_28(glibc 前向兼容 SteamOS)打包,冻结二进制真机运行正常。- 登录必需:QQ 即使免费歌 vkey 也需登录态(匿名返 104003),扫码登录已实现; credential 由 bridge 存 SETTINGS_DIR、spawn 时注入。登录用
QRLoginType.QQ(手机 QQ 扫)。- 发现 2 已排除:player 用 reqwest(rustls)可直连 QQ CDN 取流,无需 JA3 伪装 (quaverq 因 FFmpeg 才需代理;rodio/reqwest 直取即可)。
- 遗留:登录错误细分消息(设备超限等目前统一提示重试)、进度条插值、seek/音量 UI。
- P2:网易云端同样打通"搜索 → 选歌 → 播放 + 扫码登录",真机验证通过。
- ncm-provider(ncm-api-rs 库,纯 Rust,holo-toolchain 构建;NEEDED 仅 libc/libgcc/libm, 无 openssl/libasound)。协议与 qq-provider 对齐,bridge/player/UI 零改动复用。
- 与 QQ 相反:免费歌
song_url_v1匿名即可播,基本播放无需登录;扫码登录仅为 VIP/高音质。login_qr_create只给 qrurl,ncm-provider 用qrcodecrate 本地渲染二维码 → base64 SVG。
graph LR P0[P0 出声 spike] --> P1[P1 QQ 基本播放] P1 --> P2[P2 网易云 基本播放] P2 --> P3[P3 差异化接口+UI 微调] P1 -.-> P4[P4 健壮性+分发] style P0 fill:#f88
| 阶段 | 目标 | 范围 | 验收(可观测) |
|---|---|---|---|
| P0 出声 spike 🔴命门 | 证明 player 能在 gamescope 出声(provider 无关) | 仅 player 独立跑:reqwest 拉一段固定 mp3 → rodio 播;手测 XDG_RUNTIME_DIR、降权 deck 用户下音频会话 |
真实 Steam Deck / gamescope 会话里听到声音;ldd 只动态依赖 libasound |
| P1 QQ 基本播放 | QQ 端打通"选歌→出声→基本控制"整条链 | qq-provider(QQMusicApi + Nuitka standalone 打包)只实现 song_url;bridge set_provider/play/pause;player load/play/pause,上报 playing/ended/error;最小 UI(QAM 选 QQ + 一个选歌/播放入口,进度条走 §5.5 插值) |
选 QQ → 某首歌响起、可暂停续播;URL 不出 bridge↔子进程;打包链路(Nuitka standalone→压缩档→安装解压)跑通 |
| P2 网易云 基本播放 | ncm 端补齐同样的基本播放 | ncm-provider(ncm-api-rs + UDS wrapper)song_url;复用 P1 的 bridge/player/UI;QAM 可选 QQ/网易云 |
选网易云 → 能播、可暂停;QQ↔网易云切换走同一段统一路径(停旧进程→起新进程,§4) |
| P3 差异化接口 + UI 微调 | 按 provider 各自铺开特色 | 各 provider 补 search/lyric/playlist 等(按各自 API 能力,不强求对齐);大屏平板 UI 铺开;按 provider 做视觉/交互微调(待截图) |
搜歌、显歌词、播歌单;两 provider 各自 UI 特色成形 |
| P4 健壮性 + 分发 | 上线就绪 | 崩溃 watchdog(§10.2,自动重启+emit);seek/volume 若 P1 未含则补齐;remote_binary 打包+sha256+CI release |
子进程杀掉能自愈;从 GitHub Release 装插件即用 |
依赖与并行:
- P0 gate 一切——出声不通则整个项目形态要重议,务必最先做、独立做。
- P1→P2→P3 串行:P1 建全部基础设施(player/bridge/UI/打包),P2 只是换 provider 复用,P3 才铺差异化。
- QQ 先做 = 打包风险前置:P1 就要跑通 Nuitka standalone,这是 QQ 端最大不确定性,早暴露早解决。
- P4 大部分可与 P1+ 并行/后置:watchdog、request-id 是稳健性增量,不阻塞基本播放验证;分发打包留到功能成型再做。
登录放 P4:MVP 用免费歌验证,修订(2026-07-04):登录提前到 P1。 实测 QQ 免费歌 song_url 也需登录(匿名song_url对免费歌不需要 cookie104003),扫码登录是 QQ 基本播放的前置,已在 P1 完成(§7.4)。ncm(P2 实测)相反:免费歌匿名即可播,登录仅为 VIP/高音质;P2 仍实现扫码登录以对齐 QQ。- UI 手柄导航(§6.4)是每个含 UI 阶段的验收硬条件。
- UI 崩溃隔离(§6.5)是从 P1 起每个含 UI 阶段的最高优先验收门槛:注入错误/杀后端/畸形数据/断网下 Steam UI 不崩不冻,凌驾于功能之上。
12. 建议目录结构
music-plugin/
├── plugin.json # name/author/flags/api_version
├── package.json # version + remote_binary[]
├── main.py # bridge(Plugin 类,§9)
├── frontend/ # React UI(callable/emit 对接)
│ └── src/
│ ├── index.tsx # QAM 面板(provider 无关,§6.2)
│ └── ProviderPage.tsx # 大屏路由页(provider 相关,§6.3)
├── provider/ # Rust:一个二进制,qq(QQMusicApi-rs)+ ncm(ncm-api-rs)两个后端
│ └── Cargo.toml
└── player/ # Rust:reqwest + rodio,gnu 目标
└── Cargo.toml
13. 性能预算
唯一标准:这是手持机,音乐与游戏抢同一份 CPU / 内存 / 电。每项优化都为"别从游戏嘴里抢食"。 下列按杠杆排序;Tier 3(gapless 预取、二进制 IPC、多层缓存)现在是 YAGNI,不做。
13.1 Tier 1 — 高杠杆
| 项 | 做法 | 代价/旋钮 |
|---|---|---|
| 进程调度让游戏赢 CPU | 子进程在 main 开头经 wire::lower_priority 降优先级(provider nice 10、player nice 5),之后建的线程全部继承 |
不给音频上 RT(会跟游戏抢),改用大音频缓冲吸收:设备缓冲 8192 帧(48 kHz 约 170 ms,rodio 默认约 50 ms)。真机 8 核满载压测 PipeWire 节点 ERR 保持 0、进度按真实时间推进 |
| 线程与内存 | player / provider 都用 tokio 单线程运行时(原按核数开 worker,Deck 上各 8 个);bridge 给子进程注入 MALLOC_ARENA_MAX=2 |
真机线程 player 14→7、provider 9→1;连续播放 20 分钟 player 稳定在 21–26 MB(原来会涨到 50 MB),provider 17–18 MB |
| 二进制体积 | release opt-level="z" + codegen-units=1 + LTO + strip;两个二进制都用 ring 作 rustls 加密实现(去掉 aws-lc / graviola 两份重复);rodio 只开播放与实际收到的格式 |
player 9.7→5.0 MB、provider 12.5→5.3 MB;按体积优化编译后真机播放 CPU 未上升。不用 panic="abort":任务内 panic 会变成整进程退出 |
| Steam UI 侧不空转 | 正在播放 / 沉浸页的进度定时器在所属窗口失焦(回到游戏、QAM 盖住)时跳过重绘(usePlayingTick;插件跑在 SharedJSContext,须用组件 DOM 的 ownerDocument 判断焦点);左侧菜单注入的每秒重试在已包裹且仍挂载时走微秒级快路径 |
原先每秒扫一遍 DOM + 深搜约 3000 个 fiber(约 2 ms),游戏中也一直跑 |
| 统一 Rust provider 常驻 | 见 §7.0:两个音源同一进程,切换只换路由;不再有几十 MB 的 Python RSS 与 Nuitka 冷启动 / 首次解包 | 原先的「QQ provider 空闲退出」不再必要,常驻 Rust 进程空闲时几乎不占 CPU |
| 默认中等码率 | 无损(FLAC)设 opt-in | 手持机无损 = 更多网络+解码 CPU+电;省码率直接省续航 |
| 流式解码(边下边播) | player 首次 GET Range: bytes=0- 判断 CDN 是否支持 byte range;producer 线程按 Range 预取到 4MiB 有界 ring buffer(low=1MiB/high=3MiB),rodio 解码线程从 buffer 读;seek 跳出窗口时重置 producer 并重新发 Range: bytes=N- |
控内存上限(整首 FLAC ~30-40MB;手持机内存与 VRAM 共享,省的归游戏)+ 首音更快+抗网络抖动。不支持 byte range 时保顺序播放;跳出当前缓冲窗口的 seek 会失败并上报 seek_failed |
13.2 Tier 2 — 低成本边角
| 项 | 做法 | 说明 |
|---|---|---|
| 采样率匹配跳过重采样 | 源率 == sink 率则不走 rubato,原样输出 | 把 §7.3 的"按需重采样"说白:相等就别算,省 CPU |
| 复用单个 HTTP client | 进程持有一个 client(keep-alive 连接池),不每请求新建 | 免重复 TLS 握手;reqwest/niquests 本就这么设计,错误写法反而多代码 |
| 缓存元数据/歌词,绝不缓存歌曲 URL | 搜索/歌词/歌单可短缓存;播放 URL 每次现取 | ⚠️ 防坑非收益:NCM/QQ 播放 URL 是限时签名,缓存会过期→403 |
| watchdog 用信号不轮询 | 子进程退出用 SIGCHLD / asyncio child watcher,不 poll() 循环 |
轮询=周期性唤醒 CPU;信号=平时睡、真死才醒。同 §5.5 event-driven |
| 大屏页代码分割懒加载 | React.lazy + WithSuspense,进路由才加载平板 UI |
QAM 秒开;减小注入 Steam 的初始 JS 与内存(Decky 原生用法) |
| 播放完成/位置锚点走 source 事件 | decoder EOF 的 EmptyCallback 和实际音频样本消耗的 periodic_access 写回音频命令队列 |
活跃 player 阻塞等待命令/事件,不再每 250ms 唤醒检查 sink.empty();缓冲停摆也不产生假位置 |
| 暂停久了释放音频 sink / player 崩溃 | player 只在 pause 后等待一个 30s deadline,超时 drop sink;bridge 收到 unloaded 或 player 连接断开后将已载入状态置空,并向 UI 发 paused |
PipeWire 节点不再常驻;恢复播放按需重拉 player,重载流并 seek 回中断位置(失败才从头播);UI 不会停在“正在播放”而把下一次按键误作 pause |
| 读停摆与加载取消(#58/#64) | 空缓冲等待 30s 后记录失败;新 load/stop/drop 取消并回收旧首开/正文任务 | 首开 10s × 2 + 1s 退避,正文没有整曲总时限;活动加载上限 2。failure probe 保持可见,旧代次 EOF/error 不发到当前播放,正常失败沿用 fetch_failed 与断点恢复 |
| 封面图缩略图 + 虚拟列表(P3) | 请求 CDN 缩略图尺寸(如 ?param=200y200,非原图);歌单只渲染视口内封面,离屏不请求;按 songId/URL 缓存 |
缩略图省 ~25× 纹理内存;<img> 直连 CDN(§6.3 方案 A),失败 onError 占位不崩溃 |
13.3 已内建(设计里已有,无需另做)
client 端进度插值(§5.5)、全链路事件驱动(仅 pause 的一次性 30s deadline)、前端零重计算(§6.5)、单个常驻 Rust provider 承载两个音源(§4、§7.0)。
附录:核心决策记录
| 决策 | 结论 | 依据 |
|---|---|---|
| 后端能否进插件进程 | 否 | 冻结解释器 + C 扩展 ABI 绑定(sandboxed_plugin.py) |
| 后端形态 | 独立二进制,脱沙盒 | subprocess 拉起即脱离冻结解释器 |
| Rust/Python 是否 FFI 合并成一进程 | 否 | 摧毁崩溃隔离、二者无互调关系、绑定工作量大而无收益 |
| 单进程 vs 多进程 | 多进程(provider 与 player 分离) | 崩溃隔离,用户核心诉求 |
| 网易云库 | ncm-api-rs(Rust) | 轻量、rustls、静态、1:1 接口 |
| QQ 音乐库 | QQMusicApi-rs(Rust),与 ncm-api-rs 合并为一个 provider 二进制 | 早期为 Python QQMusicApi + Nuitka standalone(无 Rust 替代时的取舍);有了 Rust 实现后合并,省掉冷启动、首次解包与 ~58 MB 包体(§7.0) |
| UI↔bridge 通信 | Decky RPC(callable/emit) | Decky 唯一原语 |
| bridge↔子进程通信 | UDS + NDJSON,bridge 作 server | 用户要求 UDS/不跨桥;帧格式对齐 Decky localsocket.py |
| 通信编码 | NDJSON;bridge 用 stdlib json,Rust 侧 serde_json |
冻结解释器不能用编译扩展序列化库;控制面流量微小,JSON 够快 |
| 进度上报 | 状态变化时发一次 + wall_ms,UI 本地插值 | 稳态播放期 IPC 趋零,打游戏时最省 CPU |
| player 音频输出 | rodio + 动态链 libasound,gnu 目标 | SteamOS 保证音频栈存在;避开 ALSA 静态 dlopen 死路 |
| 桌面媒体集成(MPRIS) | player 托管 MPRIS2(mpris-server/zbus 纯 Rust);控制上送 bridge、展示读本地态 | bridge 只能 stdlib 写不了 D-Bus;player 是 Rust+常驻+持播放态;维持 ldd 只 libasound(§7.5) |
| 分发 | Decky remote_binary 数组 |
原生机制,自带 sha256 校验(browser.py) |
| UI 分面 | QAM(provider 无关)+ 大屏路由页(provider 相关) | 职责分离;控制与内容浏览解耦 |
| 大屏入口注入 | routerHook.addRoute 条件注入,选无则移除 |
实证 plugin-loader.tsx;路由存在性由 provider 选择驱动 |
| 大屏 UI 范式 | 顶层横向 Tabs + 全宽内容区 + Tab 状态徽章;不用常驻左侧栏/底部固定 MiniPlayer | 对齐 SteamOS 官方库范式;避免软键盘压缩视口时底栏被顶起(§6.3) |
| 手柄导航 | 一切可交互元素包 Focusable,禁 hover/纯点击;L1/R1 切页,Start 盲操,Y 队列浮层 |
Steam Deck 无鼠标,硬约束(§6.4) |
| UI 崩溃隔离(首要) | 前端零重计算 + 全异步 try/catch + IPC 超时 + 自套 ErrorBoundary + 防御式渲染 | 侵入式插件与 Steam 同 JS 上下文;绝不拖垮宿主(§6.5) |
| 封面图取图 | 直连 CDN 缩略图(方案 A),不经 bridge | 静态资源非业务数据,图裂不威胁隔离;红线精确化(§6.3) |
| 状态归属 + 持久化 | bridge 是唯一真相源(provider 选择/音量/队列/cookie),存 SETTINGS_DIR;UI 经 callable 读写、provider 无状态;provider 切换时清空队列 |
唯一常驻不被替换;队列/账号不能放会卸载的 UI 或会退出的 provider;QQ/NCM Song ID 体系不兼容(§4) |
| 持久化格式 | 单文件 settings.json(stdlib json)+ 原子写 + version 字段 + cookie 0600 |
冻结解释器只能 stdlib;json 全项目通用;sqlite 留升级路径(§4) |
| 登录 | 统一扫码(QR);凭证由 bridge 存,选中音源 / 重连时注入 provider | 手柄无文本输入友好;§7.4 |
| 不可用歌曲 | 直接报告用户,不支持代理 / real_ip | 海外/受限网络可用性非本项目目标;§7.4 |