DECKYMUSIC
开发文档Docs › 架构设计与协议Architecture & protocol

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/>系统常驻)]

设计红线(用户明确要求):

  1. UI 只跟 bridge 说话,不知道 provider/player 存在,不直接碰后端。
  2. 不走 TCP,全部 unix domain socket。
  3. 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(stdlib json)。 被 §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") 直接调用 bridge Plugin 类的同名 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(与 Decky BUFFER_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 共用 wire crate(通用部分),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 随依赖进来,静态无碍

性能真正的着力点(与序列化无关):

  1. 进度本地插值(最大一笔)。 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(定时纠偏)先不做——插值在无缓冲卡顿时精确;等真观测到漂移再加。

  2. 全链路 event-driven,禁止轮询忙等。 bridge 阻塞在 asyncio readline 上,无消息即睡。
  3. 解码效率交给 rodio/symphonia(原生码,压缩流解码 ~1-2% 单核),已由 §7.3 解决。
  4. 播放器 store 有明确生命周期。 插件入口调用 startPlayer,卸载调用幂等 stopPlayer; 保存并执行事件退订,清除音量 debounce timer,以代次/修订号拒绝旧 hydrate 和异步错误。宿主替换 listener map 不等于清理这些 timer/promise。
  5. 窗口化几何一致。 歌曲内容行高 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 干脆 hardwire static(false) → 淘汰。
  • libpulse-binding:链 C libpulse.so,非纯 Rust → 约束放开后无必要。
  • pipewire-rs / pipewire-native:前者 C bindings;后者纯 Rust 但音频处理尚未实现,不能播 → 淘汰。
  • pulseaudio crate(纯 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 线协议,不链 C libdbus) —— 维持"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 的消息分类, 不是多请求乱序响应测试。不能把现有连接边界回归描述为所有并发业务竞态都已覆盖。

跨层改动规则:

  1. 改 bridge callable 或 emit 事件 → 同步改 src/api.ts。
  2. 改 bridge ↔ child 协议字段 → 同步改 bridge、wire、player、provider 的 protocol 模块与测试。
  3. 子进程错误必须返回稳定 error.code;日志和错误消息不得包含 URL/cookie/credential。

10. 关键风险与待验证项(按优先级)

  1. 【最高】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/1000 spawn player,UDS 连接建立。游戏模式(gamescope 会话)下经 UI「测试播放」→ bridge play_url → player load 出声正常,暂停/继续可用。→ 命门彻底关闭:整条 UI→bridge→player 在真机游戏模式跑通。
    • 通用 SteamOS 待验收:以上是历史 Steam Deck 记录,不是当前所有设备的支持证明。非 Deck 设备暂缺硬件,后续须记录系统/Steam/Decky 版本、用户名/UID、安装路径、屏幕比例/缩放、 外接控制器、默认音频与设备切换、睡眠唤醒、故障后宿主存活。Steam Deck 回归与该矩阵分开记录。
  2. 子进程崩溃恢复。 连接失效由 ipc.py 通知,supervision.py 负责按需重启和重新注入状态;player 不在时保留断点,provider 整体超时后回收并在下一请求重建。继续用真机故障注入验证,不把架构说明当作所有恢复路径已验收。
  3. NDJSON 乱序并发。 已完成:bridge Conn 支持 request-id demux;QQ/NCM provider 命令处理后台化,慢上游不堵读循环;player load 后台化,慢 CDN 不堵控制命令。请求语义按 id 匹配响应,互不等待。
  4. 二进制执行位。 remote_binary 下载后确认 bin/ 下文件有 +x;缺失则 bridge 里 os.chmod。
  5. provider 端口/环境差异消除。 因统一走 UDS + 自写 wrapper,原库的 HTTP server / 端口配置不再使用。
  6. 音频格式覆盖。 确认 symphonia 覆盖网易云/QQ 实际下发的编码(mp3/flac/aac/ogg);缺项时补 rubato 重采样或换解码路径。
  7. 原生左栏菜单注入(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 用 qrcode crate 本地渲染二维码 → 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 用免费歌验证,song_url 对免费歌不需要 cookie 修订(2026-07-04):登录提前到 P1。 实测 QQ 免费歌 song_url 也需登录(匿名 104003),扫码登录是 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

在 GitHub 上查看 / 编辑此文档View or edit this page on GitHub ↗