左侧 Steam 主菜单注入「音乐」入口
对应 issue #28。在 Steam Deck 游戏模式左侧主菜单(STEAM 键弹出的 主页/库/商店/媒体/设置/电源)里
插一个「音乐」项,点击进入大屏播放页(route /music)。
定位:可选增强。 依赖 Steam 私有 React 结构,Steam 客户端更新可能失效。QAM 面板 + 账号页
「打开播放器」按钮始终是 fallback,注入失败绝不影响插件本体。实现见 src/steamMenu.ts。
铁律
绝不能崩 UI。 所有步骤 try/catch,任何异常都返回原始渲染;找不到目标就 no-op。注入的是 「加一项」,失败退化为「菜单和原来一样」,不会白屏/卡死。
菜单结构(CDP 实测,当前 SteamOS)
用 steam-cdp skill(.agents/skills/steam-cdp/)连活动前端逆向出来的结构:
- 菜单容器 fiber:
memoizedProps.navID === "MainNavMenuContainer"(稳定锚点)。 - 菜单项组件 =
Ae,props:{ route, label, icon, active:"if-within-route", onGamepadFocus },可复用。 - 真正内联生成菜单项数组的是内部组件
Ie(props{loggedIn, menuOpen}),其输出形如<容器>{[ p.map(descriptor→<Ae>) , <footer> ]}。 <Ie>由内部组件fe(props{bLoggedIn, open, popup, …})在自己的 render 里创建。- 从导出组件
cj(MainMenuBrowserView)到Ie隔着约 11 个组件边界;fe/Ie/ve都不是模块导出。
为什么不用「一跳 findInReactTree」/「afterPatch 导出组件」
- issue #28 设想 patch
navID容器的父 renderer 后一次findInReactTree拿到菜单项数组 —— 实测 不成立:菜单项数组在容器下方约 11 层,不在容器父级的 render 输出里。 Ie/fe非导出、Ie也不是 memo 包裹(其父是 Context.Provider),没有稳定的模块对象可持久afterPatch。唯一的持久导出锚点cj离Ie太远(~11 层),要写深度 tree-patcher。
为什么最终没走「持久锚定 cj」(实测否掉)
一度打算锚在导出组件 cj(MainMenuBrowserView)上、用 createReactTreePatcher 深链下钻,让 React
自己的重渲染做触发器(无定时器、无闪烁)。CDP 实测把它否了 —— 不是"更贵",是实际更差:
cj极少重渲染:它是 memoized 的。开/关菜单时只有下面的fe在原地重渲染,cj直接 bail out 不跑。实测把cj.type换成探针后,反复开关菜单,探针从没被调用;只有切 UI 模式 / 登录态变化 / popup 彻底销毁这类深层重挂载才会让cj真正重渲染。- 冷启动空窗:插件在
cj已挂载之后才加载,而 patchexp.cj.type只对将来的挂载生效 → 当前cj实例仍用原始 type;cj又极少重挂载 → 「音乐」项可能很久不出现。这比轮询版 ≤1s 的闪烁差得多(轮询版打开菜单 ~1s 内必出)。 - 深链脆弱:
cj → Ie~11 层,每层 step 都是 Steam 更新可能断的点。
反观 fe:开菜单就原地重渲染(fiber 复用 → 包裹保留),父级 d 重渲染或菜单重挂载时才丢。
所以轮询版首次包上后大多数时候一直在,timer 很少真需要补,"闪烁"基本只是理论上的。故保留轮询版。
采用的机制:live-fiber 包裹 + 定时重打
src/steamMenu.ts:
- 找
fe:在 fiber 树 DFS,按 prop 签名(bLoggedIn/open/popup)命中fefiber。 - 包
fe:把fe.type(和fe.alternate.type)换成包裹函数。包裹每次渲染调用原始fe拿到输出,在输出里deepFind到<Ie>(loggedIn/menuOpen),把<Ie>.type换成缓存的Ie包裹(按原始Ie引用缓存 → 同一 type 引用,React 不会 remount)。 - 包
Ie:包裹调用原始Ie拿到输出,deepFind到菜单项数组,cloneElement一个原生Ae项、改成{route:"/music", label:t("music")(i18n), icon:<FaMusic/>},按 route 定位插入 ——插到「商店」/steamweb之后(即第 4 个);锚点 route 找不到就退化到末尾。按 route 而非固定 index 定位,Steam 增删菜单项也不错位。按 route 去重。 注意:菜单里「好友与聊天」「电源」是无 route 的项(me组件,非Ae),所以若锚「媒体」前会 落到好友与聊天之后(第 5)—— 锚「商店」后才是第 4。 - 定时重打:
setInterval(1s)。菜单 remount 后父级会用原始fe重建 element →fe.type被 还原,需要重打。disableMenuInjection()(onDismount)清定时器 + 还原fe.type。
何时显示:仅「已选音乐源」时
未选音乐源时大屏页无法用,故 injectItem 由模块级同步标志 providerSelected 门控 —— 为 false 就不
注入(下次 Ie 渲染也不再加,菜单里自然没有「音乐」)。标志来源:enableMenuInjection 加载时查一次
api.getProvider();之后 QAM pick() 选源成功即 setProviderSelected(true) 通知。选了源后下次开菜单
即出现。(注:登出不清除已选源,故登出后入口仍在 —— 只在"从未选过源"时隐藏。)
已知局限
- 闪烁窗口:
fe的 element 被父级重建后、下次 retry 命中前(≤1s),「音乐」项会短暂消失。 可接受(可选增强)。想更稳可缩短 retry 或抬高锚点(代价是更深的下钻)。 - Steam 更新脆弱点:
navID串、fe/Ie的 prop 签名、Ae的 props 约定任一变化都会让注入 静默失效(退回 fallback,不崩)。升级时用 steam-cdp skill 的probe-mainmenu.js重新核对。 - Decky 前端是前置条件:注入逻辑只有在 Decky 成功导入插件后才会执行。Steam client
1784934043将BFinishedInitStageOne改名为BFinishedInitBeforeLogin;Deckyv3.2.6会在导入任何插件前崩溃,表现为 Decky QAM Tab 与「音乐」入口同时消失。新版 Steam 必须使用包含 官方修复的 Deckyv3.2.8-pre1或更高版本。此时不要误改fe/Ie签名;先在 SharedJSContext 控制台排除宿主初始化异常。
踩坑记录:定位插错(第 5 → 第 4)
第一版把「音乐」插到了第 5 个而非第 4,根因是核对探针在骗人,值得记:
- 菜单里并非所有项都有
route:「好友与聊天」「电源」是me组件、无 route;有 route 的 (Ae)只有 主页/库/商店/媒体/下载/设置。 - 最初用的核对探针只列同时有
route和label的项,把无 route 的滤掉了 → 看到的是"6 项"的 残缺菜单,误以为「媒体前 = 第 4」。真实数组里「好友与聊天」夹在商店与媒体之间,于是"媒体前"实际 落到它之后 = 第 5。(插入代码用的一直是真实数组,错的只是"看不见它、锚点选偏"。) - 修法:锚「商店
/steamweb之后」(无视中间几个无 route 项),核对改用 steam-cdp skill 的probe-menu-items.js(列所有带 label 的项,含无 route 的)。
教训:验证要看完整数组,别用过滤过的子集 —— 量错了还以为对了。
复验 / 调试
见 steam-cdp skill(.agents/skills/steam-cdp/SKILL.md)。核对结构:
ssh -N -L 8080:localhost:8080 deck@<ip> &
node .agents/skills/steam-cdp/scripts/cdp.mjs SharedJSContext .agents/skills/steam-cdp/scripts/probe-mainmenu.js
关注:navID 仍是 MainNavMenuContainer、菜单项组件仍有 {route,label,icon,onGamepadFocus}、
fe/Ie 的 prop 签名未变。