# WEB-WALLPAPER —— 网页(web)类壁纸:类型判定、沙箱策略与 WE API shim > 适用范围:`dsh-mpkg-wallpaper`(MIT,插件侧)对 Wallpaper Engine **web 类壁纸**的支持。 > 相关代码:`lib/web-wallpaper.js`(宿主侧:判定 + shim 源码 + HTML 注入 + 跨源策略 + **帧内合成事件派发** + 存储 facade + 主音量)、 > `lib/web-interaction.js`(父页侧:坐标换算 + 事件整形 + 交互模式状态机 + **触摸代理**;**交互语义的唯一源**)、 > `lib/index.js`(`/custom-folder`、`/library-web` 两条资源路由 + `/custom-dir` 扫描 + `/web-store`、`/media-audio` 两条状态路由)、 > `lib/client.js`(沙箱 iframe 挂载 + postMessage 控制通道 + 交互舞台 + 兜底)。 > 回归:`node tools/web-wallpaper-test.mjs`(已并入 `tools/check.sh` 第 5 步;含**宿主路由端到端**:真注入 / 无标记零回归 / CORS / `__mpw-list.json` / `/custom-dir` 内容优先扫描 / **`/web-store` + `/media-audio` + CSP 跳过 + 源级 URL 改写**)。 > 与交互注入的帧内合成(E13)、`node tools/web-interaction-test.mjs`(坐标换算 / 事件整形 / 开关状态机 / 沙箱边界 / 舞台契约 / 两侧源码对拍)。 > 本项**渲染器零改动**:`we-scene-demo/` 一行未动,网页壁纸不经过 WebGL/渲染器。 > > **分工(①WP-1 / WP-2,2026-09-19,避免重复踩同一块地)**: > **帧内**(`WEB_SHIM_SOURCE`,在 `lib/web-wallpaper.js`)负责"消息 → 合成 DOM 事件"的实际派发与帧内策略执行; > **父页**(`lib/web-interaction.js`)负责坐标换算、事件整形、交互模式状态机与舞台; > 触摸/点击代理(`op:'touch'` + `WEB_TOUCH_FRAME_SOURCE`)由 **WP-2** 负责,注入点是 `rewriteWebEntryHtml` 的注入串 > (shim → 种子 → 作者脚本,触摸源码插在 shim 之后)。 > **触摸是自研**:上游 `oneincase/webwallgl` 的 `web-shim.js`/`web.ts` 里 **0 处** touch 处理(只有 pointer/wheel), > 所以没有可对照的上游语义,触摸协议由本仓库自定义(详见 `lib/web-interaction.js` 与 `docs/` 交互节)。 --- ## 1. 为什么需要这一层 WE 的 web 类壁纸**不走 WebGL**:它就是一张网页,作者脚本在加载时调用 WE 的宿主 API: ```js window.wallpaperPropertyListener = { applyUserProperties(p) { … }, setPaused(v) { … } }; window.wallpaperRegisterAudioListener((bands) => { … }); // 音频频谱 window.wallpaperRequestRandomFileForProperty('file', (n, p) => { … }); // slideshow ``` 官方 CEF 宿主在**作者脚本之前**就把这些函数做成原生实现。插件此前只是把入口 HTML 塞进裸 iframe(`lib/client.js` 的 `showWebEl`)——没有任何 WE API,于是所有依赖属性/音频/媒体回调的 网页壁纸只能显示静态外壳。本层补的就是:**沙箱 iframe + 在作者脚本之前注入 shim + 控制通道**。 --- ## 2. 类型判定(内容优先,声明只作线索) 判定实现在 `lib/web-wallpaper.js` 的 `detectWebWallpaperKind` / `detectWallpaperDir`; `lib/index.js` 的 `/custom-dir` 扫描直接调用它(**不再**先信 `project.json` 的 `general.type`)。 四态:`web` / `scene` / `video` / `unknown`。判定顺序(首个命中即返回,顺序本身即规格): | # | 条件(按内容) | 结果 | `reason` | |---|---|---|---| | 1 | 声明 `application` / `exe` / `app` | `unknown`(**绝不放行**) | `excluded-application` | | 2 | 有 `scene.pkg` / `scene.json` / `*.pkg` / `*.mpkg` | `scene` | `scene-container` / `scene-json` | | 3 | 声明 `video` 且目录里真有视频文件 | `video` | `declared-video` | | 4 | 有 HTML 入口:`general.file` 指向的 html > 根 `index.html`/`index.htm`/`index.xhtml` > 最浅的 html | `web` | `declared-html` / `html-entry` | | 5 | 有视频文件(`mp4/webm/mov/m4v/mkv`;多个候选取体积最大) | `video` | `video-file` | | 6 | 只剩声明、内容一条都对不上 | `unknown` | `declared-unmatched` | | 7 | 什么都没有 | `unknown` | `no-files` / `no-content` | **「声明与内容不符」是这套判定的主要目的**(既有教训:只信 `project.json` 会被骗): | 用例 | 声明 | 目录内容 | 判定 | `mismatch` | |---|---|---|---|---| | 谎报 web 的场景包 | `type: "web"` | `scene.pkg` + `preview.jpg` | `scene` | ✅ true | | 谎报 scene 的网页 | `type: "scene"`,`file: "scene.pkg"` | 只有 `index.html` | `web` | ✅ true | | 谎报 video | `type: "video"`,`file` 缺失 | 只有 `index.html` | `web` | ✅ true | | 声明 web 但空壳 | `type: "web"` | 无 html/scene/视频 | `unknown` | ✅ true | | html + mp4 并存 | `type: "video"` | `index.html` + `bg.mp4` | `video`(信声明) | false | | html + mp4 并存 | 无 `project.json` | 同上 | `web`(html 是入口) | false | `mismatch` 只作诊断/日志用(`/custom-dir` 返回项的 `kindReason`、`declaredType` 字段), 不改变挂载行为——挂载永远按 `kind` 走。 --- ## 3. iframe 与 sandbox 策略 | 通道 | 何时使用 | `sandbox` 属性 | 帧的源 | 后果 | |---|---|---|---|---| | **网页 shim(默认)** | 入口 URL 带 `?mpwshim=1`(插件对网页壁纸默认加) | `allow-scripts` | **不透明源**(opaque origin) | 作者脚本读不到宿主 DOM / `localStorage`;父页也读不到帧内 DOM(静音/倍速/暂停由 shim 在帧内执行)。**①(WP-1)** 帧内 `localStorage`/`sessionStorage` 由 shim 给 facade(内存 + 宿主 `/web-store` 持久化,见 §5.4)——**这不是放宽 sandbox**:facade 读写的是"这张壁纸自己的键值",宿主按 wallId 隔离 | | **兼容模式** | 用户在确认弹窗选「兼容模式(同源)」 | `allow-scripts allow-same-origin allow-pointer-lock` | 与宿主同源(等价于改动前的裸 iframe) | Live2D 类壁纸的「网页壁纸选项」(写 iframe 同源 `localStorage`)可用;代价是作者脚本与 DSH 界面同源。**此档不注入 shim** ⇒ 作者直接用浏览器真 storage(facade 只在真 storage 不可用时才装,见 §5.4) | | 场景渲染器(不在本项范围) | 渲染器 `:8899` URL(`sandbox=strict` 与否) | `allow-scripts allow-pointer-lock` / 旧档 | 渲染器自己的源 | 见 `../docs/COPYING-RULES.md` 与 B6 契约 | ### 3.1 为什么 `allow-scripts` 必须保留、`allow-same-origin` 必须去掉 - **`allow-scripts` 必须保留**:网页壁纸的全部内容都是 JS 驱动的(` ← ① 先 ← ② 再(策略/入口信息) …作者原有的 //` 一律转义成 `<\/script>`,不会提前闭合宿主标签。 - **不做 `` 注入**:入口用**路径式** URL(`/custom-folder/<目录>/index.html`), 相对资源(`./assets/a.js`)天然按同目录解析;查询串只挂在入口请求上。 - **超限跳过**:入口 HTML > 8MB 时原样返回(不把大文件读进内存;这类入口极罕见)。 - **失败回退**:注入过程抛错只记 `console.warn` 并原样返回文件,绝不 500(壁纸不能白屏)。 - 无标记(旧 URL / 兼容模式)的请求**一个字节都不变** ⇒ 旧行为零回归。 --- ## 5. shim API 表(`window.*`)与控制协议 ### 5.1 对作者脚本暴露的 WE API | API | 形态 | 说明 / 与官方的差异 | |---|---|---| | `wallpaperPropertyListener` | getter/setter | 赋值只登记,回调**延后到微任务**再发(官方不在赋值当下回调;同步回调会重入作者渲染函数,React 类壁纸会熔断成白屏)。重复赋值不重放,避免「赋值→回调→再赋值」自激 | | `wallpaperRegisterAudioListener` | `(cb)=>void` | 父页推来的频谱数组;**暂停期间不下发**(对齐官方"冻结渲染进程"语义) | | `wallpaperRegisterMediaPropertiesListener` | `(cb)=>void` | 曲目信息;**晚注册回放最近一帧** | | `wallpaperRegisterMediaThumbnailListener` | `(cb)=>void` | 封面缩略图 + 主色调 | | `wallpaperRegisterMediaPlaybackListener` | `(cb)=>void` | 播放状态(0/1/2,见 `wallpaperMediaIntegration`) | | `wallpaperRegisterMediaTimelineListener` | `(cb)=>void` | 进度(position/duration) | | `wallpaperRegisterMediaStatusListener` | `(cb)=>void` | 媒体会话是否可用 | | `wallpaperRequestRandomFileForProperty` | `(prop, cb)=>void` | slideshow 随机文件;**池为空时回调空串**(作者侧普遍 `if (p)` 守卫) | | `wallpaperMediaIntegration` | 对象 | `PLAYBACK_STOPPED=0 / PLAYBACK_PLAYING=1 / PLAYBACK_PAUSED=2`(缺省会让 `PLAYING \|\| 0` 把"播放"误判成 0) | | `wallpaperPluginListener` | 对象 | `{ onPluginLoaded(){} }` 空实现(iCUE 类硬件灯效;避免作者 `if` 判断崩) | | `localStorage` / `sessionStorage`(**宿主提供的 facade**) | 对象 | **①(WP-1)** 不透明源下浏览器对这两个属性的**访问**就抛 `SecurityError`(不是返回 null)⇒ 作者脚本一句 `localStorage.getItem()` 就把初始化打断。真 storage 可用时**一个字节都不动**;不可用时才装 facade:同步内存语义 + 宿主 `/web-store` 异步落盘(§5.4)、条数/单值/总字节三重上限(超限抛 `QuotaExceededError`,与真 Storage 同形)。`?mpwstore=0` 关(退回"访问即抛"的旧行为),`?mpwstore=mem` 只内存不落盘 | 另外实现两个**非 WE 官方**的内部钩子:`window.__mpwRewriteFileUrl`(文件 URL 改写,见 §6)、 `window.__mpwWebControl`(控制面入口,与 `postMessage` 同一条处理路径)、 `window.__mpwWebStore`(**①WP-1**:存储 facade 的诊断面 `{installed, persist, session, disabled, snapshot(), flush()}`)、 `window.__mpwWebAudio`(**①WP-1**:主音量诊断面 `{master(), set(v), hooked()}`)。 **不提供 `$media*`**:`$mediaThumbnail` / `$mediaProperties` 等是**场景脚本**(scene 的 `script.js`) 的接口;网页壁纸没有这套全局对象,官方 web 宿主也只给上面的 `wallpaperRegisterMedia*Listener` 回调形态。 ### 5.2 父页 → 帧内控制协议(`postMessage`,`{ mpw: "mpw:web", op }`) | `op` | 载荷 | 作用 | |---|---|---| | `props` | `props: {name:{value}}` | 合并进属性表并回调 `applyUserProperties`(全量快照) | | `general` | `general: {fps}` | 回调 `applyGeneralProperties` | | `pause` | `value: boolean` | 回调 `setPaused` + 冻结/解冻帧内 media | | `policy` | `muted, speed`(**①WP-1 新增** `volume`) | 帧内静音 / 倍速 / **主音量**(沙箱下父页读不到帧内 DOM,只能这样下达)。`volume` **不传 = 完全不接管**作者音量(连原型钩子都不装,默认路径零回归) | | `audio` | `bands: number[]` | 转交 `wallpaperRegisterAudioListener`(暂停期间丢弃) | | `media` | `payload: {op,…}` | 转交对应媒体监听器 | | `directory` / `directory-remove` | `prop, files[]` | 维护 slideshow 文件池并回调 `userDirectoryFilesAddedOrChanged` / `…Removed` | | `ping` | — | 握手:回 `pong` | | `pointer` | `x,y,inside,buttons,mods` | 交互注入:按命中元素合成 pointer/mouse 事件(`button:-1` 哨兵) | | `wheel` | `x,y,dx,dy,mode,mods` | 交互注入:同时发现代 `wheel` 与 legacy `mousewheel`(wheelDelta 与 deltaY 反号) | | `key` | `down,key,code,keyCode,mods,text,repeat,composing` | 交互注入:键盘 +(可输入元素上)文本输入 | | `blur` | — | 失焦/离开:补一次 up + 完整 leave 链(作者 hover/按下态复位) | | `interact` | `on` | 交互模式开关(诊断/复位用;事件是否注入由父页决定) | 帧内 → 父页:`ready`(shim 装好即报到)、`pong`、`load`、`error`(作者脚本异常:`kind` + `message` + `stack`)。 父页只接受 `ev.source === frame.contentWindow` 的消息(任意页面不能控制壁纸);帧内只接受父窗口消息。 ### 5.3 与既有插件能力的接线(复用,未新造轮子) | 插件已有能力 | 接到 shim 的方式 | |---|---| | 「静音」开关 / 「视频倍速」 | URL 查询串(`mpwmute` / `mpwspeed`)→ 首帧即生效;改动经 `policy` 下发 | | 「暂停壁纸」/ 省电三档 | `pause`(`pauseWebFrame`/`resumeWebFrame` 同时保留同源路径的旧行为) | | 「可调参数」(`propEdits`) | 挂载时下发 `project.json` 的 `general.properties` 全量默认值 + 该壁纸已保存的编辑;编辑时增量下发(`setProp`) | | 本地库/自定义目录扫描 | 条目仍带 `type: "web"`,但类型由 `detectWallpaperDir` 按内容给出;`webHeavy` / `webExternal` 预检保留 | | 目录清单(新增虚拟文件) | `__mpw-list.json`:该壁纸目录内的媒体文件表 → slideshow 池 | 音频频谱(`audio`)目前**没有数据源**:插件没有"把当前壁纸音频解码成频谱"的能力(场景侧只有音轨 扫描,不是实时频谱)。shim 通道已打通,接上真实频谱源即可用;当前作者调用 `wallpaperRegisterAudioListener` 不会收到数据(不报错、不崩)。 ### 5.4 ①(WP-1) 帧内存储 facade + 宿主 `/web-store` 契约 ``` 作者脚本 帧内 facade(shim) 宿主路由 localStorage.getItem(k) ← 同步读内存(首帧由种子回灌) localStorage.setItem(k,v) → 内存立即生效 → 400ms debounce → POST /api/mpkg-wallpaper/web-store body: {w:, k, v} 或 {w, del:k} 正文 text/plain(CORS 简单请求,不触发预检) ``` | 项 | 契约 | |---|---| | 谁提供 | **shim 装 facade** 只在"真 `localStorage` 不可用"(不透明源 ⇒ 访问抛 `SecurityError`)时;真能用就一个字节都不动(同源测试台/兼容模式) | | 隔离 | `w` = `sha1(label + 目录键 + 入口相对路径)` 前 12 位(`webWallId`);**不含绝对路径**,不同壁纸互不可见(K8 断言) | | 上限 | 单值 ≤ 4096 字符、单壁纸 ≤ 64 键 / 64 KiB、宿主最多保留 64 张壁纸(最旧淘汰)。超限时帧内抛 `QuotaExceededError`(与真 Storage 同形),宿主再拒一次(`{ok:false,error:"too large"}`) | | 持久化时机 | 写入 400ms debounce 后异步落盘到 `DATA_DIR/web-store.json`;**内存是权威**(落盘失败只 warn,不回灌作者) | | 首帧可用 | 宿主服务入口 HTML 时把该 wallId 的已存快照塞进种子脚本 `store.snap` ⇒ 作者首帧 `getItem` 就能命中(K9 断言) | | 关闭 | `?mpwstore=0`:种子写 `store:false`、**不带 wall**、帧内不装 facade(= 改动前"访问即抛"的行为);`?mpwstore=mem`:装 facade 但不落盘 | | 安全边界 | 不放宽 sandbox(仍 `allow-scripts`);facade 读写的是壁纸自己的键值,够不到宿主 `localStorage`/DSH 界面;键名不含宿主路径 | --- ## 6. 文件 URL 改写 官方 CEF 以文件系统为源,作者常写 `'file:///' + value`(`file:///files/x.png`)。在 HTTP 路由下 这些 URL 必然 404,所以 shim 在以下位置改写: - `Element.prototype.setAttribute`(`src` / `href` / `poster`) - `HTMLImageElement` / `HTMLMediaElement` / `HTMLSourceElement` / `HTMLScriptElement` 的 `src` setter - `CSSStyleDeclaration.prototype.setProperty` 与 `HTMLElement.prototype.style`(`background*` 走 Proxy) **①(WP-1) 宿主的源级改写**(补运行时钩子够不到的形态):`rewriteWebEntryHtml` 在注入前对 HTML 文本做一次 `rewriteWebFileUrlsInHtml` —— 只改 `src|href|poster="file:///…"` 与 `url(file:///…)` 两类(**不碰 `