dsh-plugin-wallpaper-engine-media-bridge 0.0.0-stage → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ # cordis.patch.yml — 「系统媒体桥」子包自己的行(ADR-0014 S4.1 / S4.2 / S4.4)。
2
+ #
3
+ # ⚠️ 一次安装形态(主包 patch 用 name 引用子包,ADR-0014 S4.1)下,这一行由**主包**的
4
+ # cordis.patch.yml 插入;只有把这个子包自己当成独立 bundle 时
5
+ # (各自 dsh.bundle.patch + 各自进 dsh.profile.bundles,**不采纳**)本文件才生效。
6
+ # 两处同时插同一个 id 会撞车 ⇒ 接线时以主包那份为准,本文件保持与它逐字一致(ADR-0014 S4.2)。
7
+ #
8
+ # 行名 = 真实包名(裸真名):本版 DSH 不传 bareModuleBaseUrl ⇒ 行名一律从 <profile>/cordis.yml
9
+ # 旁起算,裸名 → <profile>/node_modules/<name>;开发期靠 scripts/dev-link-subpackages.mjs 建的
10
+ # junction 让这个名字出现在 profile/node_modules 里(ADR-0014 S4.4),发布期靠传递依赖 hoist。
11
+ # id 一旦定下不再改(ADR-0014 S4.3)。
12
+ - insert:
13
+ - id: wallpaper-engine-media-bridge
14
+ name: 'dsh-plugin-wallpaper-engine-media-bridge'
package/icon.png ADDED
Binary file
@@ -0,0 +1,146 @@
1
+ // dsh-wallpaper-engine — 系统音频频谱采集器(macOS 14.2+ CoreAudio Process Tap)
2
+ //
3
+ // 用途:把「系统正在播放的声音」(loopback,不经过麦克风)转成 64 段频谱,
4
+ // 20fps 写到 stdout(每帧固定 64 字节,0-255 整数)—— 供插件宿主喂给
5
+ // WebWallGL 渲染页,让壁纸的音频可视化 / registerAudioBuffers 跟随真实音乐。
6
+ //
7
+ // 权限:首次运行会请求「音频录制」(TCC)。未授权时 AudioHardwareCreateProcessTap
8
+ // 返回错误码,本程序以 stderr 一行 `tap-create-failed <code>` 退出 —— 宿主据此
9
+ // 在设置界面给出授权指引,并回落到模拟频谱。
10
+ //
11
+ // 设计取舍:
12
+ // - tap 的 IOProc 在实时线程:回调里只做「写环形缓冲」(无分配、无锁;单写单读
13
+ // 的环形缓冲最坏读到半帧数据,频谱略微跳动,音频可视化完全可接受)。
14
+ // - FFT / 分箱 / 输出放在主线程 20fps 定时器里做(2048 点 vDSP real FFT,微秒级)。
15
+ // - 立体声 → 单声道(多声道取平均):壁纸音条不强调声道差(上游对自带 BGM 也是
16
+ // 同值喂左右)。
17
+ import AudioToolbox
18
+ import CoreAudio
19
+ import Accelerate
20
+ import Foundation
21
+
22
+ let FFT_N = 2048
23
+ let LOG2N = vDSP_Length(11) // 2^11 = 2048
24
+ let BANDS = 64
25
+ let FPS = 20
26
+ let RING = 1 << 15 // 32768 样本(约 0.68s @48k),远大于 FFT 窗口
27
+
28
+ private var ring = [Float](repeating: 0, count: RING)
29
+ private var ringWrite = 0
30
+
31
+ private func fail(_ msg: String, _ code: Int32) -> Never {
32
+ FileHandle.standardError.write((msg + "\n").data(using: .utf8)!)
33
+ exit(code)
34
+ }
35
+
36
+ // ── 1. Process Tap(系统全局音频;排除自身进程避免自采反馈)──────────────────
37
+ let tapDesc = CATapDescription(stereoGlobalTapButExcludeProcesses: [])
38
+ tapDesc.name = "dsh-we-audio-tap"
39
+ tapDesc.isPrivate = true
40
+ tapDesc.muteBehavior = .unmuted // 只监听,不影响系统输出
41
+
42
+ var tapID = AudioObjectID(kAudioObjectUnknown)
43
+ var err = AudioHardwareCreateProcessTap(tapDesc, &tapID)
44
+ if err != noErr { fail("tap-create-failed \(err)", 2) }
45
+
46
+ // ── 2. 聚合设备承载 tap ──────────────────────────────────────────────────────
47
+ let aggDesc: [String: Any] = [
48
+ kAudioAggregateDeviceNameKey: "dsh-we-audio-tap-agg",
49
+ kAudioAggregateDeviceUIDKey: UUID().uuidString,
50
+ kAudioAggregateDeviceIsPrivateKey: true,
51
+ kAudioAggregateDeviceTapListKey: [[
52
+ kAudioSubTapUIDKey: tapDesc.uuid.uuidString,
53
+ kAudioSubTapDriftCompensationKey: true,
54
+ ]],
55
+ ]
56
+ var aggID = AudioObjectID(kAudioObjectUnknown)
57
+ err = AudioHardwareCreateAggregateDevice(aggDesc as CFDictionary, &aggID)
58
+ if err != noErr { fail("agg-create-failed \(err)", 3) }
59
+
60
+ // ── 3. IOProc:只写环形缓冲 ─────────────────────────────────────────────────
61
+ var procID: AudioDeviceIOProcID?
62
+ err = AudioDeviceCreateIOProcIDWithBlock(&procID, aggID, nil) { _, inData, _, _, _ in
63
+ let abl = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: inData))
64
+ guard abl.count > 0 else { return }
65
+ let buf = abl[0]
66
+ guard let mData = buf.mData else { return }
67
+ let ch = max(1, Int(buf.mNumberChannels))
68
+ let total = Int(buf.mDataByteSize) / MemoryLayout<Float>.size
69
+ let ptr = mData.bindMemory(to: Float.self, capacity: total)
70
+ if ch == 1 {
71
+ for i in 0..<total {
72
+ ring[ringWrite] = ptr[i]
73
+ ringWrite = (ringWrite + 1) % RING
74
+ }
75
+ } else {
76
+ var i = 0
77
+ while i + ch <= total {
78
+ var s: Float = 0
79
+ for c in 0..<ch { s += ptr[i + c] }
80
+ ring[ringWrite] = s / Float(ch)
81
+ ringWrite = (ringWrite + 1) % RING
82
+ i += ch
83
+ }
84
+ }
85
+ }
86
+ if err != noErr { fail("ioproc-create-failed \(err)", 4) }
87
+ err = AudioDeviceStart(aggID, procID)
88
+ if err != noErr { fail("device-start-failed \(err)", 5) }
89
+ FileHandle.standardError.write("ready\n".data(using: .utf8)!)
90
+
91
+ // ── 4. 主线程 20fps:FFT → 64 段 → stdout ──────────────────────────────────
92
+ let setup = vDSP_create_fftsetup(LOG2N, FFTRadix(kFFTRadix2))!
93
+ var hann = [Float](repeating: 0, count: FFT_N)
94
+ vDSP_hann_window(&hann, vDSP_Length(FFT_N), Int32(vDSP_HANN_NORM))
95
+ var input = [Float](repeating: 0, count: FFT_N)
96
+ var real = [Float](repeating: 0, count: FFT_N / 2)
97
+ var imag = [Float](repeating: 0, count: FFT_N / 2)
98
+ var mags = [Float](repeating: 0, count: FFT_N / 2)
99
+ var out = [UInt8](repeating: 0, count: BANDS)
100
+ let usable = Float(FFT_N / 2) * 0.72 // 截到 ~16kHz@48k:更高频段能量极微
101
+ let lo = 2
102
+
103
+ func emitFrame() {
104
+ let w = ringWrite
105
+ let start = (w - FFT_N + RING) % RING
106
+ for i in 0..<FFT_N { input[i] = ring[(start + i) % RING] }
107
+ vDSP_vmul(input, 1, hann, 1, &input, 1, vDSP_Length(FFT_N))
108
+ real.withUnsafeMutableBufferPointer { rp in
109
+ imag.withUnsafeMutableBufferPointer { ip in
110
+ var split = DSPSplitComplex(realp: rp.baseAddress!, imagp: ip.baseAddress!)
111
+ input.withUnsafeBufferPointer { inp in
112
+ inp.baseAddress!.withMemoryRebound(to: DSPComplex.self, capacity: FFT_N / 2) { cp in
113
+ vDSP_ctoz(cp, 2, &split, 1, vDSP_Length(FFT_N / 2))
114
+ }
115
+ }
116
+ vDSP_fft_zrip(setup, &split, 1, LOG2N, FFTDirection(FFT_FORWARD))
117
+ vDSP_zvabs(&split, 1, &mags, 1, vDSP_Length(FFT_N / 2))
118
+ }
119
+ }
120
+ let norm = 1.0 / Float(FFT_N) // real FFT 的幅度归一(相对值,标定足够)
121
+ for b in 0..<BANDS {
122
+ let f0 = lo + Int(Float(usable) * pow(Float(b) / Float(BANDS), 2))
123
+ let f1 = max(f0 + 1, lo + Int(Float(usable) * pow(Float(b + 1) / Float(BANDS), 2)))
124
+ var peak: Float = 0
125
+ var f = f0
126
+ while f < f1 && f < mags.count {
127
+ let v = mags[f] * norm
128
+ if v > peak { peak = v }
129
+ f += 1
130
+ }
131
+ // -70dB..0dB → 0..255(对数观感,安静段落接近 0 而不是恒亮)
132
+ let db = 20 * log10(max(peak, 1e-7))
133
+ let n = max(0, min(1, (db + 70) / 70))
134
+ out[b] = UInt8(n * 255)
135
+ }
136
+ FileHandle.standardOutput.write(Data(out))
137
+ }
138
+
139
+ let timer = Timer(timeInterval: 1.0 / Double(FPS), repeats: true) { _ in emitFrame() }
140
+ RunLoop.main.add(timer, forMode: .common)
141
+
142
+ // 信号兜底:宿主 kill 时安静退出(private 聚合设备随进程回收)
143
+ signal(SIGTERM) { _ in exit(0) }
144
+ signal(SIGINT) { _ in exit(0) }
145
+
146
+ RunLoop.main.run()
package/lib/backend.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * backend.js — 媒体桥的**媒体后端门面**:**首选 media-bridge 子进程,失败自动回落内置实现**。
3
+ *
4
+ * 对宿主只暴露一小组方法(start/stop/spectrum/nowPlaying/artworkFile/artworkMime/status
5
+ * 与内置实现同名,另有中间件专供的 usingBridge/bridgeInfo/control),所以同目录的路由族
6
+ * (`now-playing.js`)不需要知道底下是谁在供数 —— 这是刻意留的接缝:换后端不动路由形状,
7
+ * 回滚也不需要改路由。
8
+ *
9
+ * 选择逻辑(每次会话只决定一次,之后靠 bridge.ready() 判断中间件是否还活着):
10
+ * 1. `DSH_WE_MEDIA_LEGACY=1` → 直接用内置实现(排查/对比用)
11
+ * 2. 产物解析成功 + spawn + 握手通过 → 中间件(三平台同一套能力)
12
+ * 3. 任一步失败(取不到产物/起不来/协议不符/连续崩溃)→ 内置实现 + 原因进 status
13
+ *
14
+ * 日志注入契约:本目录只调用注入的 `log(msg, level?)`,**不自己写终端**(终端与落档的唯一
15
+ * 入口是 `lib/log.js`,见其文件头)。级别缺省 `info`;判据是"影响显示效果的非正常表现
16
+ * ⇒ `warn`":回落、启动失败、中间件报错与重启都是 `warn`,成功事实与运行信息是 `info`。
17
+ */
18
+ import { join } from 'node:path';
19
+ import { createMediaBridge as createLegacy } from './legacy.js';
20
+ import { createBridgeSupervisor } from './supervisor.js';
21
+ import { provisionMediaBridge } from './provision.js';
22
+
23
+ /**
24
+ * @param {object} o
25
+ * @param {string} o.dataDir 插件数据目录(**不是**缓存根):中间件产物 `<dataDir>/bin/media-bridge/`
26
+ * 与 macOS 音频 tap 的编译产物 `<dataDir>/bin/we-audio-tap` 都落这里 —— 它们属**工具链**,
27
+ * 和 ffmpeg 一样不随「缓存位置」走(挪了要重下 / 重编译,那是另一件事)。
28
+ * @param {string|(() => string)} [o.cacheBaseDir] 缓存根(见 `lib/kernel/data-dir.js` 的 `cacheBaseDir()`):中间件的
29
+ * 运行时缓存(`--cache-dir`)与内置实现的封面缓存都在它下面。缺省 `<dataDir>/cache` ——
30
+ * 与默认布局逐字一致,所以只传 `dataDir` 的老调用点行为不变。**可以传访问器**(推荐,
31
+ * 路由就这么传):那样「缓存位置」改完,中间件下一次起动 / 下一张封面就走新根,不必重启宿主。
32
+ * @param {(text: any, level?: string) => void} [o.log] 日志被调用面(`log(msg, level?)`);缺省空实现。
33
+ * @param {(name:string, data?:any) => void} [o.diag] 诊断上报(宿主注入 `appendDiagLine` 包装)。
34
+ */
35
+ export function createMediaBackend({ dataDir, cacheBaseDir = join(dataDir, 'cache'), log = () => {}, diag = () => {} }) {
36
+ // 缓存根**每次现问**(同 `cacheBaseDir()` 的理由):传进来是访问器就现算,是字符串就照用。
37
+ // 在这里存值快照会让"改完缓存位置、新缓存又写回旧盘"悄悄发生 —— 正是本轮要修的那类 bug。
38
+ const cacheRoot = () => (typeof cacheBaseDir === 'function' ? cacheBaseDir() : cacheBaseDir);
39
+
40
+ let started = false;
41
+ let useBridge = false; // 中间件可用(握手通过且没被判死)
42
+ let fallback = ''; // 回落原因(空 = 没回落)
43
+ let note = ''; // 准备中的说明(设置界面可见)
44
+ let booting = null;
45
+ let sup = null;
46
+ let legacy = null;
47
+ const optsRef = { audio: true, online: false };
48
+ const forceLegacy = String(process.env.DSH_WE_MEDIA_LEGACY || '').trim() === '1';
49
+
50
+ /** 内置实现按需构造(音频开关要跟着用户设置走,不能构造时就定死)。 */
51
+ function legacyBackend() {
52
+ if (!legacy) legacy = createLegacy({ dataDir, cacheBaseDir: cacheRoot, log, audio: optsRef.audio });
53
+ return legacy;
54
+ }
55
+
56
+ /** 当前真正在供数的后端(中间件没就绪/已判死时都落到内置实现)。 */
57
+ function active() {
58
+ if (useBridge && sup && sup.ready()) return sup;
59
+ return legacyBackend();
60
+ }
61
+
62
+ function fallBackTo(reason) {
63
+ if (fallback) return;
64
+ fallback = reason || '中间件不可用';
65
+ useBridge = false;
66
+ note = '';
67
+ log('回落到内置媒体实现:' + fallback, 'warn');
68
+ diag('media-fallback', { reason: String(fallback).slice(0, 200) });
69
+ try { legacyBackend().start(); } catch (err) { log('内置媒体实现启动失败:' + err, 'warn'); }
70
+ }
71
+
72
+ function bootBridge() {
73
+ if (booting) return booting;
74
+ booting = (async () => {
75
+ note = '正在准备系统媒体中间件…';
76
+ const prov = await provisionMediaBridge({ dataDir, log });
77
+ if (!prov.path) { fallBackTo(prov.error || '中间件产物不可用'); return; }
78
+ sup = createBridgeSupervisor({
79
+ binPath: prov.path,
80
+ cacheDir: join(cacheRoot(), 'media-bridge'),
81
+ log,
82
+ diag,
83
+ onFatal: (why) => fallBackTo(why),
84
+ });
85
+ const ok = await sup.start({ audio: optsRef.audio, online: optsRef.online });
86
+ if (!ok) { fallBackTo(sup.lastError() || '中间件启动失败'); return; }
87
+ useBridge = true;
88
+ note = '';
89
+ log(`媒体数据源:系统媒体中间件(${prov.source}${prov.tag ? ', ' + prov.tag : ''})`, 'info');
90
+ })().catch((err) => {
91
+ fallBackTo('中间件准备出错:' + String(err && err.message ? err.message : err));
92
+ }).finally(() => { booting = null; });
93
+ return booting;
94
+ }
95
+
96
+ return {
97
+ /** 懒启动(由路由第一次被访问时触发;重复调用无副作用)。 */
98
+ start(opts = {}) {
99
+ if (started) return;
100
+ started = true;
101
+ Object.assign(optsRef, { audio: opts.audio !== false, online: opts.online === true });
102
+ if (forceLegacy) {
103
+ fallback = '按 DSH_WE_MEDIA_LEGACY=1 使用内置实现';
104
+ log('媒体数据源:内置实现(DSH_WE_MEDIA_LEGACY=1)', 'info');
105
+ legacyBackend().start();
106
+ return;
107
+ }
108
+ bootBridge();
109
+ },
110
+ stop() {
111
+ try { if (sup) sup.stop(); } catch { /* ignore */ }
112
+ try { if (legacy) legacy.stop(); } catch { /* ignore */ }
113
+ },
114
+ spectrum: () => active().spectrum(),
115
+ nowPlaying: () => active().nowPlaying(),
116
+ artworkFile: () => active().artworkFile(),
117
+ artworkMime: () => active().artworkMime(),
118
+ /**
119
+ * 反向控制(壁纸里的播放按钮 / 宿主面板 → 真实播放器):**只有中间件能做**
120
+ * —— 内置实现是纯读取。中间件没就绪(还没启动 / 已判死回落)时统一回不可用,
121
+ * 不在此处触发启动:控制是用户点一下的交互,不该顺带把产物下载与子进程
122
+ * 拉起来(启动仍由取数那四条路由的懒启动决定)。
123
+ *
124
+ * 返回值一律是 `{ ok, … }`(不抛),路由按它决定状态码。
125
+ */
126
+ async control(action) {
127
+ if (useBridge && sup && sup.ready()) return sup.control(action);
128
+ return { ok: false, error: fallback ? 'control-unsupported' : 'media-backend-not-ready' };
129
+ },
130
+ status() {
131
+ const live = useBridge && sup && sup.ready();
132
+ const st = active().status();
133
+ return {
134
+ ...st,
135
+ backend: live ? 'bridge' : st.backend,
136
+ fallback,
137
+ note,
138
+ };
139
+ },
140
+ /** 当前是否中间件在供数(自检与设置界面用)。 */
141
+ usingBridge: () => Boolean(useBridge && sup && sup.ready()),
142
+ bridgeInfo: () => (sup ? sup.info() : null),
143
+ };
144
+ }
package/lib/index.js ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `media-bridge` 子包宿主入口(P-F 第三格:`lib/components/media-bridge/` → `packages/media-bridge/`)。
3
+ *
4
+ * 为什么这一格独立成行:这五条路由(`/media-status`、`/audio-spectrum`、`/now-playing`、
5
+ * `/now-playing/artwork`、`/media-control`)与它们的后端实现(media-bridge 子进程 + 内置回落)
6
+ * 自成一条边:桥的状态 = 一个**子进程**与它自己的磁盘载体,与 `lib/media/` 那六个"内核媒体"
7
+ * 文件(帧缓存 / 媒体目录 / MP4 探测 / 场景探测 / 视频预览 / 转码)零交叉 —— 后者仍由内核
8
+ * (`lib/kernel/host-kernel.js`)持有,因为 `lib/index.js` 的 `/inventory`、`/media`、`/scene-*`
9
+ * 都消费它们。出户后"这个组件由哪些文件构成"由**位置**回答(M6/M10)。
10
+ *
11
+ * 两处加载路径共用同一份实现(照抄 dsh-ops 的组件真形):
12
+ * ① 宿主把 patch 行的 `name` 当**独立 plugin** 加载 ⇒ `apply(ctx, config)` 是入口;
13
+ * ② 门面的 `applyComponent(root, rawConfig, 'media-bridge')` 是"按组件加载"入口。
14
+ * P-F 之后两条路径的 deps **不再是两份各写各的字面量**:门面与入口都从
15
+ * `lib/subpackage-boot.js` 的 `subpackageKeys('media-bridge', ctx)` 取**同一份冻结键视图**
16
+ * ⇒ 两条路径不可能漂移(对账面 = `test/verify-inject-contracts.mjs` ② 的叶子冻结表逐键对账)。
17
+ *
18
+ * `export const name` 与 patch 行的 `name` 逐字相等(`dsh-plugin-wallpaper-engine-media-bridge`)。
19
+ *
20
+ * ⚠️ **跨包纪律(ADR-0014 S5.1 / S5.2,判据 `test/verify-subpackage-purity.mjs`)**:本模块
21
+ * **零主包说明符、零相对路径逃出本包** —— 只 import 本包内的路由族。宿主能力一律经
22
+ * `ctx.wallpaperEngineHost` 的键视图(`host.keys('media-bridge', ctx)`)取。这不是风格问题:
23
+ * 相对 import 主包文件、或 import 主包名,都是该判据的硬红。
24
+ *
25
+ * ⚠️ **取服务姿势**:先声明 `export const inject = ['webServer', 'wallpaperEngineHost']`,再直接读
26
+ * `ctx.<服务>`。缺服务且不声明 inject 会被 cordis 的 reflect `get` 当场抛
27
+ * `cannot get property "webServer" without inject at applyImpl`(真机原文),函数体里那些早退
28
+ * 分支永远执行不到(死代码)。声明 inject 后由 Loader 保证「服务就绪才激活」,下面的早退分支只兜
29
+ * 「服务在、但 register 面还没挂上」这一种情况。
30
+ *
31
+ * ⚠️ **teardown 得交两份**:本模块导出的 `apply` 是**函数声明**(有 `prototype`)⇒ cordis 的 loader
32
+ * 走 `new apply(ctx, config)` 分支、**返回值被丢弃** ⇒ 只 `return keys.runComponent(…)` 的话 fiber
33
+ * 卸载时没人撤路由。所以返回值一律走 `keys.handOffTeardown(ctx, …)`(同一份 disposer 也交给
34
+ * `ctx.effect` 并做幂等包装,见 `lib/host-boot.js`)。判据 `test/verify-bundle-rows.mjs` ⑧c。
35
+ *
36
+ * ⚠️ **模块作用域纪律**:本模块被门面**静态** import(`test/verify-reachability.mjs` 的 import 图
37
+ * 承重),宿主真实装载顺序又是**裸行在前** ⇒ 本模块可能**先于**门面求值。模块作用域(顶层、
38
+ * 任何函数体之外)只许放声明/常量 —— 不得在求值期读别的模块的绑定并调用它(会踩 TDZ)。
39
+ * 对应判据:`test/verify-bundle-rows.mjs` ⑥(结构判据 + 模块作用域文本兜底,双向)。
40
+ *
41
+ * ⚠️ **原先的两个叶子 import 已改为服务面取键(P-F)**:`configPath` 与 `CONTROL_JSON_MAX_BYTES`
42
+ * 曾由本入口直接 import 主包叶子模块(`lib/kernel/data-dir.js` / `lib/http-body.js`)——出户后
43
+ * 那两条相对路径既不可解析、也违反跨包纪律。现在它们分别走键表的 `data:<名>` 与 `http:<名>` 源型
44
+ * (`lib/subpackage-boot.js` 的 `DATA_VIEW` / `HTTP_VIEW`),**仍然是同一份模块级绑定**(不是副本)。
45
+ * 键集见 `lib/kernel/component-session.js` 的 `COMPONENT_VIEW_KEYS['media-bridge']` 注释。
46
+ */
47
+ import { registerNowPlayingRoutes } from './routes/now-playing.js';
48
+
49
+ /** patch 行 slug(`cordis.patch.yml` 里该行 `name` 的完整值,两处逐字相等)。 */
50
+ export const name = 'dsh-plugin-wallpaper-engine-media-bridge';
51
+
52
+ /**
53
+ * 硬依赖 web 服务 + 主包宿主服务:与主行 `lib/index.js` 的 `inject` 同一套语义。
54
+ * 子包行走 patch 行加载时,Loader 靠这两句保证「服务就绪才激活」,`ctx.webServer` /
55
+ * `ctx.wallpaperEngineHost` 才读得到。
56
+ */
57
+ export const inject = ['webServer', 'wallpaperEngineHost'];
58
+
59
+ /** 独立 plugin 入口:宿主按 patch 行加载本子包时走这里,转调本模块的实现(不经门面)。 */
60
+ export function apply(ctx, config) {
61
+ // ⚠️ 这个字面形态是**承重**的:`test/verify-inject-contracts.mjs` 的 `tableBindingNames()` 按
62
+ // `const <host> = ctx.<服务名>` 认「本文件里的冻结表绑定」(`recvRe`),再据此把下面调用点的
63
+ // `...keys` 展开成叶子冻结表。写成 `ctx && ctx.<服务名>` 守卫就认不出表 ⇒ 整包调用点判 dynamic
64
+ // 而红(nc-sub7 钉的就是这条:接收者必须源自 `ctx.<服务名>`)。
65
+ const host = ctx.wallpaperEngineHost;
66
+ // 拿不到服务名就大声失败:静默 `undefined` 会让下面每一步都在 undefined 上取属性(半死态)。
67
+ if (!host) {
68
+ throw new Error('[wallpaper-engine] media-bridge 没拿到 wallpaperEngineHost 服务'
69
+ + '(inject 声明与宿主装载顺序不匹配)');
70
+ }
71
+ // 键视图 = `lib/subpackage-boot.js` 的 SUBPACKAGE_KEY_TABLE 里本包那一行(值全部来自主包模块级绑定)。
72
+ // 记账 / 错误留证 / 「失败仍原样抛」三件事都在 `keys.runComponent()`(门面 `applyComponent`
73
+ // 走的是同一个函数)⇒ 两条装载路径共用一份语义,不可能一条记了、另一条忘了。
74
+ const keys = host.keys('media-bridge', ctx);
75
+ // `http` 面(`sendJson` / `bodyReader`)不在冻结键表里,与 `packages/fonts` 同款:按需显式带上。
76
+ const deps = Object.freeze({ ...keys, http: host.http });
77
+ return keys.handOffTeardown(ctx, keys.runComponent('media-bridge', ctx, config, deps, applyImpl));
78
+ }
79
+
80
+ /**
81
+ * 组件实现。第三个形参名取 `c`(与 `lib/kernel/host-session.js` 同一约定):
82
+ * `test/tools/host-route-index.mjs --deps` 按 `const { … } = c;` 现抽契约;路由族调用点的
83
+ * `...keys` 展开则由 `test/verify-inject-contracts.mjs` 按冻结表认(`keys` 与 `apply()` 里那个
84
+ * 绑定同名 ⇒ 两边指的是同一份键视图)。
85
+ */
86
+ export function applyImpl(root, _rawConfig, c) {
87
+ const keys = c;
88
+ const { base, pluginName, configPath, CONTROL_JSON_MAX_BYTES, componentViewFrom, createComponentSession } = c;
89
+ // 两条装载路径都能这么读:① 子包行自己声明了 `inject = ['webServer', 'wallpaperEngineHost']`;
90
+ // ② 门面路径的 `root` 就是门面的 ctx,而门面同样声明了 `inject = ['webServer']`(主行
91
+ // `lib/index.js` 的 `inject` 声明)。
92
+ const webServer = root && root.webServer;
93
+ // 与门面 `apply()` 同款早退:服务在、但 register 面还没挂上(或换成了别的 webServer 实现)。
94
+ if (!webServer || typeof webServer.register !== 'function') return () => {};
95
+
96
+ // P2-7B 注入面收窄:从内核取**本组件声明的视图**(`COMPONENT_VIEW_KEYS['media-bridge']`),
97
+ // 不再 `const { … } = hostKernel` 现解构;视图缺键即抛,不静默 `undefined`。
98
+ const view = componentViewFrom('media-bridge');
99
+ // 迷你会话(disposers / log / serveFile / 同序 teardown)收在内核侧;`appendDiagLine` 由会话
100
+ // 一并交出(它在 SESSION_SERVE_KEYS 里,入口若自己再从视图解构一次,`verify-component-face` ③
101
+ // 的「入口 ∩ 会话 = ∅」会红)。
102
+ const { log, disposers, serveFile, teardown, appendDiagLine } = createComponentSession(root, { base, pluginName }, view);
103
+ // 本组件自己要的两个键:后端启动决策每次现读设置(`readConfig`),缓存根传**访问器**。
104
+ const { readConfig, cacheBaseDir } = view;
105
+
106
+ // ⚠️ **注册期必须包 try**(P2-4):这一族一次性注册**五条**路由,中途抛错时已经推进
107
+ // `disposers` 的那几条会留下(`mediaBackend.stop()` 也在同一个 disposer 列表里,一并丢)
108
+ // ⇒ 下一次启用撞 `webserver: duplicate … route`,且桥的子进程无人收。`teardown()` 自己吞异常,
109
+ // 原错照抛(装载失败必须仍然是红的)。
110
+ try {
111
+ // 3c-5. 媒体状态族路由(媒体后端懒启动 + /media-status、/audio-spectrum、/now-playing、
112
+ // /now-playing/artwork、/media-control)→ 本包 `lib/routes/now-playing.js`。
113
+ // 后端实例、启动决策与五条注册都在那个文件里;这里只声明它的依赖(与门面调用点逐键相同)。
114
+ registerNowPlayingRoutes(webServer, {
115
+ ...keys, disposers, base, appendDiagLine, configPath, cacheBaseDir, readConfig, serveFile,
116
+ CONTROL_JSON_MAX_BYTES, http: keys.http,
117
+ // 媒体子系统的行统一带 `[media]` 标签(与终端既有形状逐字一致);级别由调用点标注。
118
+ log: log.tag('media'),
119
+ });
120
+ } catch (error) {
121
+ teardown();
122
+ throw error;
123
+ }
124
+
125
+ // 本组件自己的收尾(会话的 teardown):与 host-session 的 teardown 同序(逐个 disposer →
126
+ // destroy 在途流 → 清两个容器),但**没有** releaseKernelLease —— 见 `component-session.js`
127
+ // 文件头那条有意不对称(桥的子进程由它自己的 disposer `mediaBackend.stop()` 收掉)。
128
+ return teardown;
129
+ }