From 865b906a833313d9d14c468ce8fb4a3e3f72a953 Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Tue, 28 Jul 2026 01:07:09 +0800 Subject: [PATCH 1/2] feat(runtime): add cross-platform MP4 video backgrounds Support optional theme.json video fields, loopback media serving, menu/tray import, poster fallback, and renderer handoff on both platforms. Closes #240. --- README.en.md | 22 + README.md | 31 + macos/CHANGELOG.md | 6 + macos/assets/dream-skin.css | 132 +++ macos/assets/renderer-inject.js | 482 +++++++++- macos/assets/theme-package-validator.mjs | 49 +- .../CodexDreamSkinMenuBar/AppDelegate.swift | 18 + macos/scripts/build-menubar-app.sh | 1 + macos/scripts/extract-theme-zip-macos.sh | 7 +- macos/scripts/injector.mjs | 180 +++- macos/scripts/load-image-theme-macos.sh | 91 +- macos/scripts/media-server.mjs | 211 +++++ macos/scripts/publish-theme-import.mjs | 23 +- macos/scripts/stage-theme.mjs | 40 +- macos/scripts/switch-theme-macos.sh | 6 +- macos/scripts/theme-content-fingerprint.mjs | 4 +- macos/scripts/write-theme.mjs | 29 +- macos/tests/injector-bootstrap.test.mjs | 14 + macos/tests/media-server.test.mjs | 82 ++ .../tests/payload-template-integrity.test.mjs | 5 +- macos/tests/theme-package-validator.test.mjs | 61 +- macos/tests/theme-stage.test.mjs | 66 +- runtime/dream-skin.css | 132 +++ runtime/renderer-inject.js | 482 +++++++++- runtime/theme-package-validator.mjs | 49 +- tools/renderer-runtime.test.mjs | 253 +++++- windows/CHANGELOG.md | 6 + windows/README.md | 19 + windows/assets/dream-skin.css | 132 +++ windows/assets/renderer-inject.js | 482 +++++++++- windows/assets/theme-package-validator.mjs | 49 +- windows/installer/build-release.ps1 | 1 + windows/installer/setup-bootstrap.ps1 | 1 + windows/scripts/apply-community-theme.ps1 | 12 +- windows/scripts/common-windows.ps1 | 52 +- windows/scripts/injector.mjs | 852 ++++++++++++++++-- windows/scripts/media-server.mjs | 211 +++++ windows/scripts/start-dream-skin.ps1 | 2 +- windows/scripts/theme-windows.ps1 | 136 ++- windows/scripts/tray-dream-skin.ps1 | 100 +- windows/tests/injector-bootstrap.test.mjs | 80 +- .../tests/injector-window-readiness.test.mjs | 70 +- windows/tests/media-server.test.mjs | 91 ++ windows/tests/run-tests.ps1 | 57 ++ 44 files changed, 4622 insertions(+), 207 deletions(-) create mode 100644 macos/scripts/media-server.mjs create mode 100644 macos/tests/media-server.test.mjs create mode 100644 windows/scripts/media-server.mjs create mode 100644 windows/tests/media-server.test.mjs diff --git a/README.en.md b/README.en.md index 9b3d3f9d..cf003025 100644 --- a/README.en.md +++ b/README.en.md @@ -214,6 +214,28 @@ compatibility plus every declared payload file's byte length and SHA-256. registered parts. It is revalidated on every import and apply. `manifest.sig` is not used for signature verification. +### Import an MP4 video background + +You can also choose a video directly from the platform menu without making a ZIP first: + +- macOS: open the menu-bar app and choose **一键更换视频背景…**. +- Windows: open the system-tray menu and choose **一键更换视频背景**. +- Select an `.mp4` file in the picker and confirm. Dream Skin creates and applies a video theme. + +A video theme still requires a static image for its poster and failure fallback. The video is read only through the controlled local loopback path and is not uploaded to an external service. The file must be a non-empty MP4 no larger than 100 MiB. If playback fails, the page becomes hidden, or the theme changes, Dream Skin pauses and cleans up the video and restores the image. + +You can also create or import a video theme manually. In `theme.json`, `video` must be a filename inside the theme directory; absolute paths and traversal are not accepted: + +```json +{ + "schemaVersion": 1, + "image": "background.jpg", + "video": "background.mp4" +} +``` + +Place `background.jpg` (or another supported image) and `background.mp4` next to `theme.json` and `theme.css`, either in a theme directory or in a ZIP, then choose **Import Theme ZIP…**. A manually placed theme must also contain both the image and video files. Do not use symbolic links, junctions, or files outside the theme directory. After import, the theme is added to **Saved Themes** and can be selected from the menu or tray. + The local simplified ZIP must contain exactly non-empty `theme.json`, non-empty `theme.css`, and its referenced image. That format has no official manifest integrity or compatibility declaration and should come from a trusted diff --git a/README.md b/README.md index b0552712..bf96ae56 100644 --- a/README.md +++ b/README.md @@ -184,8 +184,39 @@ macOS 菜单栏和 Windows 托盘都有「主题库 Gallery」和「在线 Studi 版本,以及清单中每个负载文件的大小和 SHA-256。`theme.css` 必须通过本机 Safe CSS 校验,导入后只会 作用于 12 个注册部件;每次切换/应用仍会重新校验。`manifest.sig` 当前不参与签名验证。 +### 导入 MP4 视频背景 + +成功率最高的方式是直接从平台菜单选择视频,不要先把大视频打进主题 ZIP: + +1. 先从 `Codex Dream Skin` 快捷方式启动 Codex,并保持菜单栏应用(macOS)或系统托盘(Windows)运行。 +2. macOS 选择“一键更换视频背景…”;Windows 选择“一键更换视频背景”。 +3. 在文件选择器中选择真实的 `.mp4` 文件。文件必须非空、不超过 100 MiB,并包含有效的 MP4 `ftyp` + 容器头;只把其他格式改成 `.mp4` 后缀会被拒绝。为兼容 Codex 使用的 Chromium,建议使用 + H.264/AVC 视频和 AAC 音频,或直接移除音轨。 +4. 如果 Codex 是普通方式启动、还没有 Dream Skin 会话,首次应用可能要求重启一次;确认后等待 + Dream Skin 重新启动并验证视频。会话正常后,再切换图片、视频或已保存主题不需要反复重启。 + +视频背景仍需要静态图片作为 poster 和失败回退。视频只在本机受控回环通道中读取,不会上传到外部服务; +直接选择视频时会沿用当前主题图片作为 poster。只有真实 Codex 渲染器确认视频已经可播放,热切换才会报告 +成功;验证失败会恢复切换前的主题。播放失败、页面不可见或切换主题时,会自动暂停或清理旧视频。 + +也可以手动制作或导入包含视频的主题。`theme.json` 使用主题目录内的文件名,不接受绝对路径或路径穿越: + +```json +{ + "schemaVersion": 1, + "image": "background.jpg", + "video": "background.mp4" +} +``` + +将 `background.jpg`(或其他受支持图片)和 `background.mp4` 与 `theme.json`、`theme.css` 放在主题目录或 ZIP 中, +再通过“导入主题 ZIP…”导入。手动主题目录也必须包含图片和视频文件;不要放入符号链接、链接目录或其他 +主题目录之外的文件。导入成功后主题会加入“已保存的主题”,再从菜单/托盘切换。 + 本地简化 ZIP 也必须恰好包含非空 `theme.json`、非空 `theme.css` 和其引用图片;该格式没有正式清单的 完整性与兼容性声明,只应从可信来源使用。压缩包最大 32 MiB、最多 32 个条目、解压后最多 64 MiB。 +这些限制包含 `background.mp4`;例如 76 MiB 视频不能通过 ZIP 导入,应使用上面的菜单直接选择方式。 导入成功后主题只会加入“已保存的主题”,不会自动替换当前主题;相同内容不会重复写入,同 ID 的不同 主题会使用新的安全标识保存。 diff --git a/macos/CHANGELOG.md b/macos/CHANGELOG.md index 6f9d123e..efb7aee5 100644 --- a/macos/CHANGELOG.md +++ b/macos/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 未发布 + +### 功能 + +- 支持将本机 MP4 设为动态视频背景:菜单栏新增「一键更换视频背景…」,主题包 `theme.json` 可选 `video` 字段;视频经本机回环媒体服务传输(`127.0.0.1`、一次性 token、Range/`206`),图片仍作 poster 与失败回退。导入校验要求真实 `ftyp` 容器头、非空且不超过 100 MiB;热切换仅在渲染器确认可播放后提交,失败则恢复切换前主题。 + ## 1.5.6 — 2026-07-26 ### 安全 diff --git a/macos/assets/dream-skin.css b/macos/assets/dream-skin.css index bd112ec0..a60f2c15 100644 --- a/macos/assets/dream-skin.css +++ b/macos/assets/dream-skin.css @@ -1212,3 +1212,135 @@ html[data-dream-skin="active"] [data-message-author-role] { border-radius: 16px; animation-iteration-count: 1 !important; } } + +/* Optional media layer. It is inert for image themes and spans the complete + Codex viewport when a validated MP4 URL is supplied by the injector. */ +#codex-dream-skin-background-stage { + position: fixed; + inset: 0; + z-index: 0; + overflow: hidden; + pointer-events: none; + background: transparent !important; + visibility: hidden; +} + +#codex-dream-skin-background-stage::before { + content: ""; + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; + background-image: var(--ds-task-fade), var(--ds-task-shade); + background-repeat: no-repeat; + background-position: center, center; + background-size: 100% 100%, 100% 100%; +} + +#codex-dream-skin-background-stage img, +#codex-dream-skin-background-stage video { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: var(--ds-art-position, 50% 50%); + pointer-events: none; +} + +#codex-dream-skin-background-stage img { z-index: 1; } +#codex-dream-skin-background-stage video { + z-index: 1; + opacity: 0; +} + +html[data-dream-skin="active"][data-dream-media="video"] + #codex-dream-skin-background-stage { + visibility: visible; +} + +html[data-dream-skin="active"][data-dream-media="video"] + #codex-dream-skin-background-stage video { + opacity: 1; +} + +html[data-dream-skin="active"][data-dream-media="video"] + #codex-dream-skin-background-stage::before { + opacity: .46; +} + +html[data-dream-skin="active"][data-dream-media="video"] + #codex-dream-skin-background-stage video { + filter: brightness(1.15) saturate(1.04); +} + +html[data-dream-skin="active"][data-dream-media="video"] + #codex-dream-skin-background-stage img { + display: none !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] body { + background: transparent !important; + background-image: none !important; +} + +/* The image rules above are intentionally kept for image themes. Once the + video has its first frame, close every static-art entry point explicitly so + a Codex route cannot leave a second copy of the imported image visible. */ +html[data-dream-skin="active"][data-dream-media="video"] + main.main-surface::before { + content: none !important; + background-image: none !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] + [role="main"]:has([data-testid="home-icon"]) > div:first-child > div:first-child > div:first-child { + background-image: none !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] body > #root { + position: relative !important; + /* Keep every native task/composer surface above the full-window media stage. + The explicit stacking context also covers Codex's new-task route, whose + transparent wrappers can otherwise participate in the body's auto layer. */ + z-index: 10 !important; + isolation: isolate; + background: transparent !important; +} + +/* Some Codex builds mount dialogs and popovers directly under body instead of + inside #root. Keep those native surfaces above the media stage as well. */ +html[data-dream-skin="active"][data-dream-media="video"] body > + :is([role="dialog"], [aria-modal="true"], [data-radix-portal], + [data-radix-popper-content-wrapper]) { + z-index: 50 !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] + body > #root aside.app-shell-left-panel { + background: + linear-gradient(180deg, + rgb(var(--ds-panel-rgb) / .38), + rgb(var(--ds-bg-rgb) / .28)) !important; + backdrop-filter: blur(8px) saturate(112%) !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] main.main-surface { + background: transparent !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] + main.main-surface::before { + content: none !important; +} + +/* Wide image themes also paint artwork directly on body. In video mode the + video stage is the sole artwork source; keep the old body image only for + the image fallback path. */ +html[data-dream-skin="active"][data-dream-media="video"]:is([data-dream-task-mode="ambient"], [data-dream-art-task-mode="ambient"], [data-dream-task-mode="banner"], [data-dream-art-task-mode="banner"])[data-dream-art-wide="true"]:has(main.main-surface):not(:has(main.main-surface [role="main"])) + body, +html[data-dream-skin="active"][data-dream-media="video"][data-dream-art-wide="true"]:has(main.main-surface [role="main"]) + body { + background-image: none !important; +} diff --git a/macos/assets/renderer-inject.js b/macos/assets/renderer-inject.js index be799888..3eb39df7 100644 --- a/macos/assets/renderer-inject.js +++ b/macos/assets/renderer-inject.js @@ -1,17 +1,19 @@ // Canonical cross-platform renderer. Run tools/sync-runtime-assets.mjs after editing. -((cssText, artDataUrl, themeConfig) => { +((cssText, artDataUrl, themeConfig, videoConfig) => { const SELECTOR_CONTRACT = {"schema":"codex-dream-skin-selectors/1","selectors":[{"key":"shell-main","selector":"main.main-surface","tier":"L1","scope":"all","required":true},{"key":"left-panel","selector":"aside.app-shell-left-panel","tier":"L1","scope":"all","required":true},{"key":"header-tint","selector":"header.app-header-tint","tier":"L1","scope":"all","required":true},{"key":"home-icon","selector":"[data-testid=\"home-icon\"]","tier":"L1","scope":"home","required":true},{"key":"home-route","selector":"[role=\"main\"]:has([data-testid=\"home-icon\"])","tier":"L1","scope":"home","required":true},{"key":"home-route-css","selector":"[role=\"main\"]","tier":"L1","scope":"home","required":true},{"key":"home-banners","selector":".home-banners","tier":"L2","scope":"home","required":false},{"key":"composer-chrome","selector":".composer-surface-chrome","tier":"L2","scope":"home+thread","required":false},{"key":"composer-toolbar","selector":".composer-surface-chrome [class*=\"_footer_\"]","tier":"L2","scope":"home+thread","required":false},{"key":"home-utility","selector":"[class*=\"_homeUtilityBar_\"]","tier":"L2","scope":"home","required":false},{"key":"game-source","selector":"[data-feature=\"game-source\"]","tier":"L2","scope":"home","required":false},{"key":"home-suggestions","selector":".group\\/home-suggestions","tier":"L2","scope":"home","required":false},{"key":"project-selector","selector":".group\\/project-selector","tier":"L2","scope":"home config","required":false},{"key":"markdown","selector":"[class*=\"_markdown\"]","tier":"L2","scope":"thread","required":false},{"key":"thread-surface","selector":".thread-scroll-container","tier":"L2","scope":"thread","required":false},{"key":"message","selector":"[data-message-author-role]","tier":"L2","scope":"thread","required":false},{"key":"appearance-radio","selector":"input[name=\"appearance-theme\"]","tier":"L2","scope":"settings","required":false},{"key":"overlay-menu","selector":"[role=\"menu\"]","tier":"L2","scope":"overlay","required":false},{"key":"overlay-dialog","selector":"[role=\"dialog\"]","tier":"L2","scope":"overlay","required":false},{"key":"overlay-popper","selector":"[data-radix-popper-content-wrapper]","tier":"L2","scope":"overlay","required":false}],"stableTestids":["app-shell-header-context-menu-surface","home-icon","theme-preview"]}; const STATE_KEY = "__CODEX_DREAM_SKIN_STATE__"; const DISABLED_KEY = "__CODEX_DREAM_SKIN_DISABLED__"; const STYLE_REGISTRY_KEY = "__CODEX_DREAM_SKIN_STYLE_SHEETS__"; const STYLE_ID = "codex-dream-skin-style"; + const BACKGROUND_STAGE_ID = "codex-dream-skin-background-stage"; + const VIDEO_INPUT_ID = "codex-dream-skin-video-input"; const SHELL_ATTR = "data-dream-shell"; const PART_ATTR = "data-ds-part"; const ROOT_ATTRS = [ "data-dream-skin", SHELL_ATTR, "data-dream-art-wide", "data-dream-art-safe", "data-dream-task-mode", "data-dream-art-safe-area", "data-dream-art-task-mode", "data-dream-art-aspect", - "data-dream-art-ready", + "data-dream-art-ready", "data-dream-media", "data-dream-video-ready", ]; const VERSION = __DREAM_SKIN_VERSION_JSON__; const STYLE_REVISION = __DREAM_SKIN_STYLE_REVISION_JSON__; @@ -54,6 +56,23 @@ typeof existingAnalysisCache.set === "function" ? existingAnalysisCache : new Map(); window[ANALYSIS_CACHE_KEY] = analysisCache; let artAnalysis = typeof THEME.artKey === "string" ? analysisCache.get(THEME.artKey) ?? null : null; + const normalizedVideoConfig = typeof videoConfig === "string" + ? { mode: "server", url: videoConfig } + : videoConfig && typeof videoConfig === "object" ? videoConfig : null; + const videoUrl = typeof normalizedVideoConfig?.url === "string" + ? normalizedVideoConfig.url : null; + const videoMode = normalizedVideoConfig?.mode === "blob" ? "blob" + : /^https?:\/\//i.test(videoUrl || "") ? "server" : null; + const videoSource = videoMode === "server" ? videoUrl : null; + const videoFallbackSource = typeof normalizedVideoConfig?.fallbackUrl === "string" && + normalizedVideoConfig.fallbackUrl ? normalizedVideoConfig.fallbackUrl : null; + const videoEnabled = videoMode === "blob" || Boolean(videoSource); + let backgroundState = null; + let handedOffVideo = null; + let videoGeneration = 0; + let videoFailed = false; + let videoReady = false; + let videoError = null; let analysisTimer = null; let rootObserver = null; let partObserver = null; @@ -61,6 +80,7 @@ let styleMode = null; let styleNode = null; let styleSheet = null; + const mediaTransitions = []; const now = () => typeof performance === "object" && typeof performance.now === "function" ? performance.now() : Date.now(); const metrics = { @@ -82,6 +102,9 @@ }; const previous = window[STATE_KEY]; + const previousHandoff = videoEnabled && typeof previous?.handoffVideo === "function" + ? previous.handoffVideo() : null; + if (previousHandoff) handedOffVideo = { ...previousHandoff, preserve: true }; if (typeof previous?.cleanup === "function") previous.cleanup(); window[DISABLED_KEY] = false; @@ -353,6 +376,411 @@ setStyleProperty(root, "--ds-theme-image-focus-y", String(Number(focusY.toFixed(4)))); }; + const detachBackgroundVideoListeners = (state) => { + if (!state) return; + if (state.retryTimer) { + clearTimeout(state.retryTimer); + state.retryTimer = null; + } + try { state.video.removeEventListener("error", state.onError); } catch {} + try { state.video.removeEventListener("loadeddata", state.onReady); } catch {} + try { state.video.removeEventListener("playing", state.onReady); } catch {} + try { state.video.removeEventListener("ended", state.onEnded); } catch {} + try { state.video.removeEventListener("stalled", state.onStalled); } catch {} + try { state.video.removeEventListener("abort", state.onAbort); } catch {} + try { state.video.removeEventListener("emptied", state.onEmptied); } catch {} + try { state.video.removeEventListener("timeupdate", state.onTimeUpdate); } catch {} + try { document.removeEventListener("visibilitychange", state.onVisibility); } catch {} + }; + + const removeBackgroundStage = () => { + const state = backgroundState; + backgroundState = null; + if (!state) { + document.getElementById(BACKGROUND_STAGE_ID)?.remove(); + if (handedOffVideo) { + try { handedOffVideo.video.pause(); handedOffVideo.video.remove(); } catch {} + if (handedOffVideo.objectUrl) { + try { URL.revokeObjectURL(handedOffVideo.objectUrl); } catch {} + } + handedOffVideo = null; + } + return; + } + if (handedOffVideo?.video === state.video) { + // Preserve only the decoded frame and object URL. The superseded runtime + // must not keep visibility/media listeners that can restart this node. + detachBackgroundVideoListeners(state); + handedOffVideo = null; + return; + } + try { state.video.pause(); } catch {} + detachBackgroundVideoListeners(state); + try { state.video.removeAttribute("src"); state.video.load(); } catch {} + if (state.objectUrl) { + try { URL.revokeObjectURL(state.objectUrl); } catch {} + } + state.stage.remove(); + if (handedOffVideo) { + try { handedOffVideo.video.pause(); handedOffVideo.video.remove(); } catch {} + if (handedOffVideo.objectUrl) { + try { URL.revokeObjectURL(handedOffVideo.objectUrl); } catch {} + } + handedOffVideo = null; + } + }; + + const captureVideoError = (error = null, video = backgroundState?.video) => { + videoError = { + name: error?.name || null, + message: error?.message || null, + mediaCode: video?.error?.code ?? null, + readyState: video?.readyState ?? null, + networkState: video?.networkState ?? null, + }; + }; + + const isCurrentVideoState = (state) => Boolean( + state && backgroundState === state && state.generation === videoGeneration, + ); + + const fallbackToImage = (error = null, expectedState = null) => { + if (expectedState && !isCurrentVideoState(expectedState)) return false; + if (videoFailed) return; + captureVideoError(error, expectedState?.video ?? backgroundState?.video); + videoFailed = true; + videoReady = false; + removeBackgroundStage(); + const root = document.documentElement; + if (root) applyRootState(root); + return true; + }; + + const revealVideo = (state) => { + if (!isCurrentVideoState(state) || videoFailed || videoReady) return; + // The replacement element stays inline-hidden while the verified previous + // video remains visible. Reveal it only after Chromium produced a frame. + try { state.video.style.opacity = ""; } catch {} + // The poster is useful only before the first decoded frame. Keeping it on + // a live element lets Chromium expose the static image again during a + // transient stall or buffer reset, which looks like image/video flashing. + try { state.video.removeAttribute("poster"); } catch {} + videoReady = true; + const root = document.documentElement; + if (root) applyRootState(root); + if (handedOffVideo) { + const oldVideo = handedOffVideo.video; + const oldObjectUrl = handedOffVideo.objectUrl; + handedOffVideo = null; + try { oldVideo.pause(); oldVideo.remove(); } catch {} + if (oldObjectUrl) { + try { URL.revokeObjectURL(oldObjectUrl); } catch {} + } + } + }; + + const isTransientBackgroundPause = (error, video) => { + const hidden = document.visibilityState === "hidden" || document.hidden; + if (!hidden) return false; + const message = String(error?.message || "").toLowerCase(); + if (error?.name === "AbortError" && /background media|save power|paused/.test(message)) return true; + // Chromium can emit an empty media error while the app target reports + // hidden, even though the loopback response is valid. Treat that event as + // transient and retry instead of exposing the fallback image. + if (!video?.error && !message) return true; + return !video?.error && /failed to fetch|network|load|media/.test(message); + }; + + const isTransientVideoError = (error, video) => { + if (isTransientBackgroundPause(error, video)) return true; + const code = video?.error?.code ?? null; + // MEDIA_ERR_ABORTED, MEDIA_ERR_NETWORK, and Chromium's empty error object + // are recoverable while a source is being replaced or the page is hidden. + return code === null || code === 1 || code === 2; + }; + + const retryTransientVideo = (state) => { + if (!state || videoFailed || !state.video.src || state.retryCount >= 20) return false; + if (document.visibilityState === "hidden" || document.hidden) { + // Chromium may suspend an already-valid media element for as long as + // the renderer stays hidden. Do not spend the finite retry budget while + // playback is intentionally unavailable; visibilitychange resumes it. + state.pendingPlay = true; + return true; + } + if (state.retryTimer) return true; + state.retryCount += 1; + state.retryTimer = setTimeout(() => { + state.retryTimer = null; + if (!isCurrentVideoState(state) || videoFailed || !state.video.src) return; + if (document.visibilityState === "hidden" || document.hidden) { + state.pendingPlay = true; + return; + } + Promise.resolve(state.video.play?.()).then(() => { + if (!isCurrentVideoState(state) || videoFailed) return; + state.pendingPlay = false; + revealVideo(state); + }).catch(state.onError); + }, 500); + return true; + }; + + const ensureVideoInput = () => { + if (videoMode !== "blob" || !document?.createElement) return null; + let input = document.getElementById(VIDEO_INPUT_ID); + if (input) return input; + input = document.createElement("input"); + input.type = "file"; + input.id = VIDEO_INPUT_ID; + input.accept = "video/mp4"; + input.tabIndex = -1; + input.setAttribute("aria-hidden", "true"); + Object.assign(input.style, { + position: "fixed", + width: "1px", + height: "1px", + opacity: "0", + pointerEvents: "none", + }); + (document.body || document.documentElement)?.appendChild(input); + return input; + }; + + const mediaTokenFromUrl = (source) => { + try { + const parsed = new URL(source); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + return /^\/media\/([a-f0-9]{32})$/i.exec(parsed.pathname)?.[1] ?? null; + } catch { + return null; + } + }; + + const playVideoObjectUrl = async (state, objectUrl) => { + if (!isCurrentVideoState(state) || videoFailed) return false; + if (state.objectUrl && state.objectUrl !== objectUrl) { + try { URL.revokeObjectURL(state.objectUrl); } catch {} + } + state.objectUrl = objectUrl; + state.video.src = objectUrl; + state.video.load(); + if (document.visibilityState === "hidden" || document.hidden) { + state.pendingPlay = true; + return true; + } + if (state.video.readyState < 1) { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error("Video metadata load timed out")); + }, 15000); + const cleanup = () => { + clearTimeout(timer); + state.video.removeEventListener("loadedmetadata", onMetadata); + state.video.removeEventListener("error", onError); + }; + const onMetadata = () => { cleanup(); resolve(); }; + const onError = () => { cleanup(); reject(new Error("Video metadata could not be loaded")); }; + state.video.addEventListener("loadedmetadata", onMetadata, { once: true }); + state.video.addEventListener("error", onError, { once: true }); + }); + } + await state.video.play(); + if (!isCurrentVideoState(state) || videoFailed) return false; + state.pendingPlay = false; + // play() is the fallback for shells that do not expose a usable + // loadeddata/playing event to the injected runtime. + revealVideo(state); + return true; + }; + + const loadVideoFromUrl = async (state, source) => { + if (!state || state.loading) return false; + state.loading = true; + try { + const token = mediaTokenFromUrl(source); + const headers = token ? { "X-Codex-Dream-Skin-Token": token } : undefined; + const response = await fetch(source, { credentials: "omit", headers }); + if (!response.ok) throw new Error("Video request failed: " + response.status); + const blob = await response.blob(); + if (!isCurrentVideoState(state) || videoFailed) return false; + return playVideoObjectUrl(state, URL.createObjectURL(blob)); + } finally { + state.loading = false; + } + }; + + const fallbackToServer = async (expectedState = null) => { + const state = expectedState ?? backgroundState; + if (!isCurrentVideoState(state) || !videoFallbackSource || state.fallbackStarted) return false; + state.fallbackStarted = true; + try { + return await loadVideoFromUrl(state, videoFallbackSource); + } catch (error) { + fallbackToImage(error, state); + return false; + } + }; + + const handleVideoFailure = (state, error = null) => { + if (!isCurrentVideoState(state) || videoFailed) return false; + captureVideoError(error, state.video); + if (isTransientVideoError(error, state.video) && retryTransientVideo(state)) { + state.pendingPlay = true; + return false; + } + if (videoFallbackSource && !state.fallbackStarted) { + void fallbackToServer(state); + return false; + } + return fallbackToImage(error, state); + }; + + const attachVideoFile = async () => { + if (videoMode !== "blob") return false; + const input = ensureVideoInput(); + ensureBackgroundStage(); + const state = backgroundState; + const file = input?.files?.[0]; + if (!state || !file) return false; + try { + await playVideoObjectUrl(state, URL.createObjectURL(file)); + applyRootState(document.documentElement); + return true; + } catch (error) { + return handleVideoFailure(state, error); + } + }; + + const ensureBackgroundStage = () => { + if (!videoEnabled || videoFailed || !document?.createElement) return; + ensureVideoInput(); + if (backgroundState?.stage?.parentElement) return; + const previousVideo = handedOffVideo?.video?.parentElement ? handedOffVideo.video : null; + const stage = handedOffVideo?.stage?.parentElement + ? handedOffVideo.stage + : document.getElementById(BACKGROUND_STAGE_ID) || document.createElement("div"); + stage.id = BACKGROUND_STAGE_ID; + // A failed or superseded handoff can leave an older video in the shared + // stage. Keep at most the one explicitly handed off by the immediately + // previous runtime; two playable nodes can alternate frames and posters. + for (const child of [...(stage.children || [])]) { + if (child === previousVideo) continue; + const staleSource = typeof child.src === "string" ? child.src : ""; + try { child.pause?.(); } catch {} + try { child.removeAttribute?.("src"); child.load?.(); } catch {} + try { child.remove?.(); } catch {} + if (staleSource.startsWith("blob:")) { + try { URL.revokeObjectURL(staleSource); } catch {} + } + } + if (!previousVideo) stage.replaceChildren?.(); + if (previousVideo && !stage.parentElement) { + const parent = document.body || document.documentElement; + if (!parent) return; + if (typeof parent.prepend === "function") parent.prepend(stage); + else parent.appendChild(stage); + } + // The video is a viewport background, not a main-surface decoration. Keep + // it under the app root so the sidebar, main content, and native overlays + // all share one continuous moving background. + const parent = stage.parentElement || document.body || document.documentElement; + if (!parent) return; + + const video = document.createElement("video"); + video.muted = true; + video.defaultMuted = true; + video.autoplay = true; + video.loop = true; + video.playsInline = true; + video.preload = "auto"; + video.poster = artUrl; + video.setAttribute("aria-hidden", "true"); + // `data-dream-media="video"` intentionally remains set during a ready + // handoff so CSS keeps the previous video visible. Prevent that selector + // from exposing the pending replacement or its poster before first frame. + video.style.opacity = "0"; + const onError = (error = null) => { + // A detached video can still deliver abort/error after a new theme has + // installed. Its event must never fail the newer media generation. + if (!isCurrentVideoState(state)) return; + handleVideoFailure(state, error); + }; + const onVisibility = () => { + if (videoFailed || document.visibilityState === "hidden" || document.hidden) return; + if (videoMode === "server" && !state.loading && !video.src) { + void loadVideoFromUrl(state, videoSource).catch(onError); + return; + } + if (video.src) { + Promise.resolve(video.play?.()).then(() => { + state.pendingPlay = false; + revealVideo(state); + }).catch(onError); + } + }; + const onReady = () => revealVideo(state); + const onEnded = () => { + if (videoFailed || backgroundState !== state || document.visibilityState === "hidden" || document.hidden) return; + state.pendingPlay = true; + try { video.currentTime = 0; } catch {} + Promise.resolve(video.play?.()).then(() => { + state.pendingPlay = false; + revealVideo(state); + }).catch(onError); + }; + const onStalled = () => { + if (videoFailed || !isCurrentVideoState(state)) return; + state.pendingPlay = true; + retryTransientVideo(state); + }; + const onAbort = () => { + if (videoFailed || !isCurrentVideoState(state)) return; + state.pendingPlay = true; + retryTransientVideo(state); + }; + const onEmptied = () => { + if (videoFailed || !isCurrentVideoState(state) || !video.src) return; + state.pendingPlay = true; + retryTransientVideo(state); + }; + const onTimeUpdate = () => { + if (videoFailed || !isCurrentVideoState(state)) return; + const currentTime = Number(video.currentTime); + if (!Number.isFinite(currentTime)) return; + if (currentTime < state.lastPlaybackTime || + currentTime - state.lastPlaybackTime >= 0.1) { + state.lastPlaybackTime = currentTime; + state.retryCount = 0; + state.pendingPlay = false; + } + }; + const state = { + stage, video, onError, onVisibility, onReady, onEnded, onStalled, onAbort, onEmptied, + onTimeUpdate, + generation: ++videoGeneration, + objectUrl: null, fallbackStarted: false, retryTimer: null, retryCount: 0, + loading: false, pendingPlay: false, lastPlaybackTime: -1, + }; + backgroundState = state; + video.addEventListener("error", onError); + video.addEventListener("loadeddata", onReady); + video.addEventListener("playing", onReady); + video.addEventListener("ended", onEnded); + video.addEventListener("stalled", onStalled); + video.addEventListener("abort", onAbort); + video.addEventListener("emptied", onEmptied); + video.addEventListener("timeupdate", onTimeUpdate); + document.addEventListener("visibilitychange", onVisibility); + stage.appendChild(video); + if (typeof parent.prepend === "function") parent.prepend(stage); + else parent.appendChild(stage); + if (videoMode === "server") { + void loadVideoFromUrl(backgroundState, videoSource).catch(onError); + } + }; + const analyzeArt = () => new Promise((resolve) => { const startedAt = now(); metrics.analysisRuns += 1; @@ -550,7 +978,28 @@ const shell = resolvedShell(); setAttribute(root, "data-dream-skin", "active"); setAttribute(root, SHELL_ATTR, shell); - setStyleProperty(root, "--dream-skin-art", `url("${artUrl}")`); + const mediaReady = videoEnabled && !videoFailed && videoReady; + const handoffReady = videoEnabled && !videoFailed && + handedOffVideo?.ready === true && Boolean(handedOffVideo.video?.parentElement); + const mediaState = mediaReady || handoffReady + ? "video" + : videoEnabled && !videoFailed ? "video-pending" : "image-fallback"; + const previousMedia = mediaTransitions[mediaTransitions.length - 1]; + if (!previousMedia || previousMedia.state !== mediaState || + previousMedia.videoReady !== videoReady) { + mediaTransitions.push({ + state: mediaState, + videoReady, + handoffReady, + at: Date.now(), + }); + if (mediaTransitions.length > 16) mediaTransitions.shift(); + } + setAttribute(root, "data-dream-media", mediaState); + setAttribute(root, "data-dream-video-ready", videoReady ? "true" : "false"); + // Keep the poster image visible until the video has produced a frame. This + // avoids a blank flash while a replacement media source is loading. + setStyleProperty(root, "--dream-skin-art", mediaState === "video" ? "none" : `url("${artUrl}")`); applyTheme(root, shell); applyArtMetadata(root); return shell; @@ -664,7 +1113,10 @@ const root = document.documentElement; if (!root) return; metrics.ensureCalls += 1; + // Commit the media mode before touching the stage. This prevents the + // previous body image from being painted during a video-theme swap. if (rootPass) applyRootState(root); + if (rootPass) ensureBackgroundStage(); if (partPass) refreshParts(); if (scopePass) refreshScope(); }; @@ -693,6 +1145,8 @@ if (state?.timer) clearInterval(state.timer); if (state?.scheduler?.timeout) clearTimeout(state.scheduler.timeout); if (analysisTimer) clearTimeout(analysisTimer); + removeBackgroundStage(); + document.getElementById(VIDEO_INPUT_ID)?.remove(); if (state?.mediaHandler && state?.mediaQuery) { try { state.mediaQuery.removeEventListener("change", state.mediaHandler); } catch {} } @@ -762,6 +1216,26 @@ navigation: navigationApi, navigationHandler, artUrl, + videoUrl: videoSource, + videoMode, + get videoFailed() { return videoFailed; }, + get videoReady() { return videoReady; }, + get videoError() { return videoError; }, + mediaTransitions, + ensureVideoInput, + attachVideoFile, + useVideoFallback: fallbackToServer, + handoffVideo: () => { + const state = backgroundState; + if (!state?.stage?.parentElement || !state.video?.parentElement) return null; + handedOffVideo = { + stage: state.stage, + video: state.video, + objectUrl: state.objectUrl, + ready: videoReady && !videoFailed, + }; + return { ...handedOffVideo }; + }, installToken, styleMode, styleNode, @@ -842,4 +1316,4 @@ styleMode, analysis: artAnalysis, }; -})(__DREAM_SKIN_CSS_JSON__, __DREAM_SKIN_ART_JSON__, __DREAM_SKIN_THEME_JSON__) +})(__DREAM_SKIN_CSS_JSON__, __DREAM_SKIN_ART_JSON__, __DREAM_SKIN_THEME_JSON__, __DREAM_SKIN_VIDEO_JSON__) diff --git a/macos/assets/theme-package-validator.mjs b/macos/assets/theme-package-validator.mjs index 275fcfd1..e9fd6f2b 100644 --- a/macos/assets/theme-package-validator.mjs +++ b/macos/assets/theme-package-validator.mjs @@ -14,6 +14,7 @@ const LIMITS = Object.freeze({ simpleTheme: 1_048_576, css: 262_144, image: 10_485_760, + video: 100 * 1024 * 1024, license: 65_536, signature: 4_096, }); @@ -23,9 +24,13 @@ const BACKGROUND_MEDIA = new Map([ ["background.jpg", "image/jpeg"], ["background.png", "image/png"], ]); +const VIDEO_MEDIA = new Map([ + ["background.mp4", "video/mp4"], +]); const PAYLOAD_MEDIA = new Map([ ["theme.json", "application/json"], ...BACKGROUND_MEDIA, + ...VIDEO_MEDIA, ["theme.css", "text/css"], ["LICENSE.txt", "text/plain"], ]); @@ -206,6 +211,7 @@ function expectedLimit(name, simple = false) { if (name === "theme.css") return LIMITS.css; if (name === "LICENSE.txt") return LIMITS.license; if (name === "manifest.sig") return LIMITS.signature; + if (VIDEO_MEDIA.has(name)) return LIMITS.video; if (BACKGROUND_MEDIA.has(name) || /\.(?:png|jpe?g|webp)$/i.test(name)) return LIMITS.image; return 0; } @@ -273,7 +279,7 @@ function validateOfficialTheme(value) { assertExactKeys( theme, THEME_REQUIRED, - [...THEME_COPY_KEYS, "promoUrl", "appearance", "art", "colors"], + [...THEME_COPY_KEYS, "promoUrl", "appearance", "art", "colors", "video"], "theme.json", ); if (theme.schemaVersion !== 1) fail("theme.json must use schemaVersion 1"); @@ -281,6 +287,10 @@ function validateOfficialTheme(value) { assertString(theme.name, "theme.json.name", { min: 1, max: 80 }); assertString(theme.image, "theme.json.image", { min: 1, max: 32, controls: null }); if (!BACKGROUND_MEDIA.has(theme.image)) fail("theme.json.image must name one registered background file"); + if (theme.video !== undefined) { + assertString(theme.video, "theme.json.video", { min: 1, max: 32, controls: null }); + if (!VIDEO_MEDIA.has(theme.video)) fail("theme.json.video must name background.mp4"); + } for (const key of THEME_COPY_KEYS) { if (theme[key] !== undefined) assertString(theme[key], `theme.json.${key}`, { max: 120 }); } @@ -407,6 +417,9 @@ function validateManifest(value, platform, clientVersion) { if (!files.has("theme.json") || backgrounds.length !== 1) { fail("manifest.files must contain theme.json and exactly one background file"); } + if (files.has("background.mp4") && !files.has("theme.json")) { + fail("manifest.files video requires theme.json"); + } if (files.has("theme.css") !== capabilities.has("safe-css")) { fail("theme.css presence must match the safe-css capability"); } @@ -440,6 +453,15 @@ function detectedImageMedia(bytes) { return ""; } +function isMp4Container(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.length < 16) return false; + const view = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const firstBoxSize = view.readUInt32BE(0); + return firstBoxSize >= 16 + && firstBoxSize <= view.length + && view.subarray(4, 8).toString("ascii") === "ftyp"; +} + async function validateOfficial(root, names, platform, clientVersion) { for (const name of names) { if (!PACKAGE_FILES.has(name)) fail(`Official theme package contains unregistered file ${name}`); @@ -465,6 +487,14 @@ async function validateOfficial(root, names, platform, clientVersion) { const theme = validateOfficialTheme(decodeJson(bytes.get("theme.json"), "theme.json")); if (manifest.themeId !== theme.id) fail("manifest.themeId does not match theme.json id"); if (theme.image !== background) fail("theme.json image does not match the manifest background file"); + if (theme.video !== undefined) { + if (!files.has(theme.video) || theme.video !== "background.mp4") { + fail("theme.json video does not match the manifest video file"); + } + if (!isMp4Container(bytes.get(theme.video))) { + fail("background.mp4 content is not a valid MP4 container"); + } + } if (detectedImageMedia(bytes.get(background)) !== BACKGROUND_MEDIA.get(background)) { fail(`${background} content does not match its extension and mediaType`); } @@ -479,7 +509,7 @@ async function validateOfficial(root, names, platform, clientVersion) { } async function validateSimple(root, names) { - if (names.length !== 3 || !names.includes("theme.json") || !names.includes("theme.css")) { + if ((names.length !== 3 && names.length !== 4) || !names.includes("theme.json") || !names.includes("theme.css")) { fail("Local simplified ZIP must contain exactly theme.json, theme.css, and its image"); } const themeBytes = await readStableFile(root, "theme.json", LIMITS.simpleTheme); @@ -493,9 +523,18 @@ async function validateSimple(root, names) { || !/\.(?:png|jpe?g|webp)$/i.test(theme.image) || !names.includes(theme.image) ) fail("Local simplified theme image must be beside theme.json"); - const [imageBytes, cssBytes] = await Promise.all([ + const hasVideo = theme.video !== undefined; + if (hasVideo && ( + path.basename(theme.video) !== theme.video + || CONTROL_PATTERN.test(theme.video) + || theme.video !== "background.mp4" + || !names.includes(theme.video) + )) fail("Local simplified theme video must be background.mp4 beside theme.json"); + if (names.length !== (hasVideo ? 4 : 3)) fail("Local simplified ZIP contains unsupported files"); + const [imageBytes, cssBytes, videoBytes] = await Promise.all([ readStableFile(root, theme.image, LIMITS.image), readStableFile(root, "theme.css", LIMITS.css), + hasVideo ? readStableFile(root, theme.video, LIMITS.video) : Promise.resolve(null), ]); const expectedMedia = /\.png$/i.test(theme.image) ? "image/png" @@ -503,6 +542,9 @@ async function validateSimple(root, names) { if (detectedImageMedia(imageBytes) !== expectedMedia) { fail(`${theme.image} content does not match its extension`); } + if (videoBytes && !isMp4Container(videoBytes)) { + fail("background.mp4 content is not a valid MP4 container"); + } decodeAndValidateSafeCss(cssBytes); return { format: "simple", @@ -513,6 +555,7 @@ async function validateSimple(root, names) { ["theme.json", themeBytes], [theme.image, imageBytes], ["theme.css", cssBytes], + ...(videoBytes ? [["background.mp4", videoBytes]] : []), ]), }; } diff --git a/macos/menubar-app/Sources/CodexDreamSkinMenuBar/AppDelegate.swift b/macos/menubar-app/Sources/CodexDreamSkinMenuBar/AppDelegate.swift index fffe42a4..6149186c 100644 --- a/macos/menubar-app/Sources/CodexDreamSkinMenuBar/AppDelegate.swift +++ b/macos/menubar-app/Sources/CodexDreamSkinMenuBar/AppDelegate.swift @@ -284,6 +284,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } addActionItem("打开 ChatGPT", action: #selector(openCodex), enabled: !busy) addActionItem("换一张背景图…", action: #selector(chooseBackgroundImage), enabled: !busy) + addActionItem("一键更换视频背景…", action: #selector(chooseBackgroundVideo), enabled: !busy) addActionItem("导入主题 ZIP…", action: #selector(chooseThemeArchive), enabled: !busy) addSavedThemesMenu(enabled: !busy) addActionItem("打开主题文件夹", action: #selector(openThemesFolder)) @@ -484,6 +485,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { ) } + @objc private func chooseBackgroundVideo() { + let panel = NSOpenPanel() + panel.title = "选择 Dream Skin 视频背景" + panel.prompt = "选择" + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [.mpeg4Movie] + activateForUserInteraction() + guard panel.runModal() == .OK, let videoURL = panel.url else { return } + runInstalledScript( + named: "load-image-theme-macos.sh", + arguments: ["--file", videoURL.path], + operation: "更换视频背景" + ) + } + @objc private func chooseThemeArchive() { let panel = NSOpenPanel() panel.title = "选择 Dream Skin 主题 ZIP" diff --git a/macos/scripts/build-menubar-app.sh b/macos/scripts/build-menubar-app.sh index 1d2c8be0..5c5a61c6 100755 --- a/macos/scripts/build-menubar-app.sh +++ b/macos/scripts/build-menubar-app.sh @@ -98,6 +98,7 @@ RUNTIME_SCRIPTS=( injector.mjs install-dream-skin-macos.sh load-image-theme-macos.sh + media-server.mjs pause-dream-skin-macos.sh publish-theme-import.mjs snapshot-active-theme-macos.sh diff --git a/macos/scripts/extract-theme-zip-macos.sh b/macos/scripts/extract-theme-zip-macos.sh index 6327e2e1..f32f727d 100755 --- a/macos/scripts/extract-theme-zip-macos.sh +++ b/macos/scripts/extract-theme-zip-macos.sh @@ -188,7 +188,7 @@ if [ -f "$SOURCE_ROOT/manifest.json" ]; then while IFS= read -r -d '' source_file; do source_name="$(/usr/bin/basename "$source_file")" case "$source_name" in - manifest.json|manifest.sig|theme.json|theme.css|LICENSE.txt) ;; + manifest.json|manifest.sig|theme.json|theme.css|LICENSE.txt|background.mp4) ;; background.webp|background.jpg|background.png) official_backgrounds=$((official_backgrounds + 1)) ;; @@ -200,8 +200,9 @@ if [ -f "$SOURCE_ROOT/manifest.json" ]; then [ -f "$SOURCE_ROOT/theme.css" ] \ || fail_extract "New official theme ZIP imports require theme.css and the safe-css capability." else - [ "$source_file_count" -eq 3 ] && [ -f "$SOURCE_ROOT/theme.css" ] \ - || fail_extract "A local simplified theme ZIP must contain exactly theme.json, theme.css, and one referenced image." + [ "$source_file_count" -eq 3 ] || [ "$source_file_count" -eq 4 ] + [ -f "$SOURCE_ROOT/theme.css" ] \ + || fail_extract "A local simplified theme ZIP must contain theme.json, theme.css, and one referenced image." fi while IFS= read -r -d '' source_file; do diff --git a/macos/scripts/injector.mjs b/macos/scripts/injector.mjs index aa3679d6..42e9a879 100644 --- a/macos/scripts/injector.mjs +++ b/macos/scripts/injector.mjs @@ -7,6 +7,7 @@ import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; import { Script } from "node:vm"; import { readImageMetadata } from "./image-metadata.mjs"; +import { MAX_VIDEO_BYTES, MediaServerController, isMp4Container } from "./media-server.mjs"; import { normalizeThemeColor, normalizeThemeText, @@ -360,7 +361,6 @@ function isValidCdpPageTarget(item, port) { return false; } } - class CdpSession { constructor(target, port) { this.target = target; @@ -636,6 +636,25 @@ export async function loadTheme(themeDir) { throw new Error(`${configPath} has an invalid image field`); } if (path.basename(raw.image) !== raw.image) throw new Error("Theme image must stay inside its theme directory"); + let videoPath = null; + if (raw.video !== undefined) { + if (typeof raw.video !== "string" || !raw.video || path.basename(raw.video) !== raw.video || + /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(raw.video) || + path.extname(raw.video).toLowerCase() !== ".mp4") { + throw new Error(`${configPath} has an invalid video field; only a relative MP4 filename is supported`); + } + try { + videoPath = await fs.realpath(path.join(assetsRoot, raw.video)); + } catch (error) { + if (error.code === "ENOENT") throw new Error(`Theme video is missing: ${path.join(assetsRoot, raw.video)}`); + throw error; + } + assertContainedPath(assetsRoot, videoPath, "Theme video"); + const videoStat = await fs.stat(videoPath); + if (!videoStat.isFile() || videoStat.size < 1 || videoStat.size > MAX_VIDEO_BYTES) { + throw new Error(`Theme video must be a non-empty MP4 no larger than ${MAX_VIDEO_BYTES} bytes`); + } + } const choice = (value, name, choices) => { if (value === undefined) return undefined; if (typeof value !== "string" || !choices.includes(value)) { @@ -693,6 +712,7 @@ export async function loadTheme(themeDir) { line: normalizeThemeColor(rawColors?.line, "rgba(124, 255, 70, .28)"), }, }; + if (raw.video !== undefined) theme.video = raw.video; if (appearance !== undefined) theme.appearance = appearance; if (Object.values(art).some((value) => value !== undefined)) { theme.art = Object.fromEntries(Object.entries(art).filter(([, value]) => value !== undefined)); @@ -734,12 +754,21 @@ export async function loadTheme(themeDir) { if (art.length < 1 || art.length > MAX_ART_BYTES) { throw new Error(`Theme image must be a non-empty file no larger than ${MAX_ART_BYTES} bytes`); } + const videoBytes = videoPath ? await fs.readFile(videoPath) : Buffer.alloc(0); + if (videoPath && videoBytes.length !== (await fs.stat(videoPath)).size) { + throw new Error("Theme video changed while being loaded"); + } + if (videoPath && !isMp4Container(videoBytes)) { + throw new Error("Theme video is not a valid MP4 container"); + } const safeCss = await loadSafeCss(assetsRoot); return { art, assetsRoot, extension, imagePath, + videoPath, + videoBytes, safeCss: safeCss?.source ?? "", safeCssPath: safeCss?.path ?? null, safeCssStatus: safeCss ? "validated" : "none", @@ -769,14 +798,21 @@ function invalidateStaticPayloadAssets() { staticPayloadAssets = null; } -export async function loadPayload(themeDir) { +export async function loadPayload(themeDir, candidateTheme = null, mediaUrl = null) { const startedAt = performance.now(); const [staticAssets, loaded] = await Promise.all([ loadStaticPayloadAssets(), - loadTheme(themeDir), + candidateTheme ?? loadTheme(themeDir), ]); const { css, template } = staticAssets; const { art, extension, safeCss, safeCssStatus, theme } = loaded; + const videoTransport = loaded.videoPath + ? (mediaUrl && typeof mediaUrl === "object" + ? mediaUrl + : typeof mediaUrl === "string" && mediaUrl + ? { mode: "server", url: mediaUrl } + : { mode: "blob" }) + : null; const combinedCss = safeCss ? `${css}\n${safeCss}\n` : css; const styleRevision = createHash("sha256").update(combinedCss).digest("hex").slice(0, 20); const artMetadata = readImageMetadata(art, extension); @@ -794,6 +830,8 @@ export async function loadPayload(themeDir) { .update(combinedCss) .update(template) .update(JSON.stringify(theme)) + .update("\0") + .update(loaded.videoBytes) .digest("hex") .slice(0, 20); // Every replacement value must be supplied as a function. A plain string @@ -804,12 +842,15 @@ export async function loadPayload(themeDir) { .replace("__DREAM_SKIN_CSS_JSON__", () => JSON.stringify(combinedCss)) .replace("__DREAM_SKIN_ART_JSON__", () => JSON.stringify(artDataUrl)) .replace("__DREAM_SKIN_THEME_JSON__", () => JSON.stringify(theme)) + .replace("__DREAM_SKIN_VIDEO_JSON__", () => JSON.stringify(videoTransport)) .replace("__DREAM_SKIN_VERSION_JSON__", () => JSON.stringify(SKIN_VERSION)) .replace("__DREAM_SKIN_STYLE_REVISION_JSON__", () => JSON.stringify(styleRevision)) .replace("__DREAM_SKIN_PAYLOAD_REVISION_JSON__", () => JSON.stringify(revision)); assertPayloadIntegrity(payload); return { imageBytes: art.length, + videoPath: loaded.videoPath, + videoTransport, payload, revision, safeCssStatus, @@ -842,8 +883,75 @@ export function assertPayloadIntegrity(payload) { return true; } -async function applyToSession(session, payload) { - return session.evaluate(payload); +const VIDEO_INPUT_SELECTOR = "#codex-dream-skin-video-input"; +const VIDEO_STATE_KEY = "__CODEX_DREAM_SKIN_STATE__"; +const mediaPolicyTargets = new Set(); + +async function attachBlobVideoToSession(session, loadedPayload) { + if (loadedPayload?.videoTransport?.mode !== "blob" || !loadedPayload.videoPath) return true; + const deadline = Date.now() + 8000; + let lastError = null; + while (Date.now() < deadline) { + try { + const inputReady = await session.evaluate(`Boolean(window[${JSON.stringify(VIDEO_STATE_KEY)}]?.ensureVideoInput?.())`); + if (!inputReady) throw new Error("Renderer did not create the video file input"); + await session.send("DOM.enable"); + const document = await session.send("DOM.getDocument", { depth: -1 }); + const node = await session.send("DOM.querySelector", { + nodeId: document.root.nodeId, + selector: VIDEO_INPUT_SELECTOR, + }); + if (!node.nodeId) throw new Error("Video file input is not attached to the renderer DOM"); + await session.send("DOM.setFileInputFiles", { nodeId: node.nodeId, files: [loadedPayload.videoPath] }); + const attached = await session.evaluate( + `window[${JSON.stringify(VIDEO_STATE_KEY)}]?.attachVideoFile?.() === true`, + ); + if (attached) return true; + const prepared = await session.evaluate(`(() => { + const state = window[${JSON.stringify(VIDEO_STATE_KEY)}]; + const video = document.querySelector("#codex-dream-skin-background-stage video"); + return Boolean(video?.src && !state?.videoFailed); + })()`); + if (prepared) return true; + const handled = await session.evaluate( + `Boolean(window[${JSON.stringify(VIDEO_STATE_KEY)}]?.videoFailed)`, + ); + if (handled) return true; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 120)); + } + throw new Error(`Could not attach the local MP4 through CDP: ${lastError?.message ?? "timed out"}`); +} + +async function syncMediaFetchPolicyForSession(session, loadedPayload) { + const transport = loadedPayload?.videoTransport; + const needsBypass = transport?.mode === "server" || Boolean(transport?.fallbackUrl); + const targetId = session.target?.id; + if (typeof targetId !== "string") return; + const bypassActive = mediaPolicyTargets.has(targetId); + if (needsBypass === bypassActive) return; + if (needsBypass) { + // The renderer fetches the loopback fallback and turns it into a Blob URL. + // Enable this only after the CDP target has passed the ChatGPT identity + // probe; image-only themes and unrelated app pages retain their normal CSP. + await session.send("Page.enable").catch(() => {}); + await session.send("Page.setWebLifecycleState", { state: "active" }).catch(() => {}); + await session.send("Page.setBypassCSP", { enabled: true }); + mediaPolicyTargets.add(targetId); + } else { + await session.send("Page.enable").catch(() => {}); + await session.send("Page.setBypassCSP", { enabled: false }).catch(() => {}); + mediaPolicyTargets.delete(targetId); + } +} + +async function applyToSession(session, payload, loadedPayload = null) { + await syncMediaFetchPolicyForSession(session, loadedPayload); + const result = await session.evaluate(payload); + await attachBlobVideoToSession(session, loadedPayload); + return result; } function nextOperationToken() { @@ -1277,12 +1385,14 @@ async function runOneShot(options) { for (const { target, session, probe } of connected) { try { - if (options.mode === "remove") await removeFromSession(session); - else if (options.mode === "once") { + if (options.mode === "remove") { + await removeFromSession(session); + await syncMediaFetchPolicyForSession(session, null); + } else if (options.mode === "once") { await bestEffortOperationUi( session, "update", operationToken, "loading", `正在应用「${loaded.theme.name}」…`, ); - await applyToSession(session, payload); + await applyToSession(session, payload, loaded); } if (options.reload) { @@ -1294,7 +1404,7 @@ async function runOneShot(options) { session, operationToken, "loading", `正在应用「${loaded.theme.name}」…`, ); } - await applyToSession(session, payload); + await applyToSession(session, payload, loaded); } } @@ -1522,7 +1632,26 @@ async function watchOperationState(statePath, onState) { } async function runWatch(options) { - let current = await loadPayload(options.themeDir); + const mediaServers = new MediaServerController(); + const stagePayload = async (candidateTheme = null) => { + const theme = candidateTheme ?? await loadTheme(options.themeDir); + const stagedMedia = await mediaServers.stage(theme.videoPath); + try { + const videoTransport = theme.videoPath + ? stagedMedia?.url + ? { mode: "blob", fallbackUrl: stagedMedia.url } + : { mode: "blob" } + : null; + const payload = await loadPayload(options.themeDir, theme, videoTransport); + return { payload, stagedMedia }; + } catch (error) { + await mediaServers.abort(stagedMedia); + throw error; + } + }; + const initial = await stagePayload(); + await mediaServers.commit(initial.stagedMedia); + let current = initial.payload; const sessions = new Map(); const rejected = new Set(); let stopping = false; @@ -1667,9 +1796,19 @@ async function runWatch(options) { const refreshPayload = async () => { const refreshEpoch = mutationEpoch; let next; + let stagedMedia = null; try { - next = await loadPayload(options.themeDir); + const candidateTheme = await loadTheme(options.themeDir); + const staged = await stagePayload(candidateTheme); + next = staged.payload; + stagedMedia = staged.stagedMedia; + if (next.revision === current.revision) { + await mediaServers.abort(stagedMedia); + return; + } + await mediaServers.commit(stagedMedia); } catch (error) { + await mediaServers.abort(stagedMedia); await Promise.all([...sessions.values()].map(async (record) => { if (record.session.closed) return; const externalOperation = activeOperation; @@ -1685,7 +1824,6 @@ async function runWatch(options) { })); throw error; } - if (next.revision === current.revision) return; current = next; if (controlOnly || mutationEpoch !== refreshEpoch) { console.log(`[dream-skin] staged theme ${current.theme.id} while skin is paused`); @@ -1715,7 +1853,7 @@ async function runWatch(options) { } record.earlyScriptId = nextIdentifier; record.needsLoadFallback = !nextIdentifier; - await applyToSession(session, current.payload); + await applyToSession(session, current.payload, current); if (controlOnly || mutationEpoch !== refreshEpoch) continue; const verification = await waitForVerifiedSession( session, @@ -1871,6 +2009,7 @@ async function runWatch(options) { } record.session.close(); sessions.delete(id); + mediaPolicyTargets.delete(id); } } @@ -1902,7 +2041,7 @@ async function runWatch(options) { setTimeout(() => { if (session.closed || controlOnly || mutationEpoch !== fallbackEpoch || !record.needsLoadFallback) return; - applyToSession(session, current.payload).catch((error) => { + applyToSession(session, current.payload, current).catch((error) => { console.error(`[dream-skin] fallback reinject failed: ${error.message}`); }); }, 0); @@ -1927,12 +2066,14 @@ async function runWatch(options) { await removeEarly(record); session.close(); sessions.delete(target.id); + mediaPolicyTargets.delete(target.id); if (!rejected.has(target.id)) { console.error(`[dream-skin] rejected non-ChatGPT app target ${target.id}`); rejected.add(target.id); } continue; } + await syncMediaFetchPolicyForSession(session, current); rejected.delete(target.id); if (controlOnly || pausing || mutationEpoch !== connectionEpoch) { await invalidateEarly(record); @@ -1969,7 +2110,14 @@ async function runWatch(options) { await session.evaluate( `window.__CODEX_DREAM_SKIN_EARLY_GENERATION__ = ${JSON.stringify(`fallback:${current.revision}`)}`, ); - await applyToSession(session, current.payload); + await applyToSession(session, current.payload, current); + } else if (current.videoTransport?.mode === "server" || current.videoTransport?.fallbackUrl) { + // Early payloads can run before CSP is relaxed; replay the full + // media payload after the verified-target media policy is active. + await applyToSession(session, current.payload, current); + } else { + await syncMediaFetchPolicyForSession(session, current); + await attachBlobVideoToSession(session, current); } if (controlOnly || mutationEpoch !== connectionEpoch) { await invalidateEarly(record); @@ -2020,6 +2168,7 @@ async function runWatch(options) { if (record) await removeEarly(record); session?.close(); sessions.delete(target.id); + mediaPolicyTargets.delete(target.id); console.error(`[dream-skin] inject failed for ${target.id}: ${error.message}`); } finally { finishTargetSetup(); @@ -2045,6 +2194,7 @@ async function runWatch(options) { : Promise.resolve(false))); await Promise.all([...sessions.values()].map((record) => removeEarly(record))); for (const record of sessions.values()) record.session.close(); + await mediaServers.close(); } } diff --git a/macos/scripts/load-image-theme-macos.sh b/macos/scripts/load-image-theme-macos.sh index 4af5e351..58c44423 100755 --- a/macos/scripts/load-image-theme-macos.sh +++ b/macos/scripts/load-image-theme-macos.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Dynamically load one pure image as the active theme. +# Dynamically load an image or an MP4 as the active theme. # Hot-applies when CDP is already open (fast). set -euo pipefail @@ -50,15 +50,21 @@ if [ -n "$FROM_LIBRARY" ]; then fi [ -n "$IMAGE" ] || fail "Pass --file or --from-library " -[ -f "$IMAGE" ] || fail "Image not found: $IMAGE" +[ -f "$IMAGE" ] || fail "Media file not found: $IMAGE" +VIDEO_MODE="false" case "$IMAGE" in + *.mp4|*.MP4) VIDEO_MODE="true" ;; *.png|*.PNG|*.jpg|*.JPG|*.jpeg|*.JPEG|*.webp|*.WEBP|*.heic|*.HEIC|*.tif|*.tiff|*.TIF|*.TIFF) ;; - *) fail "Unsupported image type: $IMAGE" ;; + *) fail "Unsupported image or video type: $IMAGE" ;; esac SOURCE_BYTES="$(/usr/bin/stat -f '%z' "$IMAGE")" -[ "$SOURCE_BYTES" -le 52428800 ] || fail "Image larger than 50 MB." +if [ "$VIDEO_MODE" = "true" ]; then + [ "$SOURCE_BYTES" -le 104857600 ] || fail "Video larger than 100 MB." +else + [ "$SOURCE_BYTES" -le 52428800 ] || fail "Image larger than 50 MB." +fi if [ -z "$THEME_NAME" ]; then base="$(/usr/bin/basename "$IMAGE")" @@ -73,34 +79,47 @@ progress() { notify_user "$*" } -progress "Loading image..." +progress "Loading $([ "$VIDEO_MODE" = "true" ] && printf 'video' || printf 'image')..." # Fast Node for write-theme (avoid full codesign when possible) ensure_node_runtime -image_name="background.jpg" -temporary="$THEME_DIR/.background.$$.tmp.jpg" -prepared="$THEME_DIR/$image_name" -cleanup_temporary() { /bin/rm -f "$temporary"; } -trap cleanup_temporary EXIT - -# Prefer copying already-JPEG; sips only when needed (large PNG conversion is the slow part) -ext="$(printf '%s' "$IMAGE" | /usr/bin/tr '[:upper:]' '[:lower:]')" -case "$ext" in - *.jpg|*.jpeg) - /bin/cp -f "$IMAGE" "$temporary" - ;; - *) - /usr/bin/sips -s format jpeg -s formatOptions 82 -Z 2400 "$IMAGE" --out "$temporary" >/dev/null \ - || fail "Could not convert image. Use PNG/JPEG/HEIC/TIFF/WebP." - [ -s "$temporary" ] || fail "Converted image is empty." - ;; -esac -[ -s "$temporary" ] || fail "Prepared image is empty." -PREPARED_BYTES="$(/usr/bin/stat -f '%z' "$temporary")" -[ "$PREPARED_BYTES" -le 10485760 ] || fail "Prepared image larger than 10 MB." -/bin/chmod 600 "$temporary" -/bin/mv -f "$temporary" "$prepared" +if [ "$VIDEO_MODE" = "true" ]; then + video_name="background.mp4" + temporary="$THEME_DIR/.background.$$.tmp.mp4" + cleanup_temporary() { /bin/rm -f "$temporary"; } + trap cleanup_temporary EXIT + /bin/cp -f "$IMAGE" "$temporary" + [ -s "$temporary" ] || fail "Prepared video is empty." + /bin/chmod 600 "$temporary" + /bin/mv -f "$temporary" "$THEME_DIR/$video_name" + image_name="$("$NODE" -e 'try{const t=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(typeof t.image==="string"?t.image:"")}catch{}' "$THEME_DIR/theme.json" 2>/dev/null || true)" + [ -n "$image_name" ] && [ -f "$THEME_DIR/$image_name" ] \ + || fail "An existing fallback image is required before importing a video." +else + image_name="background.jpg" + temporary="$THEME_DIR/.background.$$.tmp.jpg" + cleanup_temporary() { /bin/rm -f "$temporary"; } + trap cleanup_temporary EXIT + + # Prefer copying already-JPEG; sips only when needed (large PNG conversion is the slow part) + ext="$(printf '%s' "$IMAGE" | /usr/bin/tr '[:upper:]' '[:lower:]')" + case "$ext" in + *.jpg|*.jpeg) + /bin/cp -f "$IMAGE" "$temporary" + ;; + *) + /usr/bin/sips -s format jpeg -s formatOptions 82 -Z 2400 "$IMAGE" --out "$temporary" >/dev/null \ + || fail "Could not convert image. Use PNG/JPEG/HEIC/TIFF/WebP." + [ -s "$temporary" ] || fail "Converted image is empty." + ;; + esac + [ -s "$temporary" ] || fail "Prepared image is empty." + PREPARED_BYTES="$(/usr/bin/stat -f '%z' "$temporary")" + [ "$PREPARED_BYTES" -le 10485760 ] || fail "Prepared image larger than 10 MB." + /bin/chmod 600 "$temporary" + /bin/mv -f "$temporary" "$THEME_DIR/$image_name" +fi theme_args=( custom @@ -113,22 +132,26 @@ theme_args=( --safe-area "$SAFE_AREA" --task-mode "$TASK_MODE" ) +[ "$VIDEO_MODE" = "true" ] && theme_args+=(--video "$video_name") [ -n "$FOCUS_X" ] && theme_args+=(--focus-x "$FOCUS_X") [ -n "$FOCUS_Y" ] && theme_args+=(--focus-y "$FOCUS_Y") "$NODE" "$SCRIPT_DIR/write-theme.mjs" "${theme_args[@]}" >/dev/null -/usr/bin/find "$THEME_DIR" -maxdepth 1 -type f -name 'background.*' ! -name "$image_name" -delete +/usr/bin/find "$THEME_DIR" -maxdepth 1 -type f -name 'background.*' ! -name "$image_name" ! -name "${video_name:-}" -delete trap - EXIT lib_dir="$THEMES_ROOT/$theme_id" /bin/mkdir -p "$lib_dir" /bin/cp -f "$THEME_DIR/$image_name" "$THEME_DIR/theme.json" "$lib_dir/" +[ "$VIDEO_MODE" = "true" ] && /bin/cp -f "$THEME_DIR/$video_name" "$lib_dir/" /bin/chmod 600 "$lib_dir/"* 2>/dev/null || true -dest_lib_img="$IMAGES_DIR/$(/usr/bin/basename "$IMAGE")" -src_dir="$(cd "$(dirname "$IMAGE")" && pwd -P)" -img_dir="$(cd "$IMAGES_DIR" && pwd -P)" -if [ "$src_dir/$(/usr/bin/basename "$IMAGE")" != "$img_dir/$(/usr/bin/basename "$IMAGE")" ]; then - /bin/cp -f "$IMAGE" "$dest_lib_img" 2>/dev/null || true +if [ "$VIDEO_MODE" != "true" ]; then + dest_lib_img="$IMAGES_DIR/$(/usr/bin/basename "$IMAGE")" + src_dir="$(cd "$(dirname "$IMAGE")" && pwd -P)" + img_dir="$(cd "$IMAGES_DIR" && pwd -P)" + if [ "$src_dir/$(/usr/bin/basename "$IMAGE")" != "$img_dir/$(/usr/bin/basename "$IMAGE")" ]; then + /bin/cp -f "$IMAGE" "$dest_lib_img" 2>/dev/null || true + fi fi if [ "$APPLY_NOW" != "true" ]; then diff --git a/macos/scripts/media-server.mjs b/macos/scripts/media-server.mjs new file mode 100644 index 00000000..d8d254b9 --- /dev/null +++ b/macos/scripts/media-server.mjs @@ -0,0 +1,211 @@ +import http from "node:http"; +import fs from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import path from "node:path"; +import { createHash, randomUUID } from "node:crypto"; + +export const MAX_VIDEO_BYTES = 100 * 1024 * 1024; +export const TRUSTED_MEDIA_SCHEME = "codex-dream-skin"; +const VIDEO_EXTENSION = ".mp4"; +const VIDEO_MIME = "video/mp4"; +const TRUSTED_ORIGINS = new Set(["app://-", "app://", "null"]); + +export function isMp4Container(bytes, totalSize = bytes?.byteLength ?? 0) { + if (!(bytes instanceof Uint8Array) || bytes.length < 16) return false; + const view = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const firstBoxSize = view.readUInt32BE(0); + return firstBoxSize >= 16 + && firstBoxSize <= totalSize + && view.subarray(4, 8).toString("ascii") === "ftyp"; +} + +function parseRange(value, size) { + if (typeof value !== "string" || !value.startsWith("bytes=")) return null; + const match = /^bytes=(\d*)-(\d*)$/.exec(value.trim()); + if (!match || (!match[1] && !match[2])) return null; + let start; + let end; + if (match[1]) { + start = Number(match[1]); + end = match[2] ? Number(match[2]) : size - 1; + } else { + const suffix = Number(match[2]); + if (!Number.isSafeInteger(suffix) || suffix < 1) return null; + start = Math.max(0, size - suffix); + end = size - 1; + } + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) return null; + if (start >= size) return { unsatisfiable: true }; + return { start, end: Math.min(end, size - 1) }; +} + +async function validateVideoFile(filePath) { + const resolved = path.resolve(filePath); + if (path.extname(resolved).toLowerCase() !== VIDEO_EXTENSION) { + throw new Error("Video backgrounds must use an MP4 file."); + } + const lstat = await fs.lstat(resolved); + if (lstat.isSymbolicLink()) throw new Error("Video background must not be a symbolic link."); + const realPath = await fs.realpath(resolved); + const stat = await fs.stat(realPath); + if (!stat.isFile() || stat.size < 1 || stat.size > MAX_VIDEO_BYTES) { + throw new Error(`Video background must be a non-empty MP4 no larger than ${MAX_VIDEO_BYTES} bytes.`); + } + const bytes = await fs.readFile(realPath); + if (!isMp4Container(bytes)) { + throw new Error("Video background is not a valid MP4 container."); + } + const hash = createHash("sha256"); + hash.update(bytes); + return { filePath: realPath, size: stat.size, identity: hash.digest("hex") }; +} + +export async function createMediaServer(filePath) { + const validated = await validateVideoFile(filePath); + const token = randomUUID().replaceAll("-", ""); + const route = `/media/${token}`; + const protocolUrl = `${TRUSTED_MEDIA_SCHEME}://${route.slice(1)}`; + const sockets = new Set(); + let closed = false; + + const server = http.createServer(async (request, response) => { + const origin = request.headers.origin; + const trustedOrigin = origin && TRUSTED_ORIGINS.has(origin) ? origin : null; + const corsHeaders = { + "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", + "Access-Control-Allow-Headers": "Range, X-Codex-Dream-Skin-Token", + "Access-Control-Allow-Private-Network": "true", + "Access-Control-Expose-Headers": "Accept-Ranges, Content-Length, Content-Range, Content-Type", + "Vary": "Origin", + }; + if (trustedOrigin) corsHeaders["Access-Control-Allow-Origin"] = trustedOrigin; + if (request.url === route && request.method === "OPTIONS") { + if (origin && !trustedOrigin) { + response.writeHead(403, { "Cache-Control": "no-store" }); + response.end(); + return; + } + response.writeHead(204, corsHeaders); + response.end(); + return; + } + if (closed || !["GET", "HEAD"].includes(request.method) || request.url !== route) { + response.writeHead(closed ? 503 : 404, { "Cache-Control": "no-store" }); + response.end(); + return; + } + if ((origin && !trustedOrigin) || request.headers["x-codex-dream-skin-token"] !== token) { + response.writeHead(403, { "Cache-Control": "no-store" }); + response.end(); + return; + } + + try { + const lstat = await fs.lstat(validated.filePath); + if (lstat.isSymbolicLink()) throw new Error("Video path became a symbolic link"); + const stat = await fs.stat(validated.filePath); + if (!stat.isFile() || stat.size !== validated.size || stat.size > MAX_VIDEO_BYTES) { + throw new Error("Video file changed or exceeded the safety limit"); + } + + const currentHash = createHash("sha256"); + currentHash.update(await fs.readFile(validated.filePath)); + if (currentHash.digest("hex") !== validated.identity) { + throw new Error("Video file content changed after staging"); + } + + const rangeHeader = request.headers.range; + const range = parseRange(rangeHeader, stat.size); + const headers = { + ...corsHeaders, + "Content-Type": VIDEO_MIME, + "Accept-Ranges": "bytes", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }; + if (rangeHeader !== undefined && !range) { + headers["Content-Range"] = `bytes */${stat.size}`; + response.writeHead(416, headers); + response.end(); + return; + } + if (!range) { + headers["Content-Length"] = stat.size; + response.writeHead(200, headers); + if (request.method === "HEAD") { response.end(); return; } + createReadStream(validated.filePath).on("error", () => response.destroy()).pipe(response); + return; + } + + headers["Content-Range"] = `bytes ${range.start}-${range.end}/${stat.size}`; + headers["Content-Length"] = range.end - range.start + 1; + response.writeHead(206, headers); + if (request.method === "HEAD") { response.end(); return; } + createReadStream(validated.filePath, { start: range.start, end: range.end }) + .on("error", () => response.destroy()) + .pipe(response); + } catch { + response.writeHead(404, { "Cache-Control": "no-store" }); + response.end(); + } + }); + + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + await new Promise((resolve) => server.close(resolve)); + throw new Error("Local media server did not expose a TCP port."); + } + + return { + filePath: validated.filePath, + size: validated.size, + identity: validated.identity, + token, + route, + protocolUrl, + url: `http://127.0.0.1:${address.port}${route}`, + async close() { + if (closed) return; + closed = true; + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +export class MediaServerController { + #active = null; + + async stage(filePath) { + if (!filePath) return null; + const validated = await validateVideoFile(filePath); + if (this.#active?.filePath === validated.filePath && + this.#active.size === validated.size && this.#active.identity === validated.identity) return this.#active; + return createMediaServer(validated.filePath); + } + + async commit(next) { + if (next === this.#active) return; + const previous = this.#active; + this.#active = next; + await previous?.close(); + } + + async abort(staged) { + if (staged && staged !== this.#active) await staged.close(); + } + + async close() { + const active = this.#active; + this.#active = null; + await active?.close(); + } +} diff --git a/macos/scripts/publish-theme-import.mjs b/macos/scripts/publish-theme-import.mjs index f66ab102..b76fb11b 100644 --- a/macos/scripts/publish-theme-import.mjs +++ b/macos/scripts/publish-theme-import.mjs @@ -12,6 +12,7 @@ if (!stageDirArg || !themesRootArg) { const MAX_CONFIG_BYTES = 1024 * 1024; const MAX_IMAGE_BYTES = 10 * 1024 * 1024; +const MAX_VIDEO_BYTES = 100 * 1024 * 1024; const MAX_CSS_BYTES = 256 * 1024; const MAX_LICENSE_BYTES = 64 * 1024; const MAX_MANIFEST_BYTES = 64 * 1024; @@ -68,7 +69,7 @@ async function readRegular(filePath, label, maxBytes) { } } -function normalizedFingerprint(theme, imageBytes, cssBytes = null, licenseBytes = null) { +function normalizedFingerprint(theme, imageBytes, cssBytes = null, licenseBytes = null, videoBytes = null) { const semanticTheme = { ...theme }; delete semanticTheme.id; const hash = createHash("sha256") @@ -77,6 +78,7 @@ function normalizedFingerprint(theme, imageBytes, cssBytes = null, licenseBytes .update(imageBytes); if (cssBytes) hash.update("\0theme.css\0").update(cssBytes); if (licenseBytes) hash.update("\0LICENSE.txt\0").update(licenseBytes); + if (videoBytes) hash.update("\0background.mp4\0").update(videoBytes); return hash.digest("hex"); } @@ -96,6 +98,9 @@ async function readStoredTheme(directory) { const configBytes = await readRegular(path.join(directory, "theme.json"), "Saved theme config", MAX_CONFIG_BYTES); const theme = decodeTheme(configBytes, "Saved theme config"); const imageBytes = await readRegular(path.join(directory, theme.image), "Saved theme image", MAX_IMAGE_BYTES); + const videoBytes = theme.video === undefined + ? null + : await readRegular(path.join(directory, theme.video), "Saved theme video", MAX_VIDEO_BYTES); const [cssBytes, licenseBytes] = await Promise.all([ readOptionalRegular(path.join(directory, "theme.css"), "Saved theme CSS", MAX_CSS_BYTES), readOptionalRegular(path.join(directory, "LICENSE.txt"), "Saved theme license", MAX_LICENSE_BYTES), @@ -103,8 +108,8 @@ async function readStoredTheme(directory) { if (cssBytes) decodeAndValidateSafeCss(cssBytes); return { theme, - fingerprint: normalizedFingerprint(theme, imageBytes, cssBytes, licenseBytes), - contentFingerprint: runtimeThemeContentFingerprint(theme, imageBytes, cssBytes), + fingerprint: normalizedFingerprint(theme, imageBytes, cssBytes, licenseBytes, videoBytes), + contentFingerprint: runtimeThemeContentFingerprint(theme, imageBytes, cssBytes, videoBytes), }; } catch { return null; @@ -170,6 +175,11 @@ async function main() { const imagePath = path.join(stageRoot, sourceTheme.image); assertContained(stageRoot, imagePath, "Imported theme image"); const imageBytes = await readRegular(imagePath, "Imported theme image", MAX_IMAGE_BYTES); + const videoPath = sourceTheme.video === undefined ? null : path.join(stageRoot, sourceTheme.video); + if (videoPath) assertContained(stageRoot, videoPath, "Imported theme video"); + const videoBytes = videoPath + ? await readRegular(videoPath, "Imported theme video", MAX_VIDEO_BYTES) + : null; const [manifestBytes, cssBytes, licenseBytes, signatureBytes] = await Promise.all([ readOptionalRegular(path.join(stageRoot, "manifest.json"), "Imported manifest", MAX_MANIFEST_BYTES), readOptionalRegular(path.join(stageRoot, "theme.css"), "Imported theme CSS", MAX_CSS_BYTES), @@ -180,7 +190,8 @@ async function main() { if (!cssBytes) throw new Error("New theme imports require non-empty theme.css"); decodeAndValidateSafeCss(cssBytes); const safeCssStatus = "validated"; - const fingerprint = normalizedFingerprint(sourceTheme, imageBytes, cssBytes, licenseBytes); + const fingerprint = normalizedFingerprint(sourceTheme, imageBytes, cssBytes, licenseBytes, videoBytes); + const contentFingerprint = runtimeThemeContentFingerprint(sourceTheme, imageBytes, cssBytes, videoBytes); const releaseLock = await acquireLock(themesRoot); let temporary = ""; try { @@ -217,14 +228,16 @@ async function main() { } const renamed = id !== (typeof sourceTheme.id === "string" ? sourceTheme.id.trim() : ""); const theme = { ...sourceTheme, id }; + if (videoBytes) theme.video = "background.mp4"; const name = displayName(theme); - const contentFingerprint = runtimeThemeContentFingerprint(theme, imageBytes, cssBytes); + const contentFingerprint = runtimeThemeContentFingerprint(theme, imageBytes, cssBytes, videoBytes); const destination = path.join(themesRoot, id); assertContained(themesRoot, destination, "Imported theme destination"); temporary = await fs.mkdtemp(path.join(themesRoot, ".theme-import-")); await fs.chmod(temporary, 0o700); await writeExclusive(path.join(temporary, theme.image), imageBytes); + if (videoBytes) await writeExclusive(path.join(temporary, "background.mp4"), videoBytes); await writeExclusive( path.join(temporary, "theme.json"), Buffer.from(`${JSON.stringify(theme, null, 2)}\n`, "utf8"), diff --git a/macos/scripts/stage-theme.mjs b/macos/scripts/stage-theme.mjs index 1bd44650..f7d5d212 100644 --- a/macos/scripts/stage-theme.mjs +++ b/macos/scripts/stage-theme.mjs @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; import path from "node:path"; import { decodeAndValidateSafeCss } from "../assets/safe-css-validator.mjs"; +import { isMp4Container } from "./media-server.mjs"; import { runtimeThemeContentFingerprint } from "./theme-content-fingerprint.mjs"; const [sourceDirArg, stageDirArg] = process.argv.slice(2); @@ -11,6 +12,7 @@ if (!sourceDirArg || !stageDirArg) { const MAX_CONFIG_BYTES = 1024 * 1024; const MAX_IMAGE_BYTES = 10 * 1024 * 1024; +const MAX_VIDEO_BYTES = 100 * 1024 * 1024; const MAX_CSS_BYTES = 256 * 1024; const OPEN_FLAGS = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0); @@ -75,6 +77,14 @@ function decodeJson(bytes, label) { } } +function hasControlCharacters(value) { + return [...value].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) + || codePoint === 0x2028 || codePoint === 0x2029; + }); +} + async function writeExclusive(filePath, bytes) { const temporary = `${filePath}.${process.pid}.tmp`; try { @@ -102,7 +112,7 @@ async function main() { if (theme.image === "theme.json") { throw new Error("Theme image must not replace theme.json"); } - if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(theme.image)) { + if (hasControlCharacters(theme.image)) { throw new Error("Theme image contains control characters"); } @@ -115,21 +125,41 @@ async function main() { if (image.bytes.length < 1) throw new Error("Theme image is empty"); if (safeCss) decodeAndValidateSafeCss(safeCss.bytes); + let video = null; + if (theme.video !== undefined) { + if (typeof theme.video !== "string" || path.basename(theme.video) !== theme.video || !/\.mp4$/i.test(theme.video)) { + throw new Error("Theme video must be an MP4 filename inside its theme directory"); + } + if (hasControlCharacters(theme.video)) { + throw new Error("Theme video contains control characters"); + } + const videoPath = path.resolve(sourceRoot, theme.video); + assertContained(sourceRoot, videoPath, "Theme video"); + video = await readStableFile(videoPath, "Theme video", MAX_VIDEO_BYTES); + if (video.bytes.length < 1) throw new Error("Theme video is empty"); + if (!isMp4Container(video.bytes)) throw new Error("Theme video is not a valid MP4 container"); + } + const stageRoot = await fs.realpath(stageDirArg); const stageStat = await fs.stat(stageRoot); if (!stageStat.isDirectory()) throw new Error("Theme stage must be a directory"); assertContained(stageRoot, path.join(stageRoot, "theme.json"), "Staged theme config"); assertContained(stageRoot, path.join(stageRoot, theme.image), "Staged theme image"); + if (theme.video) assertContained(stageRoot, path.join(stageRoot, theme.video), "Staged theme video"); - // Write both files from the already-open, stable descriptors. The caller - // publishes the image first and theme.json last, so the watcher only ever - // observes a complete pair; subsequent source edits cannot race the copy. await writeExclusive(path.join(stageRoot, theme.image), image.bytes); + if (theme.video) await writeExclusive(path.join(stageRoot, theme.video), video.bytes); if (safeCss) await writeExclusive(path.join(stageRoot, "theme.css"), safeCss.bytes); await writeExclusive(path.join(stageRoot, "theme.json"), config.bytes); process.stdout.write(JSON.stringify({ image: theme.image, - contentFingerprint: runtimeThemeContentFingerprint(theme, image.bytes, safeCss?.bytes ?? null), + video: theme.video ?? null, + contentFingerprint: runtimeThemeContentFingerprint( + theme, + image.bytes, + safeCss?.bytes ?? null, + video?.bytes ?? null, + ), })); } diff --git a/macos/scripts/switch-theme-macos.sh b/macos/scripts/switch-theme-macos.sh index acb12d72..0fc69ffb 100755 --- a/macos/scripts/switch-theme-macos.sh +++ b/macos/scripts/switch-theme-macos.sh @@ -129,6 +129,7 @@ process.stdout.write(value.contentFingerprint); [ -z "$EXPECTED_CONTENT_FINGERPRINT" ] \ || [ "$STAGED_CONTENT_FINGERPRINT" = "$EXPECTED_CONTENT_FINGERPRINT" ] \ || fail "Saved theme content no longer matches the package that was imported." +THEME_VIDEO="$("$NODE" -e 'try{const t=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(typeof t.video==="string"?t.video:"")}catch{}' "$stage/theme.json" 2>/dev/null || true)" # Validate the exact staged pair, not the mutable library directory. The # injector performs the full schema, path, dimensions, and image checks. "$NODE" "$INJECTOR" --check-payload --theme-dir "$stage" >/dev/null \ @@ -153,10 +154,11 @@ done /bin/mv -f "$stage/theme.json" "$THEME_DIR/theme.json" if [ -n "$SAFE_CSS_NAME" ]; then /usr/bin/find "$THEME_DIR" -maxdepth 1 -type f \ - ! -name 'theme.json' ! -name "$THEME_IMAGE" ! -name 'theme.css' -delete + ! -name 'theme.json' ! -name "$THEME_IMAGE" ! -name 'theme.css' \ + ! -name "$THEME_VIDEO" -delete else /usr/bin/find "$THEME_DIR" -maxdepth 1 -type f \ - ! -name 'theme.json' ! -name "$THEME_IMAGE" -delete + ! -name 'theme.json' ! -name "$THEME_IMAGE" ! -name "$THEME_VIDEO" -delete fi /bin/rm -rf "$stage" stage="" diff --git a/macos/scripts/theme-content-fingerprint.mjs b/macos/scripts/theme-content-fingerprint.mjs index 40df98da..53c50fa1 100644 --- a/macos/scripts/theme-content-fingerprint.mjs +++ b/macos/scripts/theme-content-fingerprint.mjs @@ -7,7 +7,7 @@ function updateFramed(hash, label, bytes) { hash.update(labelBytes).update("\0").update(length).update(bytes); } -export function runtimeThemeContentFingerprint(theme, imageBytes, cssBytes = null) { +export function runtimeThemeContentFingerprint(theme, imageBytes, cssBytes = null, videoBytes = null) { const hash = createHash("sha256"); hash.update("dreamskin-runtime-theme/1\0"); updateFramed(hash, "theme.json", Buffer.from(JSON.stringify(theme), "utf8")); @@ -17,5 +17,7 @@ export function runtimeThemeContentFingerprint(theme, imageBytes, cssBytes = nul } else { hash.update("theme.css\0absent\0"); } + if (videoBytes) updateFramed(hash, "video", videoBytes); + else hash.update("video\0absent\0"); return hash.digest("hex"); } diff --git a/macos/scripts/write-theme.mjs b/macos/scripts/write-theme.mjs index 3eca2c63..b043f8df 100644 --- a/macos/scripts/write-theme.mjs +++ b/macos/scripts/write-theme.mjs @@ -42,8 +42,16 @@ function validateUnit(value, name) { return parsed; } +function hasControlCharacters(value) { + return [...value].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) + || codePoint === 0x2028 || codePoint === 0x2029; + }); +} + function validateText(value, name, maxLength, fallback) { - if (/\p{Cc}|\u2028|\u2029/u.test(value)) { + if (hasControlCharacters(value)) { throw new Error(`${name} must be a single line without control characters.`); } const normalized = value.trim(); @@ -102,7 +110,7 @@ if (mode !== "custom") { const requestedImage = valueFor("image", "background.jpg"); const image = path.basename(requestedImage); -if (/\p{Cc}|\u2028|\u2029/u.test(image)) { +if (hasControlCharacters(image)) { throw new Error("image must be a single-line filename without control characters."); } if (image !== requestedImage || image === "." || image === "..") { @@ -117,6 +125,22 @@ if (!imageStat.isFile() || imageStat.size < 1 || imageStat.size > 10 * 1024 * 10 throw new Error("The prepared theme image must be non-empty and no larger than 10 MB."); } +const requestedVideo = hasValue("video") ? valueFor("video") : ""; +let video = ""; +if (requestedVideo) { + video = path.basename(requestedVideo); + if (video !== requestedVideo || hasControlCharacters(video) || video === "." || video === "..") { + throw new Error("video must be a filename inside the output directory."); + } + if (!/\.mp4$/i.test(video)) throw new Error("video must be an MP4 filename."); + const videoPath = await fs.realpath(path.join(canonicalOutputDir, video)); + assertContainedPath(canonicalOutputDir, videoPath, "video"); + const videoStat = await fs.stat(videoPath); + if (!videoStat.isFile() || videoStat.size < 1 || videoStat.size > 100 * 1024 * 1024) { + throw new Error("The prepared theme video must be non-empty and no larger than 100 MB."); + } +} + const name = validateText(valueFor("name", "我的 Codex Dream Skin"), "name", 80, "我的 Codex Dream Skin"); const tagline = validateText( valueFor("tagline", "把喜欢的画面变成可交互的 Codex 工作台。"), @@ -165,6 +189,7 @@ const custom = { if (focusX !== null) custom.art.focusX = focusX; if (focusY !== null) custom.art.focusY = focusY; +if (video) custom.video = video; if (Object.keys(explicitColors).length) custom.colors = explicitColors; await atomicWrite(themePath, `${JSON.stringify(custom, null, 2)}\n`); diff --git a/macos/tests/injector-bootstrap.test.mjs b/macos/tests/injector-bootstrap.test.mjs index 03a6bfbd..462caf9d 100644 --- a/macos/tests/injector-bootstrap.test.mjs +++ b/macos/tests/injector-bootstrap.test.mjs @@ -110,5 +110,19 @@ assert.match( ); assert.match(source, /visibleSuggestionLabels\.length >= result\.visibleCardCount/); assert.match(source, /result\.suggestionLabelColorsMatch/); +assert.match(source, /DOM\.setFileInputFiles/, + "macOS video themes must use CDP file injection instead of exposing a local file URL."); +assert.match(source, /videoTransport[\s\S]*mode: "blob"/, + "macOS video payloads must prefer Blob transport when no fallback URL is supplied."); +assert.match(source, /Page\.setBypassCSP[\s\S]*enabled: true/, + "macOS video fallback fetches must enable CSP bypass only through the verified injector."); +assert.match(source, /Page\.enable[\s\S]*Page\.setBypassCSP/, + "macOS video fallback fetches must enable CSP bypass before replaying media payloads."); +const mediaPolicyStart = source.indexOf("async function enableMediaFetchForSession"); +const mediaPolicyEnd = source.indexOf("async function applyToSession", mediaPolicyStart); +assert.doesNotMatch(source.slice(mediaPolicyStart, mediaPolicyEnd), /Page\.reload/, + "macOS video theme imports must not force a full page reload and visible flash."); +assert.match(source, /mode: "blob", fallbackUrl: stagedMedia\.url/, + "macOS watch mode must prefer CDP file injection with a controlled fallback transport."); console.log("PASS: early injection is L0-ready, generation-safe, and removed on shutdown."); diff --git a/macos/tests/media-server.test.mjs b/macos/tests/media-server.test.mjs new file mode 100644 index 00000000..e2c31b72 --- /dev/null +++ b/macos/tests/media-server.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { MediaServerController } from "../scripts/media-server.mjs"; + +function mp4Fixture(marker) { + const fileTypeBox = Buffer.alloc(24); + fileTypeBox.writeUInt32BE(fileTypeBox.length, 0); + fileTypeBox.write("ftyp", 4, "ascii"); + fileTypeBox.write("isom", 8, "ascii"); + fileTypeBox.writeUInt32BE(512, 12); + fileTypeBox.write("isom", 16, "ascii"); + fileTypeBox.write("mp41", 20, "ascii"); + return Buffer.concat([fileTypeBox, Buffer.from(marker, "ascii")]); +} + +const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-dream-skin-media-")); +try { + const invalidVideoPath = path.join(root, "renamed.mp4"); + await fs.writeFile(invalidVideoPath, Buffer.from("not-an-mp4")); + await assert.rejects( + new MediaServerController().stage(invalidVideoPath), + /not a valid MP4 container/, + ); + + const videoPath = path.join(root, "background.mp4"); + const bytes = mp4Fixture("AAAA"); + await fs.writeFile(videoPath, bytes); + + const media = new MediaServerController(); + const staged = await media.stage(videoPath); + media.commit(staged); + + assert.match(staged.protocolUrl, /^codex-dream-skin:\/\/media\/[a-f0-9]{32}$/); + + const denied = await fetch(staged.url); + assert.equal(denied.status, 403); + + const full = await fetch(staged.url, { + headers: { "X-Codex-Dream-Skin-Token": staged.token }, + }); + assert.equal(full.status, 200); + assert.equal(full.headers.get("content-type"), "video/mp4"); + assert.equal(Buffer.compare(Buffer.from(await full.arrayBuffer()), bytes), 0); + + const range = await fetch(staged.url, { + headers: { + Range: "bytes=4-7", + "X-Codex-Dream-Skin-Token": staged.token, + }, + }); + assert.equal(range.status, 206); + assert.equal(Buffer.from(await range.arrayBuffer()).toString(), "ftyp"); + + const options = await fetch(staged.url, { + method: "OPTIONS", + headers: { + Origin: "app://-", + "Access-Control-Request-Headers": "X-Codex-Dream-Skin-Token, Range", + "Access-Control-Request-Private-Network": "true", + }, + }); + assert.equal(options.status, 204); + assert.equal(options.headers.get("access-control-allow-private-network"), "true"); + + const wrongOrigin = await fetch(staged.url, { + headers: { + Origin: "https://example.invalid", + "X-Codex-Dream-Skin-Token": staged.token, + }, + }); + assert.equal(wrongOrigin.status, 403); + + const missing = await fetch(`${staged.url}/other`); + assert.equal(missing.status, 404); + await media.close(); +} finally { + await fs.rm(root, { recursive: true, force: true }); +} + +console.log("PASS: loopback media server range and route isolation."); diff --git a/macos/tests/payload-template-integrity.test.mjs b/macos/tests/payload-template-integrity.test.mjs index 3ca8053b..dd8adc5c 100644 --- a/macos/tests/payload-template-integrity.test.mjs +++ b/macos/tests/payload-template-integrity.test.mjs @@ -91,7 +91,7 @@ async function makeThemeDir(overrides) { // running any renderer logic. If substitution corrupted the payload the values // recovered here diverge from the theme on disk. function readPayloadArguments(payload) { - const marker = "((cssText, artDataUrl, themeConfig) => {"; + const marker = "((cssText, artDataUrl, themeConfig, videoConfig) => {"; const at = payload.indexOf(marker); assert.notEqual(at, -1, "payload must keep the canonical renderer IIFE signature"); const probe = `${payload.slice(0, at + marker.length)} @@ -224,10 +224,11 @@ test("the macOS injector builds payloads with function replacements only", async "__DREAM_SKIN_CSS_JSON__", "__DREAM_SKIN_ART_JSON__", "__DREAM_SKIN_THEME_JSON__", + "__DREAM_SKIN_VIDEO_JSON__", "__DREAM_SKIN_VERSION_JSON__", "__DREAM_SKIN_STYLE_REVISION_JSON__", "__DREAM_SKIN_PAYLOAD_REVISION_JSON__", - ], "all six payload placeholders must still be substituted"); + ], "all seven payload placeholders must still be substituted"); }); test.after(async () => { diff --git a/macos/tests/theme-package-validator.test.mjs b/macos/tests/theme-package-validator.test.mjs index b5531f97..cfec6a94 100644 --- a/macos/tests/theme-package-validator.test.mjs +++ b/macos/tests/theme-package-validator.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -16,7 +17,7 @@ const macosInjector = path.join(macosRoot, "scripts", "injector.mjs"); const windowsInjector = path.join(projectRoot, "windows", "scripts", "injector.mjs"); const importer = path.join(macosRoot, "scripts", "import-theme-zip-macos.sh"); const fixtureImage = path.join(macosRoot, "assets", "portal-hero.png"); -const tempRoot = await fs.mkdtemp(path.join("/tmp", "codex-dream-skin-package-contract-")); +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-dream-skin-package-contract-")); const colors = { background: "#071116", @@ -58,6 +59,17 @@ function digest(bytes) { return createHash("sha256").update(bytes).digest("hex"); } +function mp4Fixture(marker) { + const fileTypeBox = Buffer.alloc(24); + fileTypeBox.writeUInt32BE(fileTypeBox.length, 0); + fileTypeBox.write("ftyp", 4, "ascii"); + fileTypeBox.write("isom", 8, "ascii"); + fileTypeBox.writeUInt32BE(512, 12); + fileTypeBox.write("isom", 16, "ascii"); + fileTypeBox.write("mp41", 20, "ascii"); + return Buffer.concat([fileTypeBox, Buffer.from(marker, "ascii")]); +} + function fileEntry(filePath, mediaType, bytes) { return { path: filePath, mediaType, bytes: bytes.length, sha256: digest(bytes) }; } @@ -75,6 +87,7 @@ async function makeOfficial(name, options = {}) { art: { focusX: 0.7, focusY: 0.5, safeArea: "left", taskMode: "full" }, colors, }; + if (options.videoBytes) theme.video = "background.mp4"; if (options.mutateTheme) options.mutateTheme(theme); const themeData = jsonBytes(theme); const files = [ @@ -95,6 +108,10 @@ async function makeOfficial(name, options = {}) { files.push(fileEntry("LICENSE.txt", "text/plain", license)); extraFiles.set("LICENSE.txt", license); } + if (options.videoBytes) { + files.push(fileEntry("background.mp4", "video/mp4", options.videoBytes)); + extraFiles.set("background.mp4", options.videoBytes); + } if (options.signature) extraFiles.set("manifest.sig", Buffer.from("reserved-signature\n", "utf8")); const manifest = { packageVersion: 1, @@ -172,6 +189,25 @@ try { "theme.json", ]); + const officialVideo = await makeOfficial("official-video", { + videoBytes: mp4Fixture("official"), + }); + const validatedVideo = await validate(officialVideo.source, "windows", "official-video"); + assert.equal(validatedVideo.output.format, "official"); + assert.deepEqual( + await fs.readFile(path.join(validatedVideo.stage, "background.mp4")), + mp4Fixture("official"), + ); + const renamedOfficialVideo = await makeOfficial("official-renamed-video", { + videoBytes: Buffer.from("renamed-as-mp4"), + }); + await expectRejected( + renamedOfficialVideo.source, + "windows", + /not a valid MP4 container/, + "official-renamed-video", + ); + const legacyOfficial = await makeOfficial("legacy-official-no-css", { css: false }); await expectRejected( legacyOfficial.source, @@ -293,6 +329,29 @@ try { const simple = await validate(simpleSource, "macos", "simple"); assert.equal(simple.output.format, "simple"); assert.equal(simple.output.safeCssStatus, "validated"); + + const simpleVideoSource = path.join(tempRoot, "simple-video-source"); + await fs.mkdir(simpleVideoSource); + await fs.copyFile(fixtureImage, path.join(simpleVideoSource, "custom-background.png")); + await fs.copyFile(path.join(simpleSource, "theme.css"), path.join(simpleVideoSource, "theme.css")); + await fs.writeFile(path.join(simpleVideoSource, "background.mp4"), mp4Fixture("simple")); + await fs.writeFile(path.join(simpleVideoSource, "theme.json"), jsonBytes({ + schemaVersion: 1, + id: "local_simple_video", + name: "Local Simplified Video Theme", + image: "custom-background.png", + video: "background.mp4", + })); + const simpleVideo = await validate(simpleVideoSource, "macos", "simple-video"); + assert.equal(simpleVideo.output.format, "simple"); + await fs.writeFile(path.join(simpleVideoSource, "background.mp4"), Buffer.from("renamed-as-mp4")); + await expectRejected( + simpleVideoSource, + "macos", + /not a valid MP4 container/, + "simple-renamed-video", + ); + const simpleWithoutCss = path.join(tempRoot, "simple-without-css"); await fs.mkdir(simpleWithoutCss); await fs.copyFile(fixtureImage, path.join(simpleWithoutCss, "custom-background.png")); diff --git a/macos/tests/theme-stage.test.mjs b/macos/tests/theme-stage.test.mjs index 5f28f552..9f8858c3 100644 --- a/macos/tests/theme-stage.test.mjs +++ b/macos/tests/theme-stage.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; @@ -8,7 +9,18 @@ const here = path.dirname(fileURLToPath(import.meta.url)); const macosRoot = path.resolve(here, ".."); const stageScript = path.join(macosRoot, "scripts", "stage-theme.mjs"); const fixtureAsset = path.join(macosRoot, "assets", "portal-hero.png"); -const tempRoot = await fs.mkdtemp(path.join("/tmp", "codex-dream-skin-stage-")); +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-dream-skin-stage-")); + +function mp4Fixture(marker) { + const fileTypeBox = Buffer.alloc(24); + fileTypeBox.writeUInt32BE(fileTypeBox.length, 0); + fileTypeBox.write("ftyp", 4, "ascii"); + fileTypeBox.write("isom", 8, "ascii"); + fileTypeBox.writeUInt32BE(512, 12); + fileTypeBox.write("isom", 16, "ascii"); + fileTypeBox.write("mp41", 20, "ascii"); + return Buffer.concat([fileTypeBox, Buffer.from(marker, "ascii")]); +} function runStage(source, stage) { return new Promise((resolve, reject) => { @@ -70,6 +82,33 @@ try { const cssIdentity = JSON.parse(await runStage(stage, cssStage)); assert.notEqual(cssIdentity.contentFingerprint, stagedIdentity.contentFingerprint); + const videoSource = path.join(tempRoot, "video-source"); + const videoStage = path.join(tempRoot, "video-stage"); + await fs.mkdir(videoSource); + await fs.mkdir(videoStage); + await fs.copyFile(fixtureAsset, path.join(videoSource, "background.png")); + await fs.writeFile(path.join(videoSource, "background.mp4"), mp4Fixture("stage")); + await fs.writeFile( + path.join(videoSource, "theme.json"), + `${JSON.stringify({ + schemaVersion: 1, + id: "video-stage", + image: "background.png", + video: "background.mp4", + })}\n`, + ); + const videoIdentity = JSON.parse(await runStage(videoSource, videoStage)); + assert.equal(videoIdentity.video, "background.mp4"); + assert.deepEqual( + await fs.readFile(path.join(videoStage, "background.mp4")), + mp4Fixture("stage"), + ); + + await fs.writeFile(path.join(videoSource, "background.mp4"), Buffer.from("renamed-as-mp4")); + const invalidVideoStage = path.join(tempRoot, "invalid-video-stage"); + await fs.mkdir(invalidVideoStage); + await assert.rejects(runStage(videoSource, invalidVideoStage), /not a valid MP4 container/); + const outside = path.join(tempRoot, "outside.png"); await fs.copyFile(fixtureAsset, outside); const traversal = path.join(tempRoot, "traversal"); @@ -84,14 +123,23 @@ try { const symlink = path.join(tempRoot, "symlink"); await fs.mkdir(symlink); - await fs.symlink(outside, path.join(symlink, "background.png")); - await fs.writeFile( - path.join(symlink, "theme.json"), - `${JSON.stringify({ schemaVersion: 1, id: "bad-link", image: "background.png" })}\n`, - ); - const symlinkStage = path.join(tempRoot, "symlink-stage"); - await fs.mkdir(symlinkStage); - await assert.rejects(runStage(symlink, symlinkStage), /symbolic link/); + let symlinkCreated = false; + try { + await fs.symlink(outside, path.join(symlink, "background.png")); + symlinkCreated = true; + } catch (error) { + if (process.platform !== "win32" || error?.code !== "EPERM") throw error; + console.log("SKIP: Windows host does not grant symbolic-link creation; macOS/CI retains this assertion."); + } + if (symlinkCreated) { + await fs.writeFile( + path.join(symlink, "theme.json"), + `${JSON.stringify({ schemaVersion: 1, id: "bad-link", image: "background.png" })}\n`, + ); + const symlinkStage = path.join(tempRoot, "symlink-stage"); + await fs.mkdir(symlinkStage); + await assert.rejects(runStage(symlink, symlinkStage), /symbolic link/); + } console.log("PASS: theme staging snapshots a matched pair and binds it to a stable content fingerprint."); } finally { diff --git a/runtime/dream-skin.css b/runtime/dream-skin.css index b2559ffc..90b675bb 100644 --- a/runtime/dream-skin.css +++ b/runtime/dream-skin.css @@ -1212,3 +1212,135 @@ html[data-dream-skin="active"] [data-message-author-role] { border-radius: 16px; animation-iteration-count: 1 !important; } } + +/* Optional media layer. It is inert for image themes and spans the complete + Codex viewport when a validated MP4 URL is supplied by the injector. */ +#codex-dream-skin-background-stage { + position: fixed; + inset: 0; + z-index: 0; + overflow: hidden; + pointer-events: none; + background: transparent !important; + visibility: hidden; +} + +#codex-dream-skin-background-stage::before { + content: ""; + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; + background-image: var(--ds-task-fade), var(--ds-task-shade); + background-repeat: no-repeat; + background-position: center, center; + background-size: 100% 100%, 100% 100%; +} + +#codex-dream-skin-background-stage img, +#codex-dream-skin-background-stage video { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: var(--ds-art-position, 50% 50%); + pointer-events: none; +} + +#codex-dream-skin-background-stage img { z-index: 1; } +#codex-dream-skin-background-stage video { + z-index: 1; + opacity: 0; +} + +html[data-dream-skin="active"][data-dream-media="video"] + #codex-dream-skin-background-stage { + visibility: visible; +} + +html[data-dream-skin="active"][data-dream-media="video"] + #codex-dream-skin-background-stage video { + opacity: 1; +} + +html[data-dream-skin="active"][data-dream-media="video"] + #codex-dream-skin-background-stage::before { + opacity: .46; +} + +html[data-dream-skin="active"][data-dream-media="video"] + #codex-dream-skin-background-stage video { + filter: brightness(1.15) saturate(1.04); +} + +html[data-dream-skin="active"][data-dream-media="video"] + #codex-dream-skin-background-stage img { + display: none !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] body { + background: transparent !important; + background-image: none !important; +} + +/* The image rules above are intentionally kept for image themes. Once the + video has its first frame, close every static-art entry point explicitly so + a Codex route cannot leave a second copy of the imported image visible. */ +html[data-dream-skin="active"][data-dream-media="video"] + __DREAM_SELECTOR_SHELL_MAIN__::before { + content: none !important; + background-image: none !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] + __DREAM_SELECTOR_HOME_ROUTE__ > div:first-child > div:first-child > div:first-child { + background-image: none !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] body > #root { + position: relative !important; + /* Keep every native task/composer surface above the full-window media stage. + The explicit stacking context also covers Codex's new-task route, whose + transparent wrappers can otherwise participate in the body's auto layer. */ + z-index: 10 !important; + isolation: isolate; + background: transparent !important; +} + +/* Some Codex builds mount dialogs and popovers directly under body instead of + inside #root. Keep those native surfaces above the media stage as well. */ +html[data-dream-skin="active"][data-dream-media="video"] body > + :is([role="dialog"], [aria-modal="true"], [data-radix-portal], + [data-radix-popper-content-wrapper]) { + z-index: 50 !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] + body > #root __DREAM_SELECTOR_LEFT_PANEL__ { + background: + linear-gradient(180deg, + rgb(var(--ds-panel-rgb) / .38), + rgb(var(--ds-bg-rgb) / .28)) !important; + backdrop-filter: blur(8px) saturate(112%) !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] __DREAM_SELECTOR_SHELL_MAIN__ { + background: transparent !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] + __DREAM_SELECTOR_SHELL_MAIN__::before { + content: none !important; +} + +/* Wide image themes also paint artwork directly on body. In video mode the + video stage is the sole artwork source; keep the old body image only for + the image fallback path. */ +html[data-dream-skin="active"][data-dream-media="video"]:is([data-dream-task-mode="ambient"], [data-dream-art-task-mode="ambient"], [data-dream-task-mode="banner"], [data-dream-art-task-mode="banner"])[data-dream-art-wide="true"]:has(__DREAM_SELECTOR_SHELL_MAIN__):not(:has(__DREAM_SELECTOR_SHELL_MAIN__ __DREAM_SELECTOR_HOME_ROUTE_CSS__)) + body, +html[data-dream-skin="active"][data-dream-media="video"][data-dream-art-wide="true"]:has(__DREAM_SELECTOR_SHELL_MAIN__ __DREAM_SELECTOR_HOME_ROUTE_CSS__) + body { + background-image: none !important; +} diff --git a/runtime/renderer-inject.js b/runtime/renderer-inject.js index 4066c711..974540cc 100644 --- a/runtime/renderer-inject.js +++ b/runtime/renderer-inject.js @@ -1,17 +1,19 @@ // Canonical cross-platform renderer. Run tools/sync-runtime-assets.mjs after editing. -((cssText, artDataUrl, themeConfig) => { +((cssText, artDataUrl, themeConfig, videoConfig) => { const SELECTOR_CONTRACT = __DREAM_SKIN_SELECTORS_JSON__; const STATE_KEY = "__CODEX_DREAM_SKIN_STATE__"; const DISABLED_KEY = "__CODEX_DREAM_SKIN_DISABLED__"; const STYLE_REGISTRY_KEY = "__CODEX_DREAM_SKIN_STYLE_SHEETS__"; const STYLE_ID = "codex-dream-skin-style"; + const BACKGROUND_STAGE_ID = "codex-dream-skin-background-stage"; + const VIDEO_INPUT_ID = "codex-dream-skin-video-input"; const SHELL_ATTR = "data-dream-shell"; const PART_ATTR = "data-ds-part"; const ROOT_ATTRS = [ "data-dream-skin", SHELL_ATTR, "data-dream-art-wide", "data-dream-art-safe", "data-dream-task-mode", "data-dream-art-safe-area", "data-dream-art-task-mode", "data-dream-art-aspect", - "data-dream-art-ready", + "data-dream-art-ready", "data-dream-media", "data-dream-video-ready", ]; const VERSION = __DREAM_SKIN_VERSION_JSON__; const STYLE_REVISION = __DREAM_SKIN_STYLE_REVISION_JSON__; @@ -54,6 +56,23 @@ typeof existingAnalysisCache.set === "function" ? existingAnalysisCache : new Map(); window[ANALYSIS_CACHE_KEY] = analysisCache; let artAnalysis = typeof THEME.artKey === "string" ? analysisCache.get(THEME.artKey) ?? null : null; + const normalizedVideoConfig = typeof videoConfig === "string" + ? { mode: "server", url: videoConfig } + : videoConfig && typeof videoConfig === "object" ? videoConfig : null; + const videoUrl = typeof normalizedVideoConfig?.url === "string" + ? normalizedVideoConfig.url : null; + const videoMode = normalizedVideoConfig?.mode === "blob" ? "blob" + : /^https?:\/\//i.test(videoUrl || "") ? "server" : null; + const videoSource = videoMode === "server" ? videoUrl : null; + const videoFallbackSource = typeof normalizedVideoConfig?.fallbackUrl === "string" && + normalizedVideoConfig.fallbackUrl ? normalizedVideoConfig.fallbackUrl : null; + const videoEnabled = videoMode === "blob" || Boolean(videoSource); + let backgroundState = null; + let handedOffVideo = null; + let videoGeneration = 0; + let videoFailed = false; + let videoReady = false; + let videoError = null; let analysisTimer = null; let rootObserver = null; let partObserver = null; @@ -61,6 +80,7 @@ let styleMode = null; let styleNode = null; let styleSheet = null; + const mediaTransitions = []; const now = () => typeof performance === "object" && typeof performance.now === "function" ? performance.now() : Date.now(); const metrics = { @@ -82,6 +102,9 @@ }; const previous = window[STATE_KEY]; + const previousHandoff = videoEnabled && typeof previous?.handoffVideo === "function" + ? previous.handoffVideo() : null; + if (previousHandoff) handedOffVideo = { ...previousHandoff, preserve: true }; if (typeof previous?.cleanup === "function") previous.cleanup(); window[DISABLED_KEY] = false; @@ -353,6 +376,411 @@ setStyleProperty(root, "--ds-theme-image-focus-y", String(Number(focusY.toFixed(4)))); }; + const detachBackgroundVideoListeners = (state) => { + if (!state) return; + if (state.retryTimer) { + clearTimeout(state.retryTimer); + state.retryTimer = null; + } + try { state.video.removeEventListener("error", state.onError); } catch {} + try { state.video.removeEventListener("loadeddata", state.onReady); } catch {} + try { state.video.removeEventListener("playing", state.onReady); } catch {} + try { state.video.removeEventListener("ended", state.onEnded); } catch {} + try { state.video.removeEventListener("stalled", state.onStalled); } catch {} + try { state.video.removeEventListener("abort", state.onAbort); } catch {} + try { state.video.removeEventListener("emptied", state.onEmptied); } catch {} + try { state.video.removeEventListener("timeupdate", state.onTimeUpdate); } catch {} + try { document.removeEventListener("visibilitychange", state.onVisibility); } catch {} + }; + + const removeBackgroundStage = () => { + const state = backgroundState; + backgroundState = null; + if (!state) { + document.getElementById(BACKGROUND_STAGE_ID)?.remove(); + if (handedOffVideo) { + try { handedOffVideo.video.pause(); handedOffVideo.video.remove(); } catch {} + if (handedOffVideo.objectUrl) { + try { URL.revokeObjectURL(handedOffVideo.objectUrl); } catch {} + } + handedOffVideo = null; + } + return; + } + if (handedOffVideo?.video === state.video) { + // Preserve only the decoded frame and object URL. The superseded runtime + // must not keep visibility/media listeners that can restart this node. + detachBackgroundVideoListeners(state); + handedOffVideo = null; + return; + } + try { state.video.pause(); } catch {} + detachBackgroundVideoListeners(state); + try { state.video.removeAttribute("src"); state.video.load(); } catch {} + if (state.objectUrl) { + try { URL.revokeObjectURL(state.objectUrl); } catch {} + } + state.stage.remove(); + if (handedOffVideo) { + try { handedOffVideo.video.pause(); handedOffVideo.video.remove(); } catch {} + if (handedOffVideo.objectUrl) { + try { URL.revokeObjectURL(handedOffVideo.objectUrl); } catch {} + } + handedOffVideo = null; + } + }; + + const captureVideoError = (error = null, video = backgroundState?.video) => { + videoError = { + name: error?.name || null, + message: error?.message || null, + mediaCode: video?.error?.code ?? null, + readyState: video?.readyState ?? null, + networkState: video?.networkState ?? null, + }; + }; + + const isCurrentVideoState = (state) => Boolean( + state && backgroundState === state && state.generation === videoGeneration, + ); + + const fallbackToImage = (error = null, expectedState = null) => { + if (expectedState && !isCurrentVideoState(expectedState)) return false; + if (videoFailed) return; + captureVideoError(error, expectedState?.video ?? backgroundState?.video); + videoFailed = true; + videoReady = false; + removeBackgroundStage(); + const root = document.documentElement; + if (root) applyRootState(root); + return true; + }; + + const revealVideo = (state) => { + if (!isCurrentVideoState(state) || videoFailed || videoReady) return; + // The replacement element stays inline-hidden while the verified previous + // video remains visible. Reveal it only after Chromium produced a frame. + try { state.video.style.opacity = ""; } catch {} + // The poster is useful only before the first decoded frame. Keeping it on + // a live element lets Chromium expose the static image again during a + // transient stall or buffer reset, which looks like image/video flashing. + try { state.video.removeAttribute("poster"); } catch {} + videoReady = true; + const root = document.documentElement; + if (root) applyRootState(root); + if (handedOffVideo) { + const oldVideo = handedOffVideo.video; + const oldObjectUrl = handedOffVideo.objectUrl; + handedOffVideo = null; + try { oldVideo.pause(); oldVideo.remove(); } catch {} + if (oldObjectUrl) { + try { URL.revokeObjectURL(oldObjectUrl); } catch {} + } + } + }; + + const isTransientBackgroundPause = (error, video) => { + const hidden = document.visibilityState === "hidden" || document.hidden; + if (!hidden) return false; + const message = String(error?.message || "").toLowerCase(); + if (error?.name === "AbortError" && /background media|save power|paused/.test(message)) return true; + // Chromium can emit an empty media error while the app target reports + // hidden, even though the loopback response is valid. Treat that event as + // transient and retry instead of exposing the fallback image. + if (!video?.error && !message) return true; + return !video?.error && /failed to fetch|network|load|media/.test(message); + }; + + const isTransientVideoError = (error, video) => { + if (isTransientBackgroundPause(error, video)) return true; + const code = video?.error?.code ?? null; + // MEDIA_ERR_ABORTED, MEDIA_ERR_NETWORK, and Chromium's empty error object + // are recoverable while a source is being replaced or the page is hidden. + return code === null || code === 1 || code === 2; + }; + + const retryTransientVideo = (state) => { + if (!state || videoFailed || !state.video.src || state.retryCount >= 20) return false; + if (document.visibilityState === "hidden" || document.hidden) { + // Chromium may suspend an already-valid media element for as long as + // the renderer stays hidden. Do not spend the finite retry budget while + // playback is intentionally unavailable; visibilitychange resumes it. + state.pendingPlay = true; + return true; + } + if (state.retryTimer) return true; + state.retryCount += 1; + state.retryTimer = setTimeout(() => { + state.retryTimer = null; + if (!isCurrentVideoState(state) || videoFailed || !state.video.src) return; + if (document.visibilityState === "hidden" || document.hidden) { + state.pendingPlay = true; + return; + } + Promise.resolve(state.video.play?.()).then(() => { + if (!isCurrentVideoState(state) || videoFailed) return; + state.pendingPlay = false; + revealVideo(state); + }).catch(state.onError); + }, 500); + return true; + }; + + const ensureVideoInput = () => { + if (videoMode !== "blob" || !document?.createElement) return null; + let input = document.getElementById(VIDEO_INPUT_ID); + if (input) return input; + input = document.createElement("input"); + input.type = "file"; + input.id = VIDEO_INPUT_ID; + input.accept = "video/mp4"; + input.tabIndex = -1; + input.setAttribute("aria-hidden", "true"); + Object.assign(input.style, { + position: "fixed", + width: "1px", + height: "1px", + opacity: "0", + pointerEvents: "none", + }); + (document.body || document.documentElement)?.appendChild(input); + return input; + }; + + const mediaTokenFromUrl = (source) => { + try { + const parsed = new URL(source); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + return /^\/media\/([a-f0-9]{32})$/i.exec(parsed.pathname)?.[1] ?? null; + } catch { + return null; + } + }; + + const playVideoObjectUrl = async (state, objectUrl) => { + if (!isCurrentVideoState(state) || videoFailed) return false; + if (state.objectUrl && state.objectUrl !== objectUrl) { + try { URL.revokeObjectURL(state.objectUrl); } catch {} + } + state.objectUrl = objectUrl; + state.video.src = objectUrl; + state.video.load(); + if (document.visibilityState === "hidden" || document.hidden) { + state.pendingPlay = true; + return true; + } + if (state.video.readyState < 1) { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error("Video metadata load timed out")); + }, 15000); + const cleanup = () => { + clearTimeout(timer); + state.video.removeEventListener("loadedmetadata", onMetadata); + state.video.removeEventListener("error", onError); + }; + const onMetadata = () => { cleanup(); resolve(); }; + const onError = () => { cleanup(); reject(new Error("Video metadata could not be loaded")); }; + state.video.addEventListener("loadedmetadata", onMetadata, { once: true }); + state.video.addEventListener("error", onError, { once: true }); + }); + } + await state.video.play(); + if (!isCurrentVideoState(state) || videoFailed) return false; + state.pendingPlay = false; + // play() is the fallback for shells that do not expose a usable + // loadeddata/playing event to the injected runtime. + revealVideo(state); + return true; + }; + + const loadVideoFromUrl = async (state, source) => { + if (!state || state.loading) return false; + state.loading = true; + try { + const token = mediaTokenFromUrl(source); + const headers = token ? { "X-Codex-Dream-Skin-Token": token } : undefined; + const response = await fetch(source, { credentials: "omit", headers }); + if (!response.ok) throw new Error("Video request failed: " + response.status); + const blob = await response.blob(); + if (!isCurrentVideoState(state) || videoFailed) return false; + return playVideoObjectUrl(state, URL.createObjectURL(blob)); + } finally { + state.loading = false; + } + }; + + const fallbackToServer = async (expectedState = null) => { + const state = expectedState ?? backgroundState; + if (!isCurrentVideoState(state) || !videoFallbackSource || state.fallbackStarted) return false; + state.fallbackStarted = true; + try { + return await loadVideoFromUrl(state, videoFallbackSource); + } catch (error) { + fallbackToImage(error, state); + return false; + } + }; + + const handleVideoFailure = (state, error = null) => { + if (!isCurrentVideoState(state) || videoFailed) return false; + captureVideoError(error, state.video); + if (isTransientVideoError(error, state.video) && retryTransientVideo(state)) { + state.pendingPlay = true; + return false; + } + if (videoFallbackSource && !state.fallbackStarted) { + void fallbackToServer(state); + return false; + } + return fallbackToImage(error, state); + }; + + const attachVideoFile = async () => { + if (videoMode !== "blob") return false; + const input = ensureVideoInput(); + ensureBackgroundStage(); + const state = backgroundState; + const file = input?.files?.[0]; + if (!state || !file) return false; + try { + await playVideoObjectUrl(state, URL.createObjectURL(file)); + applyRootState(document.documentElement); + return true; + } catch (error) { + return handleVideoFailure(state, error); + } + }; + + const ensureBackgroundStage = () => { + if (!videoEnabled || videoFailed || !document?.createElement) return; + ensureVideoInput(); + if (backgroundState?.stage?.parentElement) return; + const previousVideo = handedOffVideo?.video?.parentElement ? handedOffVideo.video : null; + const stage = handedOffVideo?.stage?.parentElement + ? handedOffVideo.stage + : document.getElementById(BACKGROUND_STAGE_ID) || document.createElement("div"); + stage.id = BACKGROUND_STAGE_ID; + // A failed or superseded handoff can leave an older video in the shared + // stage. Keep at most the one explicitly handed off by the immediately + // previous runtime; two playable nodes can alternate frames and posters. + for (const child of [...(stage.children || [])]) { + if (child === previousVideo) continue; + const staleSource = typeof child.src === "string" ? child.src : ""; + try { child.pause?.(); } catch {} + try { child.removeAttribute?.("src"); child.load?.(); } catch {} + try { child.remove?.(); } catch {} + if (staleSource.startsWith("blob:")) { + try { URL.revokeObjectURL(staleSource); } catch {} + } + } + if (!previousVideo) stage.replaceChildren?.(); + if (previousVideo && !stage.parentElement) { + const parent = document.body || document.documentElement; + if (!parent) return; + if (typeof parent.prepend === "function") parent.prepend(stage); + else parent.appendChild(stage); + } + // The video is a viewport background, not a main-surface decoration. Keep + // it under the app root so the sidebar, main content, and native overlays + // all share one continuous moving background. + const parent = stage.parentElement || document.body || document.documentElement; + if (!parent) return; + + const video = document.createElement("video"); + video.muted = true; + video.defaultMuted = true; + video.autoplay = true; + video.loop = true; + video.playsInline = true; + video.preload = "auto"; + video.poster = artUrl; + video.setAttribute("aria-hidden", "true"); + // `data-dream-media="video"` intentionally remains set during a ready + // handoff so CSS keeps the previous video visible. Prevent that selector + // from exposing the pending replacement or its poster before first frame. + video.style.opacity = "0"; + const onError = (error = null) => { + // A detached video can still deliver abort/error after a new theme has + // installed. Its event must never fail the newer media generation. + if (!isCurrentVideoState(state)) return; + handleVideoFailure(state, error); + }; + const onVisibility = () => { + if (videoFailed || document.visibilityState === "hidden" || document.hidden) return; + if (videoMode === "server" && !state.loading && !video.src) { + void loadVideoFromUrl(state, videoSource).catch(onError); + return; + } + if (video.src) { + Promise.resolve(video.play?.()).then(() => { + state.pendingPlay = false; + revealVideo(state); + }).catch(onError); + } + }; + const onReady = () => revealVideo(state); + const onEnded = () => { + if (videoFailed || backgroundState !== state || document.visibilityState === "hidden" || document.hidden) return; + state.pendingPlay = true; + try { video.currentTime = 0; } catch {} + Promise.resolve(video.play?.()).then(() => { + state.pendingPlay = false; + revealVideo(state); + }).catch(onError); + }; + const onStalled = () => { + if (videoFailed || !isCurrentVideoState(state)) return; + state.pendingPlay = true; + retryTransientVideo(state); + }; + const onAbort = () => { + if (videoFailed || !isCurrentVideoState(state)) return; + state.pendingPlay = true; + retryTransientVideo(state); + }; + const onEmptied = () => { + if (videoFailed || !isCurrentVideoState(state) || !video.src) return; + state.pendingPlay = true; + retryTransientVideo(state); + }; + const onTimeUpdate = () => { + if (videoFailed || !isCurrentVideoState(state)) return; + const currentTime = Number(video.currentTime); + if (!Number.isFinite(currentTime)) return; + if (currentTime < state.lastPlaybackTime || + currentTime - state.lastPlaybackTime >= 0.1) { + state.lastPlaybackTime = currentTime; + state.retryCount = 0; + state.pendingPlay = false; + } + }; + const state = { + stage, video, onError, onVisibility, onReady, onEnded, onStalled, onAbort, onEmptied, + onTimeUpdate, + generation: ++videoGeneration, + objectUrl: null, fallbackStarted: false, retryTimer: null, retryCount: 0, + loading: false, pendingPlay: false, lastPlaybackTime: -1, + }; + backgroundState = state; + video.addEventListener("error", onError); + video.addEventListener("loadeddata", onReady); + video.addEventListener("playing", onReady); + video.addEventListener("ended", onEnded); + video.addEventListener("stalled", onStalled); + video.addEventListener("abort", onAbort); + video.addEventListener("emptied", onEmptied); + video.addEventListener("timeupdate", onTimeUpdate); + document.addEventListener("visibilitychange", onVisibility); + stage.appendChild(video); + if (typeof parent.prepend === "function") parent.prepend(stage); + else parent.appendChild(stage); + if (videoMode === "server") { + void loadVideoFromUrl(backgroundState, videoSource).catch(onError); + } + }; + const analyzeArt = () => new Promise((resolve) => { const startedAt = now(); metrics.analysisRuns += 1; @@ -550,7 +978,28 @@ const shell = resolvedShell(); setAttribute(root, "data-dream-skin", "active"); setAttribute(root, SHELL_ATTR, shell); - setStyleProperty(root, "--dream-skin-art", `url("${artUrl}")`); + const mediaReady = videoEnabled && !videoFailed && videoReady; + const handoffReady = videoEnabled && !videoFailed && + handedOffVideo?.ready === true && Boolean(handedOffVideo.video?.parentElement); + const mediaState = mediaReady || handoffReady + ? "video" + : videoEnabled && !videoFailed ? "video-pending" : "image-fallback"; + const previousMedia = mediaTransitions[mediaTransitions.length - 1]; + if (!previousMedia || previousMedia.state !== mediaState || + previousMedia.videoReady !== videoReady) { + mediaTransitions.push({ + state: mediaState, + videoReady, + handoffReady, + at: Date.now(), + }); + if (mediaTransitions.length > 16) mediaTransitions.shift(); + } + setAttribute(root, "data-dream-media", mediaState); + setAttribute(root, "data-dream-video-ready", videoReady ? "true" : "false"); + // Keep the poster image visible until the video has produced a frame. This + // avoids a blank flash while a replacement media source is loading. + setStyleProperty(root, "--dream-skin-art", mediaState === "video" ? "none" : `url("${artUrl}")`); applyTheme(root, shell); applyArtMetadata(root); return shell; @@ -664,7 +1113,10 @@ const root = document.documentElement; if (!root) return; metrics.ensureCalls += 1; + // Commit the media mode before touching the stage. This prevents the + // previous body image from being painted during a video-theme swap. if (rootPass) applyRootState(root); + if (rootPass) ensureBackgroundStage(); if (partPass) refreshParts(); if (scopePass) refreshScope(); }; @@ -693,6 +1145,8 @@ if (state?.timer) clearInterval(state.timer); if (state?.scheduler?.timeout) clearTimeout(state.scheduler.timeout); if (analysisTimer) clearTimeout(analysisTimer); + removeBackgroundStage(); + document.getElementById(VIDEO_INPUT_ID)?.remove(); if (state?.mediaHandler && state?.mediaQuery) { try { state.mediaQuery.removeEventListener("change", state.mediaHandler); } catch {} } @@ -762,6 +1216,26 @@ navigation: navigationApi, navigationHandler, artUrl, + videoUrl: videoSource, + videoMode, + get videoFailed() { return videoFailed; }, + get videoReady() { return videoReady; }, + get videoError() { return videoError; }, + mediaTransitions, + ensureVideoInput, + attachVideoFile, + useVideoFallback: fallbackToServer, + handoffVideo: () => { + const state = backgroundState; + if (!state?.stage?.parentElement || !state.video?.parentElement) return null; + handedOffVideo = { + stage: state.stage, + video: state.video, + objectUrl: state.objectUrl, + ready: videoReady && !videoFailed, + }; + return { ...handedOffVideo }; + }, installToken, styleMode, styleNode, @@ -842,4 +1316,4 @@ styleMode, analysis: artAnalysis, }; -})(__DREAM_SKIN_CSS_JSON__, __DREAM_SKIN_ART_JSON__, __DREAM_SKIN_THEME_JSON__) +})(__DREAM_SKIN_CSS_JSON__, __DREAM_SKIN_ART_JSON__, __DREAM_SKIN_THEME_JSON__, __DREAM_SKIN_VIDEO_JSON__) diff --git a/runtime/theme-package-validator.mjs b/runtime/theme-package-validator.mjs index 275fcfd1..e9fd6f2b 100644 --- a/runtime/theme-package-validator.mjs +++ b/runtime/theme-package-validator.mjs @@ -14,6 +14,7 @@ const LIMITS = Object.freeze({ simpleTheme: 1_048_576, css: 262_144, image: 10_485_760, + video: 100 * 1024 * 1024, license: 65_536, signature: 4_096, }); @@ -23,9 +24,13 @@ const BACKGROUND_MEDIA = new Map([ ["background.jpg", "image/jpeg"], ["background.png", "image/png"], ]); +const VIDEO_MEDIA = new Map([ + ["background.mp4", "video/mp4"], +]); const PAYLOAD_MEDIA = new Map([ ["theme.json", "application/json"], ...BACKGROUND_MEDIA, + ...VIDEO_MEDIA, ["theme.css", "text/css"], ["LICENSE.txt", "text/plain"], ]); @@ -206,6 +211,7 @@ function expectedLimit(name, simple = false) { if (name === "theme.css") return LIMITS.css; if (name === "LICENSE.txt") return LIMITS.license; if (name === "manifest.sig") return LIMITS.signature; + if (VIDEO_MEDIA.has(name)) return LIMITS.video; if (BACKGROUND_MEDIA.has(name) || /\.(?:png|jpe?g|webp)$/i.test(name)) return LIMITS.image; return 0; } @@ -273,7 +279,7 @@ function validateOfficialTheme(value) { assertExactKeys( theme, THEME_REQUIRED, - [...THEME_COPY_KEYS, "promoUrl", "appearance", "art", "colors"], + [...THEME_COPY_KEYS, "promoUrl", "appearance", "art", "colors", "video"], "theme.json", ); if (theme.schemaVersion !== 1) fail("theme.json must use schemaVersion 1"); @@ -281,6 +287,10 @@ function validateOfficialTheme(value) { assertString(theme.name, "theme.json.name", { min: 1, max: 80 }); assertString(theme.image, "theme.json.image", { min: 1, max: 32, controls: null }); if (!BACKGROUND_MEDIA.has(theme.image)) fail("theme.json.image must name one registered background file"); + if (theme.video !== undefined) { + assertString(theme.video, "theme.json.video", { min: 1, max: 32, controls: null }); + if (!VIDEO_MEDIA.has(theme.video)) fail("theme.json.video must name background.mp4"); + } for (const key of THEME_COPY_KEYS) { if (theme[key] !== undefined) assertString(theme[key], `theme.json.${key}`, { max: 120 }); } @@ -407,6 +417,9 @@ function validateManifest(value, platform, clientVersion) { if (!files.has("theme.json") || backgrounds.length !== 1) { fail("manifest.files must contain theme.json and exactly one background file"); } + if (files.has("background.mp4") && !files.has("theme.json")) { + fail("manifest.files video requires theme.json"); + } if (files.has("theme.css") !== capabilities.has("safe-css")) { fail("theme.css presence must match the safe-css capability"); } @@ -440,6 +453,15 @@ function detectedImageMedia(bytes) { return ""; } +function isMp4Container(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.length < 16) return false; + const view = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const firstBoxSize = view.readUInt32BE(0); + return firstBoxSize >= 16 + && firstBoxSize <= view.length + && view.subarray(4, 8).toString("ascii") === "ftyp"; +} + async function validateOfficial(root, names, platform, clientVersion) { for (const name of names) { if (!PACKAGE_FILES.has(name)) fail(`Official theme package contains unregistered file ${name}`); @@ -465,6 +487,14 @@ async function validateOfficial(root, names, platform, clientVersion) { const theme = validateOfficialTheme(decodeJson(bytes.get("theme.json"), "theme.json")); if (manifest.themeId !== theme.id) fail("manifest.themeId does not match theme.json id"); if (theme.image !== background) fail("theme.json image does not match the manifest background file"); + if (theme.video !== undefined) { + if (!files.has(theme.video) || theme.video !== "background.mp4") { + fail("theme.json video does not match the manifest video file"); + } + if (!isMp4Container(bytes.get(theme.video))) { + fail("background.mp4 content is not a valid MP4 container"); + } + } if (detectedImageMedia(bytes.get(background)) !== BACKGROUND_MEDIA.get(background)) { fail(`${background} content does not match its extension and mediaType`); } @@ -479,7 +509,7 @@ async function validateOfficial(root, names, platform, clientVersion) { } async function validateSimple(root, names) { - if (names.length !== 3 || !names.includes("theme.json") || !names.includes("theme.css")) { + if ((names.length !== 3 && names.length !== 4) || !names.includes("theme.json") || !names.includes("theme.css")) { fail("Local simplified ZIP must contain exactly theme.json, theme.css, and its image"); } const themeBytes = await readStableFile(root, "theme.json", LIMITS.simpleTheme); @@ -493,9 +523,18 @@ async function validateSimple(root, names) { || !/\.(?:png|jpe?g|webp)$/i.test(theme.image) || !names.includes(theme.image) ) fail("Local simplified theme image must be beside theme.json"); - const [imageBytes, cssBytes] = await Promise.all([ + const hasVideo = theme.video !== undefined; + if (hasVideo && ( + path.basename(theme.video) !== theme.video + || CONTROL_PATTERN.test(theme.video) + || theme.video !== "background.mp4" + || !names.includes(theme.video) + )) fail("Local simplified theme video must be background.mp4 beside theme.json"); + if (names.length !== (hasVideo ? 4 : 3)) fail("Local simplified ZIP contains unsupported files"); + const [imageBytes, cssBytes, videoBytes] = await Promise.all([ readStableFile(root, theme.image, LIMITS.image), readStableFile(root, "theme.css", LIMITS.css), + hasVideo ? readStableFile(root, theme.video, LIMITS.video) : Promise.resolve(null), ]); const expectedMedia = /\.png$/i.test(theme.image) ? "image/png" @@ -503,6 +542,9 @@ async function validateSimple(root, names) { if (detectedImageMedia(imageBytes) !== expectedMedia) { fail(`${theme.image} content does not match its extension`); } + if (videoBytes && !isMp4Container(videoBytes)) { + fail("background.mp4 content is not a valid MP4 container"); + } decodeAndValidateSafeCss(cssBytes); return { format: "simple", @@ -513,6 +555,7 @@ async function validateSimple(root, names) { ["theme.json", themeBytes], [theme.image, imageBytes], ["theme.css", cssBytes], + ...(videoBytes ? [["background.mp4", videoBytes]] : []), ]), }; } diff --git a/tools/renderer-runtime.test.mjs b/tools/renderer-runtime.test.mjs index c0492567..199319f3 100644 --- a/tools/renderer-runtime.test.mjs +++ b/tools/renderer-runtime.test.mjs @@ -27,7 +27,7 @@ function classList(initial) { }; } -function makeFixture({ nativeAppearance = "dark", settings = false, adopted = true } = {}) { +function makeFixture({ nativeAppearance = "dark", settings = false, adopted = true, autoMetadata = false } = {}) { const attrs = new Map(); const rootStyle = styleDeclaration(); const rootClasses = classList([nativeAppearance === "dark" ? "electron-dark" : "electron-light"]); @@ -39,6 +39,8 @@ function makeFixture({ nativeAppearance = "dark", settings = false, adopted = tr const intervals = new Map(); const listeners = new Map(); const revoked = []; + const fetchCalls = []; + let documentHidden = false; let nextId = 0; let nextBlob = 0; const attributesFor = (values) => [...values].map(([name, value]) => ({ name, value })); @@ -63,6 +65,11 @@ function makeFixture({ nativeAppearance = "dark", settings = false, adopted = tr if (node.id) nodes.set(node.id, node); return node; }; + const shell = { + children: [], + prepend(node) { node.parentElement = shell; shell.children.unshift(node); if (node.id) nodes.set(node.id, node); return node; }, + appendChild(node) { node.parentElement = shell; shell.children.push(node); if (node.id) nodes.set(node.id, node); return node; }, + }; const body = makeDomNode("body", root); body.appendChild = (node) => { node.parentElement = body; @@ -109,17 +116,75 @@ function makeFixture({ nativeAppearance = "dark", settings = false, adopted = tr }; return node; }; + const makeMediaNode = (tagName) => { + const node = { + tagName, + id: "", + parentElement: null, + dataset: {}, + style: styleDeclaration(), + children: [], + listeners: new Map(), + eventListeners: new Map(), + src: "", + readyState: autoMetadata ? 1 : 0, + loadCalls: 0, + appendChild(child) { child.parentElement = node; node.children.push(child); if (child.id) nodes.set(child.id, child); return child; }, + replaceChildren(...children) { + for (const child of node.children) child.parentElement = null; + node.children = []; + for (const child of children) node.appendChild(child); + }, + setAttribute(name, value) { node[name] = String(value); }, + removeAttribute(name) { delete node[name]; }, + addEventListener(type, callback) { + const callbacks = node.eventListeners.get(type) || new Set(); + callbacks.add(callback); + node.eventListeners.set(type, callbacks); + node.listeners.set(type, callback); + }, + removeEventListener(type, callback) { + const callbacks = node.eventListeners.get(type); + if (!callbacks) return; + callbacks.delete(callback); + if (callbacks.size === 0) { + node.eventListeners.delete(type); + node.listeners.delete(type); + } else { + node.listeners.set(type, [...callbacks].at(-1)); + } + }, + pause() {}, + load() { node.loadCalls += 1; }, + play() { return Promise.resolve(); }, + remove() { + if (node.id) nodes.delete(node.id); + const parent = node.parentElement; + if (Array.isArray(parent?.children)) { + const index = parent.children.indexOf(node); + if (index >= 0) parent.children.splice(index, 1); + } + node.parentElement = null; + }, + }; + return node; + }; const document = { + get hidden() { return documentHidden; }, + get visibilityState() { return documentHidden ? "hidden" : "visible"; }, documentElement: root, head: root, body, adoptedStyleSheets: adopted ? [] : undefined, - createElement(tag) { return tag === "style" ? makeStyleNode() : { tagName: tag }; }, + addEventListener(type, callback) { listeners.set(`document:${type}`, callback); }, + removeEventListener(type) { listeners.delete(`document:${type}`); }, + createElement(tag) { return tag === "style" ? makeStyleNode() : makeMediaNode(tag); }, getElementById(id) { return nodes.get(id) || null; }, querySelector(selector) { if (settings && (selector.includes("appearance-theme") || selector.includes("theme-preview"))) { return makeDomNode(`settings:${selector}`, body); } + if (selector === "main.main-surface") return shell; return (selectorNodes.get(selector) || [])[0] || null; }, querySelectorAll(selector) { @@ -153,6 +218,9 @@ function makeFixture({ nativeAppearance = "dark", settings = false, adopted = tr addEventListener() {}, removeEventListener() {}, }; + const FixtureURL = class extends globalThis.URL {}; + FixtureURL.createObjectURL = () => { nextBlob += 1; return `blob:fixture-${nextBlob}`; }; + FixtureURL.revokeObjectURL = (value) => { revoked.push(value); }; const context = { window, document, @@ -161,18 +229,22 @@ function makeFixture({ nativeAppearance = "dark", settings = false, adopted = tr Blob, Uint8Array, atob, - URL: { - createObjectURL() { nextBlob += 1; return `blob:fixture-${nextBlob}`; }, - revokeObjectURL(value) { revoked.push(value); }, - }, + URL: FixtureURL, performance: { now: () => 1 }, setTimeout(callback, delay) { const id = ++nextId; timers.set(id, { callback, delay }); return id; }, clearTimeout(id) { timers.delete(id); }, setInterval(callback, delay) { const id = ++nextId; intervals.set(id, { callback, delay }); return id; }, clearInterval(id) { intervals.delete(id); }, console, + fetch: async (...args) => { + fetchCalls.push(args); + return { + ok: true, + blob: async () => new Blob(["fixture-video"], { type: "video/mp4" }), + }; + }, }; - const payloadFor = (theme = {}) => { + const payloadFor = (theme = {}, videoUrl = null) => { const template = fixture.template; return template .replace("__DREAM_SKIN_CSS_JSON__", JSON.stringify(".fixture { color: red; }")) @@ -180,7 +252,8 @@ function makeFixture({ nativeAppearance = "dark", settings = false, adopted = tr .replace("__DREAM_SKIN_THEME_JSON__", JSON.stringify({ id: "fixture", appearance: "auto", ...theme })) .replace("__DREAM_SKIN_VERSION_JSON__", JSON.stringify("test")) .replace("__DREAM_SKIN_STYLE_REVISION_JSON__", JSON.stringify("css-rev")) - .replace("__DREAM_SKIN_PAYLOAD_REVISION_JSON__", JSON.stringify("payload-rev")); + .replace("__DREAM_SKIN_PAYLOAD_REVISION_JSON__", JSON.stringify("payload-rev")) + .replace("__DREAM_SKIN_VIDEO_JSON__", JSON.stringify(videoUrl)); }; const flushTimers = (maximumDelay = Infinity) => { for (const [id, timer] of [...timers]) { @@ -192,9 +265,14 @@ function makeFixture({ nativeAppearance = "dark", settings = false, adopted = tr register('[data-message-author-role]', node); return node; }; + const setVisibility = (hidden) => { + documentHidden = Boolean(hidden); + listeners.get("document:visibilitychange")?.(); + }; return { - addDynamicMessage, attrs, context, document, domNodes, flushTimers, intervals, listeners, - nodes, observers, partFixtures, payloadFor, revoked, root, rootClasses, rootStyle, timers, window, + addDynamicMessage, attrs, context, document, domNodes, fetchCalls, flushTimers, intervals, listeners, + nodes, observers, partFixtures, payloadFor, revoked, root, rootClasses, rootStyle, setVisibility, + shell, timers, window, }; } @@ -252,6 +330,12 @@ export async function runRendererRuntimeTest(assetRoot) { // and the measured fossil selector must be absent from the canonical CSS. assert.doesNotMatch(css, /(?:^|[.#\s])(?:codex-dream-skin|dream-skin-home|dream-home|dream-task)(?:[\s.#:{>]|$)|home-suggestion-list-item/); assert.match(css, /html\[data-dream-skin="active"\]/); + assert.match(css, /data-dream-media="video"[\s\S]*background-image: none !important/, + "Video mode must explicitly disable the static-art background rules."); + assert.match(template, /video-pending/, + "Video themes must expose a pending state while the first frame is loading."); + assert.match(template, /videoGeneration/, + "Video event handlers must isolate stale media generations."); // Home gating must stay single-level: CSS forbids :has() inside :has(), // and Chromium drops any rule that nests it (the v1.3.1 regression). The // canonical CSS therefore gates on the :has()-free home-route-css alias. @@ -270,7 +354,8 @@ export async function runRendererRuntimeTest(assetRoot) { const unscoped = unscopedCssRules(css).join("\n"); assert.doesNotMatch(unscoped, /\[role="main"\]:has\(\[data-testid="home-icon"\]\)/); assert.doesNotMatch(unscoped, /\.group\\\/project-selector/); - + assert.match(css, /html\[data-dream-skin="active"\][\s\S]*\[role="main"\]:has\(\[data-testid="home-icon"\]\)[^{}]*[\s\S]*\.group\\\/project-selector/, + "Project-picker styling must remain scoped to the home route."); const home = makeFixture({ nativeAppearance: "dark" }); vm.runInNewContext(home.payloadFor({ art: { safeArea: "left", taskMode: "banner" } }), home.context); const state = home.window.__CODEX_DREAM_SKIN_STATE__; @@ -331,6 +416,152 @@ export async function runRendererRuntimeTest(assetRoot) { home.flushTimers(80); assert.equal(dynamicMessage.getAttribute("data-ds-part"), "message"); + const video = makeFixture({ nativeAppearance: "dark" }); + vm.runInNewContext(video.payloadFor({}, "http://127.0.0.1:1234/media/0123456789abcdef0123456789abcdef"), video.context); + const videoState = video.window.__CODEX_DREAM_SKIN_STATE__; + const stage = video.nodes.get("codex-dream-skin-background-stage"); + assert.equal(video.attrs.get("data-dream-media"), "video-pending"); + assert.equal(video.attrs.get("data-dream-video-ready"), "false"); + assert.ok(stage, "Video must mount a dedicated background stage"); + assert.equal(stage.children.length, 1, "Video mode must use one media element"); + assert.equal(stage.children[0].tagName, "video"); + assert.equal(stage.children[0].loop, true, "Video backgrounds must loop natively"); + for (let attempt = 0; attempt < 24 && !stage.children[0].src; attempt += 1) { + await Promise.resolve(); + } + assert.match(stage.children[0].src, /^blob:fixture-/); + stage.children[0].listeners.get("loadedmetadata")(); + await Promise.resolve(); + stage.children[0].listeners.get("loadeddata")(); + assert.equal(video.attrs.get("data-dream-media"), "video"); + assert.equal(video.attrs.get("data-dream-video-ready"), "true"); + assert.equal("poster" in stage.children[0], false, + "The poster must be removed after the first decoded frame."); + assert.equal(stage.children[0].style.opacity, "", + "The first ready video must clear its pending inline visibility guard."); + assert.deepEqual( + [...videoState.mediaTransitions].map((entry) => entry.state), + ["video-pending", "video"], + "Initial video load must expose one pending state followed by the ready state.", + ); + const transientAbort = stage.children[0].listeners.get("abort"); + const loadCallsBeforeAbort = stage.children[0].loadCalls; + transientAbort(); + assert.equal(video.attrs.get("data-dream-media"), "video", + "A transient abort must not expose the image fallback."); + video.flushTimers(500); + await Promise.resolve(); + assert.equal(stage.children[0].loadCalls, loadCallsBeforeAbort, + "A transient abort must resume playback without reloading the source and exposing its poster."); + const endedHandler = stage.children[0].listeners.get("ended"); + assert.equal(typeof endedHandler, "function", "Video backgrounds must recover from an unexpected ended event"); + endedHandler(); + await Promise.resolve(); + assert.equal(video.attrs.get("data-dream-video-ready"), "true"); + assert.equal(video.attrs.get("data-dream-media"), "video"); + assert.equal(video.attrs.get("data-dream-video-ready"), "true"); + assert.match(stage.children[0].src, /^blob:fixture-/); + assert.equal(video.fetchCalls[0][1].headers["X-Codex-Dream-Skin-Token"], + "0123456789abcdef0123456789abcdef"); + const transientPlaybackError = Object.assign(new Error("background media was interrupted"), { + name: "AbortError", + }); + stage.children[0].listeners.get("error")(transientPlaybackError); + assert.equal(videoState.videoFailed, false, + "A recoverable playback error must not race its scheduled retry with image fallback."); + video.flushTimers(500); + await Promise.resolve(); + assert.equal(video.attrs.get("data-dream-media"), "video"); + video.setVisibility(true); + for (let attempt = 0; attempt < 25; attempt += 1) transientAbort(); + video.flushTimers(500); + await Promise.resolve(); + assert.equal(videoState.videoFailed, false, + "A hidden renderer must not exhaust the finite playback retry budget."); + assert.equal(videoState.videoReady, true, + "Suspending a ready hidden video must preserve the last known good frame state."); + assert.equal(video.timers.size, 0, + "Hidden playback failures must wait for visibilitychange instead of polling."); + video.setVisibility(false); + await Promise.resolve(); + assert.equal(videoState.videoReady, true, + "Visibility restoration must resume the existing video without image fallback."); + const previousVideoState = videoState; + const previousVideoStage = stage; + const previousVideo = stage.children[0]; + const previousVideoError = stage.children[0].listeners.get("error"); + const staleVideo = video.document.createElement("video"); + staleVideo.poster = "blob:stale-poster"; + stage.appendChild(staleVideo); + vm.runInNewContext(video.payloadFor({}, "http://127.0.0.1:1234/media/fedcba9876543210fedcba9876543210"), video.context); + const replacementState = video.window.__CODEX_DREAM_SKIN_STATE__; + const replacementStage = video.nodes.get("codex-dream-skin-background-stage"); + const replacementVideo = replacementStage.children.at(-1); + assert.notEqual(replacementState, previousVideoState); + assert.equal(replacementStage, previousVideoStage, + "A replacement video must reuse the mounted stage rather than its parent shell."); + assert.equal(previousVideo.parentElement, previousVideoStage, + "The previous video must remain mounted until the replacement is ready."); + assert.equal(staleVideo.parentElement, null, + "A replacement must discard any video left by an older superseded handoff."); + assert.equal(previousVideo.eventListeners.size, 0, + "A handed-off video must not retain listeners from its superseded runtime."); + assert.equal(video.attrs.get("data-dream-media"), "video", + "A ready previous video must remain the displayed medium during replacement loading."); + assert.equal(replacementVideo.style.opacity, "0", + "A pending replacement must not expose its poster over the previous video."); + assert.deepEqual( + [...replacementState.mediaTransitions].map((entry) => entry.state), + ["video"], + "A ready handoff must never transition the replacement runtime through image or pending display.", + ); + previousVideoError(new Error("detached old video error")); + assert.equal(video.attrs.get("data-dream-media"), "video", + "An old video's late error must not hide the ready handoff or fail the replacement generation."); + assert.equal(video.window.__CODEX_DREAM_SKIN_STATE__.videoFailed, false); + for (let attempt = 0; attempt < 24 && !replacementVideo.src; attempt += 1) { + await Promise.resolve(); + } + assert.match(replacementVideo.src, /^blob:fixture-/); + replacementVideo.listeners.get("loadedmetadata")(); + await Promise.resolve(); + replacementVideo.listeners.get("loadeddata")(); + assert.equal(replacementVideo.style.opacity, "", + "The replacement visibility guard must clear only after its first frame."); + assert.equal( + replacementState.mediaTransitions.some((entry) => entry.state !== "video"), + false, + "A successful video-to-video handoff must keep video as the displayed medium throughout.", + ); + assert.equal(previousVideo.parentElement, null, + "The previous video must be released after the replacement becomes ready."); + assert.equal(replacementStage.children.length, 1, + "A completed handoff must leave exactly one video in the background stage."); + assert.equal(video.attrs.get("data-dream-media"), "video"); + assert.equal(replacementState.cleanup(), true); + assert.equal(video.nodes.has("codex-dream-skin-background-stage"), false); + + const blobVideo = makeFixture({ nativeAppearance: "dark", autoMetadata: true }); + vm.runInNewContext(blobVideo.payloadFor({}, { + mode: "blob", + fallbackUrl: "http://127.0.0.1:1234/media/0123456789abcdef0123456789abcdef", + }), blobVideo.context); + const blobState = blobVideo.window.__CODEX_DREAM_SKIN_STATE__; + const blobInput = blobVideo.nodes.get("codex-dream-skin-video-input"); + assert.equal(blobState.videoMode, "blob"); + assert.equal(blobInput.type, "file"); + assert.equal(blobInput.accept, "video/mp4"); + blobInput.files = [{ name: "fixture.mp4", type: "video/mp4" }]; + assert.equal(await blobState.attachVideoFile(), true, "CDP-provided file input must attach as a Blob URL"); + const blobStage = blobVideo.nodes.get("codex-dream-skin-background-stage"); + assert.ok(blobStage, "Blob video must mount a dedicated background stage"); + assert.equal(blobStage.children.length, 1, "Blob video mode must use one media element"); + assert.match(blobStage.children[0].src, /^blob:fixture-/); + assert.equal(blobVideo.fetchCalls.length, 0, "Blob-first mode must not fetch the loopback fallback eagerly"); + assert.equal(blobState.cleanup(), true); + assert.equal(blobVideo.nodes.has("codex-dream-skin-video-input"), false); + + const full = makeFixture({ nativeAppearance: "dark" }); vm.runInNewContext(full.payloadFor({ art: { taskMode: "full" } }), full.context); assert.equal(full.attrs.get("data-dream-task-mode"), "full"); diff --git a/windows/CHANGELOG.md b/windows/CHANGELOG.md index 972f3362..06e410a5 100644 --- a/windows/CHANGELOG.md +++ b/windows/CHANGELOG.md @@ -1,5 +1,11 @@ # Windows Changelog +## 未发布 + +### 功能 + +- 支持将本机 MP4 设为动态视频背景:托盘新增「一键更换视频背景」,主题包 `theme.json` 可选 `video` 字段;视频经本机回环媒体服务传输(`127.0.0.1`、一次性 token、Range/`206`),图片仍作 poster 与失败回退。导入校验要求真实 `ftyp` 容器头、非空且不超过 100 MiB;热切换仅在渲染器确认可播放后提交,失败则恢复切换前主题。 + ## 1.5.6 — 2026-07-26 ### 安全 diff --git a/windows/README.md b/windows/README.md index 1f7e2e2d..0f115ca6 100644 --- a/windows/README.md +++ b/windows/README.md @@ -81,6 +81,7 @@ powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File .\scripts\verify-d 打开 `Codex Dream Skin - Tray` 后可以: - 更换 PNG、JPEG 或 WebP 背景图。 +- 直接选择 MP4 视频背景。 - 导入普通 `.zip` 主题包到“已保存主题”(不支持 `.dreamskin`)。 - 保存当前主题并从「已保存主题」切换。 - 暂停或继续显示皮肤。 @@ -94,6 +95,24 @@ powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File .\scripts\verify-d 导入图片必须是纯背景,不要使用包含窗口、侧栏、输入框、文字或按钮的效果截图。图片上限为 10 MB;宽或高不能超过 16384 像素,总像素不能超过 5000 万。 +### 成功导入 MP4 视频 + +推荐直接导入,不要先制作 ZIP: + +1. 从 `Codex Dream Skin` 快捷方式启动 Codex,并保持 `Codex Dream Skin - Tray` 运行。 +2. 在托盘菜单选择“一键更换视频背景”。 +3. 选择真实的 `.mp4` 文件。文件必须非空、不超过 100 MiB,并带有效的 MP4 `ftyp` 容器头;其他格式 + 仅修改扩展名无效。为兼容 Codex Chromium,建议使用 H.264/AVC 视频和 AAC 音频,或无音轨视频。 +4. 普通方式启动的 Codex 没有 Dream Skin 调试会话,首次应用可能要求重启一次。确认后等待启动器完成 + 真实渲染验证;以后在健康会话中切换图片、视频和已保存主题可以直接热更新。 + +直接选择视频会沿用当前背景图作为 poster。热切换只有在渲染器确认视频可播放后才报告成功;失败时恢复 +切换前的图片或视频主题。视频只通过本机受控通道读取,不会上传。 + +视频也可以放进包含 `theme.json`、非空 `theme.css` 和 poster 图片的主题 ZIP,但 ZIP 仍受下面的 +32 MiB 压缩包、64 MiB 解压总量限制,限制包含 MP4 本身。大于 64 MiB 的视频(例如 76 MiB 文件)应当 +使用托盘的直接选择方式。 + 新的正式 Studio ZIP 必须包含 `manifest.json`、非空 `theme.json`、非空 `theme.css`、恰好一张 `background.webp|jpg|png`,并可选 带 `LICENSE.txt`、`manifest.sig`;文件直接位于根目录或只包一层主题目录。本地简化包也必须恰好包含 `theme.json`、`theme.css` 与其引用图片,且只应来自可信来源。压缩文件上限 32 MiB、最多 diff --git a/windows/assets/dream-skin.css b/windows/assets/dream-skin.css index bd112ec0..a60f2c15 100644 --- a/windows/assets/dream-skin.css +++ b/windows/assets/dream-skin.css @@ -1212,3 +1212,135 @@ html[data-dream-skin="active"] [data-message-author-role] { border-radius: 16px; animation-iteration-count: 1 !important; } } + +/* Optional media layer. It is inert for image themes and spans the complete + Codex viewport when a validated MP4 URL is supplied by the injector. */ +#codex-dream-skin-background-stage { + position: fixed; + inset: 0; + z-index: 0; + overflow: hidden; + pointer-events: none; + background: transparent !important; + visibility: hidden; +} + +#codex-dream-skin-background-stage::before { + content: ""; + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; + background-image: var(--ds-task-fade), var(--ds-task-shade); + background-repeat: no-repeat; + background-position: center, center; + background-size: 100% 100%, 100% 100%; +} + +#codex-dream-skin-background-stage img, +#codex-dream-skin-background-stage video { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: var(--ds-art-position, 50% 50%); + pointer-events: none; +} + +#codex-dream-skin-background-stage img { z-index: 1; } +#codex-dream-skin-background-stage video { + z-index: 1; + opacity: 0; +} + +html[data-dream-skin="active"][data-dream-media="video"] + #codex-dream-skin-background-stage { + visibility: visible; +} + +html[data-dream-skin="active"][data-dream-media="video"] + #codex-dream-skin-background-stage video { + opacity: 1; +} + +html[data-dream-skin="active"][data-dream-media="video"] + #codex-dream-skin-background-stage::before { + opacity: .46; +} + +html[data-dream-skin="active"][data-dream-media="video"] + #codex-dream-skin-background-stage video { + filter: brightness(1.15) saturate(1.04); +} + +html[data-dream-skin="active"][data-dream-media="video"] + #codex-dream-skin-background-stage img { + display: none !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] body { + background: transparent !important; + background-image: none !important; +} + +/* The image rules above are intentionally kept for image themes. Once the + video has its first frame, close every static-art entry point explicitly so + a Codex route cannot leave a second copy of the imported image visible. */ +html[data-dream-skin="active"][data-dream-media="video"] + main.main-surface::before { + content: none !important; + background-image: none !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] + [role="main"]:has([data-testid="home-icon"]) > div:first-child > div:first-child > div:first-child { + background-image: none !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] body > #root { + position: relative !important; + /* Keep every native task/composer surface above the full-window media stage. + The explicit stacking context also covers Codex's new-task route, whose + transparent wrappers can otherwise participate in the body's auto layer. */ + z-index: 10 !important; + isolation: isolate; + background: transparent !important; +} + +/* Some Codex builds mount dialogs and popovers directly under body instead of + inside #root. Keep those native surfaces above the media stage as well. */ +html[data-dream-skin="active"][data-dream-media="video"] body > + :is([role="dialog"], [aria-modal="true"], [data-radix-portal], + [data-radix-popper-content-wrapper]) { + z-index: 50 !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] + body > #root aside.app-shell-left-panel { + background: + linear-gradient(180deg, + rgb(var(--ds-panel-rgb) / .38), + rgb(var(--ds-bg-rgb) / .28)) !important; + backdrop-filter: blur(8px) saturate(112%) !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] main.main-surface { + background: transparent !important; +} + +html[data-dream-skin="active"][data-dream-media="video"] + main.main-surface::before { + content: none !important; +} + +/* Wide image themes also paint artwork directly on body. In video mode the + video stage is the sole artwork source; keep the old body image only for + the image fallback path. */ +html[data-dream-skin="active"][data-dream-media="video"]:is([data-dream-task-mode="ambient"], [data-dream-art-task-mode="ambient"], [data-dream-task-mode="banner"], [data-dream-art-task-mode="banner"])[data-dream-art-wide="true"]:has(main.main-surface):not(:has(main.main-surface [role="main"])) + body, +html[data-dream-skin="active"][data-dream-media="video"][data-dream-art-wide="true"]:has(main.main-surface [role="main"]) + body { + background-image: none !important; +} diff --git a/windows/assets/renderer-inject.js b/windows/assets/renderer-inject.js index be799888..3eb39df7 100644 --- a/windows/assets/renderer-inject.js +++ b/windows/assets/renderer-inject.js @@ -1,17 +1,19 @@ // Canonical cross-platform renderer. Run tools/sync-runtime-assets.mjs after editing. -((cssText, artDataUrl, themeConfig) => { +((cssText, artDataUrl, themeConfig, videoConfig) => { const SELECTOR_CONTRACT = {"schema":"codex-dream-skin-selectors/1","selectors":[{"key":"shell-main","selector":"main.main-surface","tier":"L1","scope":"all","required":true},{"key":"left-panel","selector":"aside.app-shell-left-panel","tier":"L1","scope":"all","required":true},{"key":"header-tint","selector":"header.app-header-tint","tier":"L1","scope":"all","required":true},{"key":"home-icon","selector":"[data-testid=\"home-icon\"]","tier":"L1","scope":"home","required":true},{"key":"home-route","selector":"[role=\"main\"]:has([data-testid=\"home-icon\"])","tier":"L1","scope":"home","required":true},{"key":"home-route-css","selector":"[role=\"main\"]","tier":"L1","scope":"home","required":true},{"key":"home-banners","selector":".home-banners","tier":"L2","scope":"home","required":false},{"key":"composer-chrome","selector":".composer-surface-chrome","tier":"L2","scope":"home+thread","required":false},{"key":"composer-toolbar","selector":".composer-surface-chrome [class*=\"_footer_\"]","tier":"L2","scope":"home+thread","required":false},{"key":"home-utility","selector":"[class*=\"_homeUtilityBar_\"]","tier":"L2","scope":"home","required":false},{"key":"game-source","selector":"[data-feature=\"game-source\"]","tier":"L2","scope":"home","required":false},{"key":"home-suggestions","selector":".group\\/home-suggestions","tier":"L2","scope":"home","required":false},{"key":"project-selector","selector":".group\\/project-selector","tier":"L2","scope":"home config","required":false},{"key":"markdown","selector":"[class*=\"_markdown\"]","tier":"L2","scope":"thread","required":false},{"key":"thread-surface","selector":".thread-scroll-container","tier":"L2","scope":"thread","required":false},{"key":"message","selector":"[data-message-author-role]","tier":"L2","scope":"thread","required":false},{"key":"appearance-radio","selector":"input[name=\"appearance-theme\"]","tier":"L2","scope":"settings","required":false},{"key":"overlay-menu","selector":"[role=\"menu\"]","tier":"L2","scope":"overlay","required":false},{"key":"overlay-dialog","selector":"[role=\"dialog\"]","tier":"L2","scope":"overlay","required":false},{"key":"overlay-popper","selector":"[data-radix-popper-content-wrapper]","tier":"L2","scope":"overlay","required":false}],"stableTestids":["app-shell-header-context-menu-surface","home-icon","theme-preview"]}; const STATE_KEY = "__CODEX_DREAM_SKIN_STATE__"; const DISABLED_KEY = "__CODEX_DREAM_SKIN_DISABLED__"; const STYLE_REGISTRY_KEY = "__CODEX_DREAM_SKIN_STYLE_SHEETS__"; const STYLE_ID = "codex-dream-skin-style"; + const BACKGROUND_STAGE_ID = "codex-dream-skin-background-stage"; + const VIDEO_INPUT_ID = "codex-dream-skin-video-input"; const SHELL_ATTR = "data-dream-shell"; const PART_ATTR = "data-ds-part"; const ROOT_ATTRS = [ "data-dream-skin", SHELL_ATTR, "data-dream-art-wide", "data-dream-art-safe", "data-dream-task-mode", "data-dream-art-safe-area", "data-dream-art-task-mode", "data-dream-art-aspect", - "data-dream-art-ready", + "data-dream-art-ready", "data-dream-media", "data-dream-video-ready", ]; const VERSION = __DREAM_SKIN_VERSION_JSON__; const STYLE_REVISION = __DREAM_SKIN_STYLE_REVISION_JSON__; @@ -54,6 +56,23 @@ typeof existingAnalysisCache.set === "function" ? existingAnalysisCache : new Map(); window[ANALYSIS_CACHE_KEY] = analysisCache; let artAnalysis = typeof THEME.artKey === "string" ? analysisCache.get(THEME.artKey) ?? null : null; + const normalizedVideoConfig = typeof videoConfig === "string" + ? { mode: "server", url: videoConfig } + : videoConfig && typeof videoConfig === "object" ? videoConfig : null; + const videoUrl = typeof normalizedVideoConfig?.url === "string" + ? normalizedVideoConfig.url : null; + const videoMode = normalizedVideoConfig?.mode === "blob" ? "blob" + : /^https?:\/\//i.test(videoUrl || "") ? "server" : null; + const videoSource = videoMode === "server" ? videoUrl : null; + const videoFallbackSource = typeof normalizedVideoConfig?.fallbackUrl === "string" && + normalizedVideoConfig.fallbackUrl ? normalizedVideoConfig.fallbackUrl : null; + const videoEnabled = videoMode === "blob" || Boolean(videoSource); + let backgroundState = null; + let handedOffVideo = null; + let videoGeneration = 0; + let videoFailed = false; + let videoReady = false; + let videoError = null; let analysisTimer = null; let rootObserver = null; let partObserver = null; @@ -61,6 +80,7 @@ let styleMode = null; let styleNode = null; let styleSheet = null; + const mediaTransitions = []; const now = () => typeof performance === "object" && typeof performance.now === "function" ? performance.now() : Date.now(); const metrics = { @@ -82,6 +102,9 @@ }; const previous = window[STATE_KEY]; + const previousHandoff = videoEnabled && typeof previous?.handoffVideo === "function" + ? previous.handoffVideo() : null; + if (previousHandoff) handedOffVideo = { ...previousHandoff, preserve: true }; if (typeof previous?.cleanup === "function") previous.cleanup(); window[DISABLED_KEY] = false; @@ -353,6 +376,411 @@ setStyleProperty(root, "--ds-theme-image-focus-y", String(Number(focusY.toFixed(4)))); }; + const detachBackgroundVideoListeners = (state) => { + if (!state) return; + if (state.retryTimer) { + clearTimeout(state.retryTimer); + state.retryTimer = null; + } + try { state.video.removeEventListener("error", state.onError); } catch {} + try { state.video.removeEventListener("loadeddata", state.onReady); } catch {} + try { state.video.removeEventListener("playing", state.onReady); } catch {} + try { state.video.removeEventListener("ended", state.onEnded); } catch {} + try { state.video.removeEventListener("stalled", state.onStalled); } catch {} + try { state.video.removeEventListener("abort", state.onAbort); } catch {} + try { state.video.removeEventListener("emptied", state.onEmptied); } catch {} + try { state.video.removeEventListener("timeupdate", state.onTimeUpdate); } catch {} + try { document.removeEventListener("visibilitychange", state.onVisibility); } catch {} + }; + + const removeBackgroundStage = () => { + const state = backgroundState; + backgroundState = null; + if (!state) { + document.getElementById(BACKGROUND_STAGE_ID)?.remove(); + if (handedOffVideo) { + try { handedOffVideo.video.pause(); handedOffVideo.video.remove(); } catch {} + if (handedOffVideo.objectUrl) { + try { URL.revokeObjectURL(handedOffVideo.objectUrl); } catch {} + } + handedOffVideo = null; + } + return; + } + if (handedOffVideo?.video === state.video) { + // Preserve only the decoded frame and object URL. The superseded runtime + // must not keep visibility/media listeners that can restart this node. + detachBackgroundVideoListeners(state); + handedOffVideo = null; + return; + } + try { state.video.pause(); } catch {} + detachBackgroundVideoListeners(state); + try { state.video.removeAttribute("src"); state.video.load(); } catch {} + if (state.objectUrl) { + try { URL.revokeObjectURL(state.objectUrl); } catch {} + } + state.stage.remove(); + if (handedOffVideo) { + try { handedOffVideo.video.pause(); handedOffVideo.video.remove(); } catch {} + if (handedOffVideo.objectUrl) { + try { URL.revokeObjectURL(handedOffVideo.objectUrl); } catch {} + } + handedOffVideo = null; + } + }; + + const captureVideoError = (error = null, video = backgroundState?.video) => { + videoError = { + name: error?.name || null, + message: error?.message || null, + mediaCode: video?.error?.code ?? null, + readyState: video?.readyState ?? null, + networkState: video?.networkState ?? null, + }; + }; + + const isCurrentVideoState = (state) => Boolean( + state && backgroundState === state && state.generation === videoGeneration, + ); + + const fallbackToImage = (error = null, expectedState = null) => { + if (expectedState && !isCurrentVideoState(expectedState)) return false; + if (videoFailed) return; + captureVideoError(error, expectedState?.video ?? backgroundState?.video); + videoFailed = true; + videoReady = false; + removeBackgroundStage(); + const root = document.documentElement; + if (root) applyRootState(root); + return true; + }; + + const revealVideo = (state) => { + if (!isCurrentVideoState(state) || videoFailed || videoReady) return; + // The replacement element stays inline-hidden while the verified previous + // video remains visible. Reveal it only after Chromium produced a frame. + try { state.video.style.opacity = ""; } catch {} + // The poster is useful only before the first decoded frame. Keeping it on + // a live element lets Chromium expose the static image again during a + // transient stall or buffer reset, which looks like image/video flashing. + try { state.video.removeAttribute("poster"); } catch {} + videoReady = true; + const root = document.documentElement; + if (root) applyRootState(root); + if (handedOffVideo) { + const oldVideo = handedOffVideo.video; + const oldObjectUrl = handedOffVideo.objectUrl; + handedOffVideo = null; + try { oldVideo.pause(); oldVideo.remove(); } catch {} + if (oldObjectUrl) { + try { URL.revokeObjectURL(oldObjectUrl); } catch {} + } + } + }; + + const isTransientBackgroundPause = (error, video) => { + const hidden = document.visibilityState === "hidden" || document.hidden; + if (!hidden) return false; + const message = String(error?.message || "").toLowerCase(); + if (error?.name === "AbortError" && /background media|save power|paused/.test(message)) return true; + // Chromium can emit an empty media error while the app target reports + // hidden, even though the loopback response is valid. Treat that event as + // transient and retry instead of exposing the fallback image. + if (!video?.error && !message) return true; + return !video?.error && /failed to fetch|network|load|media/.test(message); + }; + + const isTransientVideoError = (error, video) => { + if (isTransientBackgroundPause(error, video)) return true; + const code = video?.error?.code ?? null; + // MEDIA_ERR_ABORTED, MEDIA_ERR_NETWORK, and Chromium's empty error object + // are recoverable while a source is being replaced or the page is hidden. + return code === null || code === 1 || code === 2; + }; + + const retryTransientVideo = (state) => { + if (!state || videoFailed || !state.video.src || state.retryCount >= 20) return false; + if (document.visibilityState === "hidden" || document.hidden) { + // Chromium may suspend an already-valid media element for as long as + // the renderer stays hidden. Do not spend the finite retry budget while + // playback is intentionally unavailable; visibilitychange resumes it. + state.pendingPlay = true; + return true; + } + if (state.retryTimer) return true; + state.retryCount += 1; + state.retryTimer = setTimeout(() => { + state.retryTimer = null; + if (!isCurrentVideoState(state) || videoFailed || !state.video.src) return; + if (document.visibilityState === "hidden" || document.hidden) { + state.pendingPlay = true; + return; + } + Promise.resolve(state.video.play?.()).then(() => { + if (!isCurrentVideoState(state) || videoFailed) return; + state.pendingPlay = false; + revealVideo(state); + }).catch(state.onError); + }, 500); + return true; + }; + + const ensureVideoInput = () => { + if (videoMode !== "blob" || !document?.createElement) return null; + let input = document.getElementById(VIDEO_INPUT_ID); + if (input) return input; + input = document.createElement("input"); + input.type = "file"; + input.id = VIDEO_INPUT_ID; + input.accept = "video/mp4"; + input.tabIndex = -1; + input.setAttribute("aria-hidden", "true"); + Object.assign(input.style, { + position: "fixed", + width: "1px", + height: "1px", + opacity: "0", + pointerEvents: "none", + }); + (document.body || document.documentElement)?.appendChild(input); + return input; + }; + + const mediaTokenFromUrl = (source) => { + try { + const parsed = new URL(source); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + return /^\/media\/([a-f0-9]{32})$/i.exec(parsed.pathname)?.[1] ?? null; + } catch { + return null; + } + }; + + const playVideoObjectUrl = async (state, objectUrl) => { + if (!isCurrentVideoState(state) || videoFailed) return false; + if (state.objectUrl && state.objectUrl !== objectUrl) { + try { URL.revokeObjectURL(state.objectUrl); } catch {} + } + state.objectUrl = objectUrl; + state.video.src = objectUrl; + state.video.load(); + if (document.visibilityState === "hidden" || document.hidden) { + state.pendingPlay = true; + return true; + } + if (state.video.readyState < 1) { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error("Video metadata load timed out")); + }, 15000); + const cleanup = () => { + clearTimeout(timer); + state.video.removeEventListener("loadedmetadata", onMetadata); + state.video.removeEventListener("error", onError); + }; + const onMetadata = () => { cleanup(); resolve(); }; + const onError = () => { cleanup(); reject(new Error("Video metadata could not be loaded")); }; + state.video.addEventListener("loadedmetadata", onMetadata, { once: true }); + state.video.addEventListener("error", onError, { once: true }); + }); + } + await state.video.play(); + if (!isCurrentVideoState(state) || videoFailed) return false; + state.pendingPlay = false; + // play() is the fallback for shells that do not expose a usable + // loadeddata/playing event to the injected runtime. + revealVideo(state); + return true; + }; + + const loadVideoFromUrl = async (state, source) => { + if (!state || state.loading) return false; + state.loading = true; + try { + const token = mediaTokenFromUrl(source); + const headers = token ? { "X-Codex-Dream-Skin-Token": token } : undefined; + const response = await fetch(source, { credentials: "omit", headers }); + if (!response.ok) throw new Error("Video request failed: " + response.status); + const blob = await response.blob(); + if (!isCurrentVideoState(state) || videoFailed) return false; + return playVideoObjectUrl(state, URL.createObjectURL(blob)); + } finally { + state.loading = false; + } + }; + + const fallbackToServer = async (expectedState = null) => { + const state = expectedState ?? backgroundState; + if (!isCurrentVideoState(state) || !videoFallbackSource || state.fallbackStarted) return false; + state.fallbackStarted = true; + try { + return await loadVideoFromUrl(state, videoFallbackSource); + } catch (error) { + fallbackToImage(error, state); + return false; + } + }; + + const handleVideoFailure = (state, error = null) => { + if (!isCurrentVideoState(state) || videoFailed) return false; + captureVideoError(error, state.video); + if (isTransientVideoError(error, state.video) && retryTransientVideo(state)) { + state.pendingPlay = true; + return false; + } + if (videoFallbackSource && !state.fallbackStarted) { + void fallbackToServer(state); + return false; + } + return fallbackToImage(error, state); + }; + + const attachVideoFile = async () => { + if (videoMode !== "blob") return false; + const input = ensureVideoInput(); + ensureBackgroundStage(); + const state = backgroundState; + const file = input?.files?.[0]; + if (!state || !file) return false; + try { + await playVideoObjectUrl(state, URL.createObjectURL(file)); + applyRootState(document.documentElement); + return true; + } catch (error) { + return handleVideoFailure(state, error); + } + }; + + const ensureBackgroundStage = () => { + if (!videoEnabled || videoFailed || !document?.createElement) return; + ensureVideoInput(); + if (backgroundState?.stage?.parentElement) return; + const previousVideo = handedOffVideo?.video?.parentElement ? handedOffVideo.video : null; + const stage = handedOffVideo?.stage?.parentElement + ? handedOffVideo.stage + : document.getElementById(BACKGROUND_STAGE_ID) || document.createElement("div"); + stage.id = BACKGROUND_STAGE_ID; + // A failed or superseded handoff can leave an older video in the shared + // stage. Keep at most the one explicitly handed off by the immediately + // previous runtime; two playable nodes can alternate frames and posters. + for (const child of [...(stage.children || [])]) { + if (child === previousVideo) continue; + const staleSource = typeof child.src === "string" ? child.src : ""; + try { child.pause?.(); } catch {} + try { child.removeAttribute?.("src"); child.load?.(); } catch {} + try { child.remove?.(); } catch {} + if (staleSource.startsWith("blob:")) { + try { URL.revokeObjectURL(staleSource); } catch {} + } + } + if (!previousVideo) stage.replaceChildren?.(); + if (previousVideo && !stage.parentElement) { + const parent = document.body || document.documentElement; + if (!parent) return; + if (typeof parent.prepend === "function") parent.prepend(stage); + else parent.appendChild(stage); + } + // The video is a viewport background, not a main-surface decoration. Keep + // it under the app root so the sidebar, main content, and native overlays + // all share one continuous moving background. + const parent = stage.parentElement || document.body || document.documentElement; + if (!parent) return; + + const video = document.createElement("video"); + video.muted = true; + video.defaultMuted = true; + video.autoplay = true; + video.loop = true; + video.playsInline = true; + video.preload = "auto"; + video.poster = artUrl; + video.setAttribute("aria-hidden", "true"); + // `data-dream-media="video"` intentionally remains set during a ready + // handoff so CSS keeps the previous video visible. Prevent that selector + // from exposing the pending replacement or its poster before first frame. + video.style.opacity = "0"; + const onError = (error = null) => { + // A detached video can still deliver abort/error after a new theme has + // installed. Its event must never fail the newer media generation. + if (!isCurrentVideoState(state)) return; + handleVideoFailure(state, error); + }; + const onVisibility = () => { + if (videoFailed || document.visibilityState === "hidden" || document.hidden) return; + if (videoMode === "server" && !state.loading && !video.src) { + void loadVideoFromUrl(state, videoSource).catch(onError); + return; + } + if (video.src) { + Promise.resolve(video.play?.()).then(() => { + state.pendingPlay = false; + revealVideo(state); + }).catch(onError); + } + }; + const onReady = () => revealVideo(state); + const onEnded = () => { + if (videoFailed || backgroundState !== state || document.visibilityState === "hidden" || document.hidden) return; + state.pendingPlay = true; + try { video.currentTime = 0; } catch {} + Promise.resolve(video.play?.()).then(() => { + state.pendingPlay = false; + revealVideo(state); + }).catch(onError); + }; + const onStalled = () => { + if (videoFailed || !isCurrentVideoState(state)) return; + state.pendingPlay = true; + retryTransientVideo(state); + }; + const onAbort = () => { + if (videoFailed || !isCurrentVideoState(state)) return; + state.pendingPlay = true; + retryTransientVideo(state); + }; + const onEmptied = () => { + if (videoFailed || !isCurrentVideoState(state) || !video.src) return; + state.pendingPlay = true; + retryTransientVideo(state); + }; + const onTimeUpdate = () => { + if (videoFailed || !isCurrentVideoState(state)) return; + const currentTime = Number(video.currentTime); + if (!Number.isFinite(currentTime)) return; + if (currentTime < state.lastPlaybackTime || + currentTime - state.lastPlaybackTime >= 0.1) { + state.lastPlaybackTime = currentTime; + state.retryCount = 0; + state.pendingPlay = false; + } + }; + const state = { + stage, video, onError, onVisibility, onReady, onEnded, onStalled, onAbort, onEmptied, + onTimeUpdate, + generation: ++videoGeneration, + objectUrl: null, fallbackStarted: false, retryTimer: null, retryCount: 0, + loading: false, pendingPlay: false, lastPlaybackTime: -1, + }; + backgroundState = state; + video.addEventListener("error", onError); + video.addEventListener("loadeddata", onReady); + video.addEventListener("playing", onReady); + video.addEventListener("ended", onEnded); + video.addEventListener("stalled", onStalled); + video.addEventListener("abort", onAbort); + video.addEventListener("emptied", onEmptied); + video.addEventListener("timeupdate", onTimeUpdate); + document.addEventListener("visibilitychange", onVisibility); + stage.appendChild(video); + if (typeof parent.prepend === "function") parent.prepend(stage); + else parent.appendChild(stage); + if (videoMode === "server") { + void loadVideoFromUrl(backgroundState, videoSource).catch(onError); + } + }; + const analyzeArt = () => new Promise((resolve) => { const startedAt = now(); metrics.analysisRuns += 1; @@ -550,7 +978,28 @@ const shell = resolvedShell(); setAttribute(root, "data-dream-skin", "active"); setAttribute(root, SHELL_ATTR, shell); - setStyleProperty(root, "--dream-skin-art", `url("${artUrl}")`); + const mediaReady = videoEnabled && !videoFailed && videoReady; + const handoffReady = videoEnabled && !videoFailed && + handedOffVideo?.ready === true && Boolean(handedOffVideo.video?.parentElement); + const mediaState = mediaReady || handoffReady + ? "video" + : videoEnabled && !videoFailed ? "video-pending" : "image-fallback"; + const previousMedia = mediaTransitions[mediaTransitions.length - 1]; + if (!previousMedia || previousMedia.state !== mediaState || + previousMedia.videoReady !== videoReady) { + mediaTransitions.push({ + state: mediaState, + videoReady, + handoffReady, + at: Date.now(), + }); + if (mediaTransitions.length > 16) mediaTransitions.shift(); + } + setAttribute(root, "data-dream-media", mediaState); + setAttribute(root, "data-dream-video-ready", videoReady ? "true" : "false"); + // Keep the poster image visible until the video has produced a frame. This + // avoids a blank flash while a replacement media source is loading. + setStyleProperty(root, "--dream-skin-art", mediaState === "video" ? "none" : `url("${artUrl}")`); applyTheme(root, shell); applyArtMetadata(root); return shell; @@ -664,7 +1113,10 @@ const root = document.documentElement; if (!root) return; metrics.ensureCalls += 1; + // Commit the media mode before touching the stage. This prevents the + // previous body image from being painted during a video-theme swap. if (rootPass) applyRootState(root); + if (rootPass) ensureBackgroundStage(); if (partPass) refreshParts(); if (scopePass) refreshScope(); }; @@ -693,6 +1145,8 @@ if (state?.timer) clearInterval(state.timer); if (state?.scheduler?.timeout) clearTimeout(state.scheduler.timeout); if (analysisTimer) clearTimeout(analysisTimer); + removeBackgroundStage(); + document.getElementById(VIDEO_INPUT_ID)?.remove(); if (state?.mediaHandler && state?.mediaQuery) { try { state.mediaQuery.removeEventListener("change", state.mediaHandler); } catch {} } @@ -762,6 +1216,26 @@ navigation: navigationApi, navigationHandler, artUrl, + videoUrl: videoSource, + videoMode, + get videoFailed() { return videoFailed; }, + get videoReady() { return videoReady; }, + get videoError() { return videoError; }, + mediaTransitions, + ensureVideoInput, + attachVideoFile, + useVideoFallback: fallbackToServer, + handoffVideo: () => { + const state = backgroundState; + if (!state?.stage?.parentElement || !state.video?.parentElement) return null; + handedOffVideo = { + stage: state.stage, + video: state.video, + objectUrl: state.objectUrl, + ready: videoReady && !videoFailed, + }; + return { ...handedOffVideo }; + }, installToken, styleMode, styleNode, @@ -842,4 +1316,4 @@ styleMode, analysis: artAnalysis, }; -})(__DREAM_SKIN_CSS_JSON__, __DREAM_SKIN_ART_JSON__, __DREAM_SKIN_THEME_JSON__) +})(__DREAM_SKIN_CSS_JSON__, __DREAM_SKIN_ART_JSON__, __DREAM_SKIN_THEME_JSON__, __DREAM_SKIN_VIDEO_JSON__) diff --git a/windows/assets/theme-package-validator.mjs b/windows/assets/theme-package-validator.mjs index 275fcfd1..e9fd6f2b 100644 --- a/windows/assets/theme-package-validator.mjs +++ b/windows/assets/theme-package-validator.mjs @@ -14,6 +14,7 @@ const LIMITS = Object.freeze({ simpleTheme: 1_048_576, css: 262_144, image: 10_485_760, + video: 100 * 1024 * 1024, license: 65_536, signature: 4_096, }); @@ -23,9 +24,13 @@ const BACKGROUND_MEDIA = new Map([ ["background.jpg", "image/jpeg"], ["background.png", "image/png"], ]); +const VIDEO_MEDIA = new Map([ + ["background.mp4", "video/mp4"], +]); const PAYLOAD_MEDIA = new Map([ ["theme.json", "application/json"], ...BACKGROUND_MEDIA, + ...VIDEO_MEDIA, ["theme.css", "text/css"], ["LICENSE.txt", "text/plain"], ]); @@ -206,6 +211,7 @@ function expectedLimit(name, simple = false) { if (name === "theme.css") return LIMITS.css; if (name === "LICENSE.txt") return LIMITS.license; if (name === "manifest.sig") return LIMITS.signature; + if (VIDEO_MEDIA.has(name)) return LIMITS.video; if (BACKGROUND_MEDIA.has(name) || /\.(?:png|jpe?g|webp)$/i.test(name)) return LIMITS.image; return 0; } @@ -273,7 +279,7 @@ function validateOfficialTheme(value) { assertExactKeys( theme, THEME_REQUIRED, - [...THEME_COPY_KEYS, "promoUrl", "appearance", "art", "colors"], + [...THEME_COPY_KEYS, "promoUrl", "appearance", "art", "colors", "video"], "theme.json", ); if (theme.schemaVersion !== 1) fail("theme.json must use schemaVersion 1"); @@ -281,6 +287,10 @@ function validateOfficialTheme(value) { assertString(theme.name, "theme.json.name", { min: 1, max: 80 }); assertString(theme.image, "theme.json.image", { min: 1, max: 32, controls: null }); if (!BACKGROUND_MEDIA.has(theme.image)) fail("theme.json.image must name one registered background file"); + if (theme.video !== undefined) { + assertString(theme.video, "theme.json.video", { min: 1, max: 32, controls: null }); + if (!VIDEO_MEDIA.has(theme.video)) fail("theme.json.video must name background.mp4"); + } for (const key of THEME_COPY_KEYS) { if (theme[key] !== undefined) assertString(theme[key], `theme.json.${key}`, { max: 120 }); } @@ -407,6 +417,9 @@ function validateManifest(value, platform, clientVersion) { if (!files.has("theme.json") || backgrounds.length !== 1) { fail("manifest.files must contain theme.json and exactly one background file"); } + if (files.has("background.mp4") && !files.has("theme.json")) { + fail("manifest.files video requires theme.json"); + } if (files.has("theme.css") !== capabilities.has("safe-css")) { fail("theme.css presence must match the safe-css capability"); } @@ -440,6 +453,15 @@ function detectedImageMedia(bytes) { return ""; } +function isMp4Container(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.length < 16) return false; + const view = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const firstBoxSize = view.readUInt32BE(0); + return firstBoxSize >= 16 + && firstBoxSize <= view.length + && view.subarray(4, 8).toString("ascii") === "ftyp"; +} + async function validateOfficial(root, names, platform, clientVersion) { for (const name of names) { if (!PACKAGE_FILES.has(name)) fail(`Official theme package contains unregistered file ${name}`); @@ -465,6 +487,14 @@ async function validateOfficial(root, names, platform, clientVersion) { const theme = validateOfficialTheme(decodeJson(bytes.get("theme.json"), "theme.json")); if (manifest.themeId !== theme.id) fail("manifest.themeId does not match theme.json id"); if (theme.image !== background) fail("theme.json image does not match the manifest background file"); + if (theme.video !== undefined) { + if (!files.has(theme.video) || theme.video !== "background.mp4") { + fail("theme.json video does not match the manifest video file"); + } + if (!isMp4Container(bytes.get(theme.video))) { + fail("background.mp4 content is not a valid MP4 container"); + } + } if (detectedImageMedia(bytes.get(background)) !== BACKGROUND_MEDIA.get(background)) { fail(`${background} content does not match its extension and mediaType`); } @@ -479,7 +509,7 @@ async function validateOfficial(root, names, platform, clientVersion) { } async function validateSimple(root, names) { - if (names.length !== 3 || !names.includes("theme.json") || !names.includes("theme.css")) { + if ((names.length !== 3 && names.length !== 4) || !names.includes("theme.json") || !names.includes("theme.css")) { fail("Local simplified ZIP must contain exactly theme.json, theme.css, and its image"); } const themeBytes = await readStableFile(root, "theme.json", LIMITS.simpleTheme); @@ -493,9 +523,18 @@ async function validateSimple(root, names) { || !/\.(?:png|jpe?g|webp)$/i.test(theme.image) || !names.includes(theme.image) ) fail("Local simplified theme image must be beside theme.json"); - const [imageBytes, cssBytes] = await Promise.all([ + const hasVideo = theme.video !== undefined; + if (hasVideo && ( + path.basename(theme.video) !== theme.video + || CONTROL_PATTERN.test(theme.video) + || theme.video !== "background.mp4" + || !names.includes(theme.video) + )) fail("Local simplified theme video must be background.mp4 beside theme.json"); + if (names.length !== (hasVideo ? 4 : 3)) fail("Local simplified ZIP contains unsupported files"); + const [imageBytes, cssBytes, videoBytes] = await Promise.all([ readStableFile(root, theme.image, LIMITS.image), readStableFile(root, "theme.css", LIMITS.css), + hasVideo ? readStableFile(root, theme.video, LIMITS.video) : Promise.resolve(null), ]); const expectedMedia = /\.png$/i.test(theme.image) ? "image/png" @@ -503,6 +542,9 @@ async function validateSimple(root, names) { if (detectedImageMedia(imageBytes) !== expectedMedia) { fail(`${theme.image} content does not match its extension`); } + if (videoBytes && !isMp4Container(videoBytes)) { + fail("background.mp4 content is not a valid MP4 container"); + } decodeAndValidateSafeCss(cssBytes); return { format: "simple", @@ -513,6 +555,7 @@ async function validateSimple(root, names) { ["theme.json", themeBytes], [theme.image, imageBytes], ["theme.css", cssBytes], + ...(videoBytes ? [["background.mp4", videoBytes]] : []), ]), }; } diff --git a/windows/installer/build-release.ps1 b/windows/installer/build-release.ps1 index 620e154f..9589c55a 100644 --- a/windows/installer/build-release.ps1 +++ b/windows/installer/build-release.ps1 @@ -407,6 +407,7 @@ try { 'scripts\image-metadata.mjs', 'scripts\injector.mjs', 'scripts\install-dream-skin.ps1', + 'scripts\media-server.mjs', 'scripts\restore-dream-skin.ps1', 'scripts\start-dream-skin.ps1', 'scripts\theme-windows.ps1', diff --git a/windows/installer/setup-bootstrap.ps1 b/windows/installer/setup-bootstrap.ps1 index 4fc4ed63..c9465f43 100644 --- a/windows/installer/setup-bootstrap.ps1 +++ b/windows/installer/setup-bootstrap.ps1 @@ -128,6 +128,7 @@ try { 'scripts\image-metadata.mjs', 'scripts\injector.mjs', 'scripts\install-dream-skin.ps1', + 'scripts\media-server.mjs', 'scripts\restore-dream-skin.ps1', 'scripts\start-dream-skin.ps1', 'scripts\theme-windows.ps1', diff --git a/windows/scripts/apply-community-theme.ps1 b/windows/scripts/apply-community-theme.ps1 index 426f2702..acbb35e8 100644 --- a/windows/scripts/apply-community-theme.ps1 +++ b/windows/scripts/apply-community-theme.ps1 @@ -197,6 +197,12 @@ function Copy-DreamSkinActiveThemeSnapshot { $imageName = [System.IO.Path]::GetFileName($active.ImagePath) Copy-Item -LiteralPath $active.ThemePath -Destination (Join-Path $Destination 'theme.json') -Force Copy-Item -LiteralPath $active.ImagePath -Destination (Join-Path $Destination $imageName) -Force + # theme.json names the video, so a snapshot that skips the MP4 would restore a + # manifest pointing at a file that no longer exists. + if ($active.VideoPath) { + $videoName = [System.IO.Path]::GetFileName($active.VideoPath) + Copy-Item -LiteralPath $active.VideoPath -Destination (Join-Path $Destination $videoName) -Force + } $activeCss = Join-Path $Paths.Active 'theme.css' if (Test-Path -LiteralPath $activeCss -PathType Leaf) { Assert-DreamSkinSafeCssFile -Path $activeCss @@ -257,8 +263,10 @@ function Restore-DreamSkinActiveThemeSnapshot { $theme = $snapshot.Theme | ConvertTo-Json -Depth 8 | ConvertFrom-Json $cssPath = Join-Path $SnapshotDirectory 'theme.css' if (-not (Test-Path -LiteralPath $cssPath -PathType Leaf)) { $cssPath = $null } - $null = Set-DreamSkinActiveTheme -ImagePath $snapshot.ImagePath -Theme $theme ` - -SafeCssPath $cssPath -StateRoot $StateRoot + # Restoring a video theme has to pass the MP4 back through the same bounded + # validation Set-DreamSkinActiveTheme applies to a fresh import. + $null = Set-DreamSkinActiveTheme -ImagePath $snapshot.ImagePath -VideoPath $snapshot.VideoPath ` + -Theme $theme -SafeCssPath $cssPath -StateRoot $StateRoot $paths = Get-DreamSkinThemePaths -StateRoot $StateRoot $restoredFingerprint = Get-DreamSkinThemeRuntimeContentFingerprint -ThemeDirectory $paths.Active if ($restoredFingerprint -cne $ExpectedContentFingerprint) { diff --git a/windows/scripts/common-windows.ps1 b/windows/scripts/common-windows.ps1 index 54c5497c..e6bbe1b7 100644 --- a/windows/scripts/common-windows.ps1 +++ b/windows/scripts/common-windows.ps1 @@ -23,6 +23,26 @@ function Enter-DreamSkinOperationLock { return $mutex } +function Test-DreamSkinOperationActive { + $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $mutex = [System.Threading.Mutex]::new($false, "Local\CodexDreamSkin.$sid.Operation") + $acquired = $false + try { + try { $acquired = $mutex.WaitOne(0) } catch [System.Threading.AbandonedMutexException] { + $acquired = $true + } + if ($acquired) { + $mutex.ReleaseMutex() + $acquired = $false + return $false + } + return $true + } finally { + if ($acquired) { try { $mutex.ReleaseMutex() } catch {} } + $mutex.Dispose() + } +} + function Exit-DreamSkinOperationLock { param([Parameter(Mandatory = $true)][System.Threading.Mutex]$Mutex) try { $Mutex.ReleaseMutex() } finally { $Mutex.Dispose() } @@ -200,6 +220,7 @@ function Install-DreamSkinRuntimeEngine { 'scripts\image-metadata.mjs', 'scripts\injector.mjs', 'scripts\install-dream-skin.ps1', + 'scripts\media-server.mjs', 'scripts\restore-dream-skin.ps1', 'scripts\start-dream-skin.ps1', 'scripts\theme-windows.ps1', @@ -803,7 +824,7 @@ function Test-DreamSkinWebSocketUrl { $uri = [Uri]$Value $hostName = $uri.Host.ToLowerInvariant() return ($uri.IsAbsoluteUri -and $uri.Scheme -eq 'ws' -and $uri.Port -eq $Port -and - $hostName -in @('127.0.0.1', 'localhost', '::1', '[::1]') -and -not $uri.UserInfo -and + $hostName -eq '127.0.0.1' -and -not $uri.UserInfo -and -not $uri.Query -and -not $uri.Fragment -and $uri.AbsolutePath -cmatch '^/devtools/(?:page|browser)/[A-Za-z0-9._-]{1,200}$') } catch { @@ -1029,6 +1050,35 @@ function Get-DreamSkinProcessStartedAt { } } +function Test-DreamSkinRecordedInjector { + param([AllowNull()][object]$State) + if ($null -eq $State -or -not $State.injectorPid) { return $false } + $processId = [int]$State.injectorPid + $process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId" -ErrorAction SilentlyContinue + if (-not $process) { return $false } + $expectedInjector = if ($State.injectorPath) { "$($State.injectorPath)" } elseif ($State.skillRoot) { + Join-Path "$($State.skillRoot)" 'scripts\injector.mjs' + } else { $null } + $processPath = Get-DreamSkinProcessExecutablePath -ProcessInfo $process + if (-not $processPath -or -not $process.CommandLine) { return $false } + $isNodeExecutable = [System.IO.Path]::GetFileName("$processPath") -ieq 'node.exe' + $nodeMatches = -not $State.nodePath -or (Test-DreamSkinPathEqual -Left $processPath -Right "$($State.nodePath)") + $injectorMatches = [bool]($expectedInjector -and + (Test-DreamSkinCommandLineToken -CommandLine "$($process.CommandLine)" -Token $expectedInjector) -and + (Test-DreamSkinCommandLineToken -CommandLine "$($process.CommandLine)" -Token '--watch')) + if ($State.port) { + $portPattern = '(?i)(?:^|\s)--port(?:=|\s+)' + [regex]::Escape("$($State.port)") + '(?=$|\s)' + $injectorMatches = $injectorMatches -and [regex]::IsMatch("$($process.CommandLine)", $portPattern) + } else { $injectorMatches = $false } + if ($State.browserId) { + $browserPattern = '(?:^|\s)(?i:--browser-id)(?:=|\s+)' + [regex]::Escape("$($State.browserId)") + '(?=$|\s)' + $injectorMatches = $injectorMatches -and [regex]::IsMatch("$($process.CommandLine)", $browserPattern) + } + $startedAt = Get-DreamSkinProcessStartedAt -ProcessId $processId + $startMatches = -not $State.injectorStartedAt -or $startedAt -eq "$($State.injectorStartedAt)" + return [bool]($isNodeExecutable -and $nodeMatches -and $injectorMatches -and $startMatches) +} + function Stop-DreamSkinRecordedInjector { param([AllowNull()][object]$State) if ($null -eq $State -or -not $State.injectorPid) { return $true } diff --git a/windows/scripts/injector.mjs b/windows/scripts/injector.mjs index 636bf5bf..0d2f4567 100644 --- a/windows/scripts/injector.mjs +++ b/windows/scripts/injector.mjs @@ -3,13 +3,17 @@ import { constants as fsConstants } from "node:fs"; import { createHash } from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { readImageMetadata } from "./image-metadata.mjs"; +import { MAX_VIDEO_BYTES, MediaServerController, isMp4Container } from "./media-server.mjs"; import { normalizeThemeColor, normalizeThemeText, } from "../assets/theme-package-validator.mjs"; import { decodeAndValidateSafeCss } from "../assets/safe-css-validator.mjs"; +const execFileAsync = promisify(execFile); const scriptPath = fileURLToPath(import.meta.url); const here = path.dirname(scriptPath); const root = path.resolve(here, ".."); @@ -46,7 +50,7 @@ const STRONG_THEME_AUDIT_MS = 30000; const MIN_RENDERER_VIEWPORT_WIDTH = 320; const MIN_RENDERER_VIEWPORT_HEIGHT = 240; const VISIBLE_WINDOW_STATES = new Set(["normal", "maximized", "fullscreen"]); -const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]); +const LOOPBACK_HOSTS = new Set(["127.0.0.1"]); const BROWSER_ID_PATTERN = /^[A-Za-z0-9._-]{1,200}$/; const OPERATION_UI_HOST_ID = "chatgpt-dream-skin-operation"; const OPERATION_UI_REGISTRY_KEY = "__CHATGPT_DREAM_SKIN_OPERATION_UI__"; @@ -156,6 +160,7 @@ function parseArgs(argv) { operationUiState: null, operationMessage: null, operationToken: null, + allowHiddenApplied: false, }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; @@ -175,6 +180,7 @@ function parseArgs(argv) { else if (arg === "--operation-ui-state") options.operationUiState = argv[++i]; else if (arg === "--operation-message") options.operationMessage = argv[++i]; else if (arg === "--operation-token") options.operationToken = argv[++i]; + else if (arg === "--allow-hidden-applied") options.allowHiddenApplied = true; else if (arg === "--reload") options.reload = true; else if (arg === "--self-test") options.mode = "self-test"; else if (arg === "--check-payload") options.mode = "check-payload"; @@ -212,6 +218,9 @@ function parseArgs(argv) { if (["watch", "once", "verify", "remove"].includes(options.mode) && !options.browserId) { throw new Error(`--browser-id is required in ${options.mode} mode`); } + if (options.allowHiddenApplied && options.mode !== "verify") { + throw new Error("--allow-hidden-applied is only valid in verify mode"); + } return options; } @@ -256,8 +265,9 @@ function isValidCdpPageTarget(item, port) { } class CdpSession { - constructor(target, port) { + constructor(target, port, enableDomains = true) { this.target = target; + this.enableDomains = enableDomains; this.ws = new WebSocket(validatedDebuggerUrl(target, port)); this.nextId = 1; this.pending = new Map(); @@ -284,8 +294,10 @@ class CdpSession { } this.pending.clear(); }); - await this.send("Runtime.enable"); - await this.send("Page.enable"); + if (this.enableDomains) { + await this.send("Runtime.enable"); + await this.send("Page.enable"); + } return this; } @@ -404,6 +416,36 @@ class BrowserIdentityAnchor { } } +async function verifyCodexPortOwner(port) { + const statePath = process.env.LOCALAPPDATA + ? path.join(process.env.LOCALAPPDATA, "CodexDreamSkin", "state.json") + : null; + if (!statePath) return false; + let state; + try { + state = JSON.parse(await fs.readFile(statePath, "utf8")); + } catch { + return false; + } + const expectedExecutable = typeof state.codexExe === "string" ? state.codexExe : ""; + if (!expectedExecutable) return false; + const script = "$ErrorActionPreference = 'Stop'; " + + "$listeners = @(Get-NetTCPConnection -State Listen -LocalPort $env:DREAM_SKIN_PORT " + + "| Where-Object { $_.LocalAddress -eq '127.0.0.1' }); " + + "if ($listeners.Count -ne 1) { exit 2 }; " + + "$process = Get-CimInstance Win32_Process -Filter (\"ProcessId = $($listeners[0].OwningProcess)\"); " + + "if (-not $process.ExecutablePath) { exit 3 }; " + + "[Console]::Write($process.ExecutablePath)"; + try { + const { stdout } = await execFileAsync("powershell.exe", [ + "-NoProfile", "-NonInteractive", "-Command", script, + ], { env: { ...process.env, DREAM_SKIN_PORT: String(port) }, windowsHide: true }); + const normalize = (value) => path.normalize(String(value).trim()).replace(/[\\/]+$/, "").toLowerCase(); + return normalize(stdout) === normalize(expectedExecutable); + } catch { + return false; + } +} async function fetchCdpJson(port, resource) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 2000); @@ -534,6 +576,24 @@ export async function loadTheme(themeDir) { if (!realRelativeImage || realRelativeImage.startsWith("..") || path.isAbsolute(realRelativeImage)) { throw new Error("Theme image cannot escape through a link or junction"); } + let videoPath = null; + if (raw.video !== undefined) { + if (typeof raw.video !== "string" || !raw.video || path.basename(raw.video) !== raw.video || + /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(raw.video) || + path.extname(raw.video).toLowerCase() !== ".mp4") { + throw new Error(`${themePath} has an invalid video field; only a relative MP4 filename is supported`); + } + const requestedVideoPath = path.resolve(realThemeDir, raw.video); + const relativeVideo = path.relative(realThemeDir, requestedVideoPath); + if (!relativeVideo || relativeVideo.startsWith("..") || path.isAbsolute(relativeVideo)) { + throw new Error("Theme video must remain inside the selected theme directory"); + } + videoPath = await fs.realpath(requestedVideoPath); + const realRelativeVideo = path.relative(realThemeDir, videoPath); + if (!realRelativeVideo || realRelativeVideo.startsWith("..") || path.isAbsolute(realRelativeVideo)) { + throw new Error("Theme video cannot escape through a link or junction"); + } + } const art = raw.art && typeof raw.art === "object" && !Array.isArray(raw.art) ? raw.art : {}; const palette = raw.palette && typeof raw.palette === "object" && !Array.isArray(raw.palette) ? raw.palette : {}; @@ -570,6 +630,7 @@ export async function loadTheme(themeDir) { statusText: normalizeThemeText(raw.statusText, "DREAM SKIN ONLINE", 120, "statusText", themePath), quote: normalizeThemeText(raw.quote, "MAKE SOMETHING WONDERFUL", 120, "quote", themePath), image, + ...(raw.video !== undefined ? { video: raw.video } : {}), appearance: normalizedChoice(raw.appearance, "appearance", THEME_CHOICES.appearance, "auto"), art: { focusX: normalizedUnit(art.focusX, "art.focusX"), @@ -585,9 +646,10 @@ export async function loadTheme(themeDir) { palette: {}, }; if (paletteAccent) theme.palette.accent = paletteAccent; - const [themeStat, imageStat, safeCss] = await Promise.all([ + const [themeStat, imageStat, videoStat, safeCss] = await Promise.all([ fs.stat(themePath), fs.stat(realImagePath), + videoPath ? fs.stat(videoPath) : Promise.resolve(null), loadSafeCss(realThemeDir), ]); if (!imageStat.isFile()) throw new Error("Theme image is not a file"); @@ -603,11 +665,54 @@ export async function loadTheme(themeDir) { if (!artMetadata) { throw new Error("Theme image metadata is invalid or exceeds the 16384px / 50MP safety limit"); } + if (videoStat && (!videoStat.isFile() || videoStat.size < 1 || videoStat.size > MAX_VIDEO_BYTES)) { + throw new Error(`Theme video must be a non-empty MP4 no larger than ${MAX_VIDEO_BYTES} bytes`); + } theme.artMetadata = artMetadata; - const fingerprint = createHash("sha256") + const fingerprintHash = createHash("sha256") .update(themeText, "utf8") .update("\0") .update(imageBytes) + .update("\0"); + if (videoPath) { + const videoHandle = await fs.open(videoPath, "r"); + try { + const before = await videoHandle.stat(); + if (!before.isFile() || before.size !== videoStat.size || before.mtimeMs !== videoStat.mtimeMs) { + throw new Error("Theme video changed while being loaded"); + } + const header = Buffer.alloc(16); + const { bytesRead } = await videoHandle.read(header, 0, header.length, 0); + if (!isMp4Container(header.subarray(0, bytesRead), before.size)) { + throw new Error("Theme video is not a valid MP4 container"); + } + for await (const chunk of videoHandle.createReadStream({ start: 0, autoClose: false })) { + fingerprintHash.update(chunk); + } + const after = await videoHandle.stat(); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + throw new Error("Theme video changed while being loaded"); + } + } finally { + await videoHandle.close(); + } + } + const [themeAfter, imageAfter, videoAfter, cssAfter] = await Promise.all([ + fs.stat(themePath), + fs.stat(realImagePath), + videoPath ? fs.stat(videoPath) : Promise.resolve(null), + fs.stat(path.join(realThemeDir, "theme.css")).catch((error) => { + if (error.code === "ENOENT") return null; + throw error; + }), + ]); + if (!sameFileStat(themeStat, themeAfter) || + !sameFileStat(imageStat, imageAfter) || + (videoStat ? !videoAfter || !sameFileStat(videoStat, videoAfter) : videoAfter !== null) || + (safeCss?.stat ? !cssAfter || !sameFileStat(safeCss.stat, cssAfter) : cssAfter !== null)) { + throw new Error("Active theme changed while its committed snapshot was being loaded"); + } + const fingerprint = fingerprintHash .update("\0") .update(safeCss?.source ?? "") .digest("hex"); @@ -620,13 +725,22 @@ export async function loadTheme(themeDir) { safeCssPath: safeCss?.path ?? null, safeCssStatus: safeCss ? "validated" : "none", fingerprint, + videoPath, sourceStamp: `${themeStat.size}:${themeStat.mtimeMs}:${imageStat.size}:${imageStat.mtimeMs}:` + + `${videoStat ? `${videoStat.size}:${videoStat.mtimeMs}` : "none"}:` + (safeCss ? `${safeCss.stat.size}:${safeCss.stat.mtimeMs}` : "none"), }; } -export async function loadPayload(themeDir = path.join(root, "assets"), candidateTheme = null) { +export async function loadPayload(themeDir = path.join(root, "assets"), candidateTheme = null, mediaUrl = null) { const loadedTheme = candidateTheme ?? await loadTheme(themeDir); + const videoTransport = loadedTheme.videoPath + ? (mediaUrl && typeof mediaUrl === "object" + ? mediaUrl + : typeof mediaUrl === "string" && mediaUrl + ? { mode: "server", url: mediaUrl } + : { mode: "blob" }) + : null; const [css, template] = await Promise.all([ fs.readFile(path.join(root, "assets", "dream-skin.css"), "utf8"), fs.readFile(path.join(root, "assets", "renderer-inject.js"), "utf8"), @@ -656,6 +770,7 @@ export async function loadPayload(themeDir = path.join(root, "assets"), candidat .replace("__DREAM_SKIN_CSS_JSON__", () => JSON.stringify(combinedCss)) .replace("__DREAM_SKIN_ART_JSON__", () => JSON.stringify(artDataUrl)) .replace("__DREAM_SKIN_THEME_JSON__", () => JSON.stringify(loadedTheme.theme)) + .replace("__DREAM_SKIN_VIDEO_JSON__", () => JSON.stringify(videoTransport)) .replace("__DREAM_SKIN_VERSION_JSON__", () => JSON.stringify(SKIN_VERSION)) .replace("__DREAM_SKIN_STYLE_REVISION_JSON__", () => JSON.stringify(styleRevision)) .replace("__DREAM_SKIN_PAYLOAD_REVISION_JSON__", () => JSON.stringify(revision)); @@ -673,7 +788,7 @@ export async function loadPayload(themeDir = path.join(root, "assets"), candidat throw new Error(`Payload failed to parse as JavaScript: ${error.message}`); } const { imageBytes: _imageBytes, ...themeState } = loadedTheme; - return { ...themeState, payload, revision }; + return { ...themeState, payload, revision, videoTransport }; } async function fileExists(filePath) { @@ -687,15 +802,17 @@ async function fileExists(filePath) { } async function readThemeSourceStamp(loadedTheme) { - const [themeStat, imageStat, cssStat] = await Promise.all([ + const [themeStat, imageStat, videoStat, cssStat] = await Promise.all([ fs.stat(loadedTheme.themePath), fs.stat(loadedTheme.imagePath), + loadedTheme.videoPath ? fs.stat(loadedTheme.videoPath) : Promise.resolve(null), fs.stat(path.join(path.dirname(loadedTheme.themePath), "theme.css")).catch((error) => { if (error.code === "ENOENT") return null; throw error; }), ]); return `${themeStat.size}:${themeStat.mtimeMs}:${imageStat.size}:${imageStat.mtimeMs}:` + + `${videoStat ? `${videoStat.size}:${videoStat.mtimeMs}` : "none"}:` + (cssStat ? `${cssStat.size}:${cssStat.mtimeMs}` : "none"); } @@ -736,6 +853,17 @@ async function connectTarget(target, port) { return new CdpSession(target, port).open(); } +async function connectBrowserSession(port, expectedBrowserId) { + const version = await fetchCdpJson(port, "/json/version"); + const actualBrowserId = browserIdFromVersion(version, port); + if (actualBrowserId !== expectedBrowserId) { + throw new CdpIdentityMismatchError( + `CDP browser identity changed from ${expectedBrowserId} to ${actualBrowserId}`, + ); + } + return new CdpSession(version, port, false).open(); +} + function unavailableNativeWindow(error) { const message = String(error?.message ?? ""); const cdpCode = Number(error?.cdpCode); @@ -766,42 +894,233 @@ function unavailableNativeWindow(error) { }; } -export async function inspectTargetWindow(session, targetId) { - if (typeof targetId !== "string" || !BROWSER_ID_PATTERN.test(targetId)) { - return { pass: false, bound: false, reason: "invalid-target-id" }; - } +let nativeWindowProbeCache = { checkedAt: 0, value: null }; - let binding; +export async function inspectCodexNativeWindow() { + const now = Date.now(); + if (nativeWindowProbeCache.value && now - nativeWindowProbeCache.checkedAt < 1500) { + return nativeWindowProbeCache.value; + } + const unavailable = (reason, detail = null) => ({ + pass: false, + bound: false, + reason, + detail, + }); + const statePath = process.env.LOCALAPPDATA + ? path.join(process.env.LOCALAPPDATA, "CodexDreamSkin", "state.json") + : null; + let state; try { - binding = await session.send("Browser.getWindowForTarget", { targetId }); + state = JSON.parse(await fs.readFile(statePath, "utf8")); } catch (error) { - return unavailableNativeWindow(error); + return unavailable("native-window-state-unavailable", error.message); + } + const port = Number(state?.port); + const expectedExecutable = typeof state?.codexExe === "string" ? state.codexExe : ""; + if (!Number.isInteger(port) || !expectedExecutable || !await verifyCodexPortOwner(port)) { + return unavailable("native-window-owner-unverified"); + } + const script = ` +$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @' +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; + +public static class DreamSkinNativeWindowProbe { + private delegate bool EnumWindowProc(IntPtr hwnd, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowProc callback, IntPtr lParam); + [DllImport("user32.dll")] + private static extern bool EnumChildWindows(IntPtr parent, EnumWindowProc callback, IntPtr lParam); + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(IntPtr hwnd); + [DllImport("user32.dll")] + private static extern bool IsIconic(IntPtr hwnd); + [DllImport("user32.dll")] + private static extern bool GetWindowRect(IntPtr hwnd, out Rect rect); + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint processId); + + [StructLayout(LayoutKind.Sequential)] + private struct Rect { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + private static string ProcessPath(uint processId) { + try { + using (Process process = Process.GetProcessById((int)processId)) { + return process.MainModule.FileName; + } + } catch { + return null; + } } - if (!Number.isInteger(binding?.windowId) || binding.windowId <= 0) { - return { pass: false, bound: false, reason: "invalid-window-binding" }; + + private static bool MatchesExecutable(IntPtr hwnd, string expectedExecutable, out uint processId) { + processId = 0; + GetWindowThreadProcessId(hwnd, out processId); + string executable = ProcessPath(processId); + return executable != null && + String.Equals(executable, expectedExecutable, StringComparison.OrdinalIgnoreCase); } - let latest; + private static bool ContainsExpectedChild(IntPtr parent, string expectedExecutable, out uint processId) { + uint match = 0; + EnumChildWindows(parent, delegate(IntPtr child, IntPtr unused) { + uint childProcess; + if (MatchesExecutable(child, expectedExecutable, out childProcess)) { + match = childProcess; + return false; + } + return true; + }, IntPtr.Zero); + processId = match; + return match != 0; + } + + public static string Find(string expectedExecutable, int minimumWidth, int minimumHeight) { + long bestArea = -1; + IntPtr bestWindow = IntPtr.Zero; + uint bestProcess = 0; + int bestWidth = 0; + int bestHeight = 0; + EnumWindows(delegate(IntPtr hwnd, IntPtr unused) { + if (!IsWindowVisible(hwnd) || IsIconic(hwnd)) return true; + Rect rect; + if (!GetWindowRect(hwnd, out rect)) return true; + int width = rect.Right - rect.Left; + int height = rect.Bottom - rect.Top; + if (width < minimumWidth || height < minimumHeight) return true; + uint processId; + if (!MatchesExecutable(hwnd, expectedExecutable, out processId) && + !ContainsExpectedChild(hwnd, expectedExecutable, out processId)) { + return true; + } + long area = (long)width * height; + if (area > bestArea) { + bestArea = area; + bestWindow = hwnd; + bestProcess = processId; + bestWidth = width; + bestHeight = height; + } + return true; + }, IntPtr.Zero); + return bestWindow == IntPtr.Zero + ? "" + : bestProcess + "|" + bestWindow.ToInt64() + "|" + bestWidth + "|" + bestHeight; + } +} +'@ +[Console]::Write([DreamSkinNativeWindowProbe]::Find( + $env:DREAM_SKIN_EXPECTED_EXE, + [int]$env:DREAM_SKIN_MIN_WIDTH, + [int]$env:DREAM_SKIN_MIN_HEIGHT +)) +`; + let value; try { - latest = await session.send("Browser.getWindowBounds", { windowId: binding.windowId }); + const { stdout } = await execFileAsync("powershell.exe", [ + "-NoProfile", "-NonInteractive", "-Command", script, + ], { + env: { + ...process.env, + DREAM_SKIN_EXPECTED_EXE: expectedExecutable, + DREAM_SKIN_MIN_WIDTH: String(MIN_RENDERER_VIEWPORT_WIDTH), + DREAM_SKIN_MIN_HEIGHT: String(MIN_RENDERER_VIEWPORT_HEIGHT), + }, + windowsHide: true, + timeout: 8000, + }); + const match = /^(\d+)\|(\d+)\|(\d+)\|(\d+)$/.exec(String(stdout).trim()); + value = match ? { + pass: true, + bound: true, + targetBound: false, + processBound: true, + source: "verified-codex-process-window", + processId: Number(match[1]), + windowHandle: Number(match[2]), + state: "normal", + width: Number(match[3]), + height: Number(match[4]), + reason: null, + } : unavailable("codex-native-window-unavailable"); } catch (error) { - return unavailableNativeWindow(error); - } - const bounds = { ...(binding.bounds ?? {}), ...(latest?.bounds ?? {}) }; - const state = typeof bounds.windowState === "string" ? bounds.windowState : null; - const width = Number.isFinite(bounds.width) ? Number(bounds.width) : null; - const height = Number.isFinite(bounds.height) ? Number(bounds.height) : null; - const statePass = VISIBLE_WINDOW_STATES.has(state); - const boundsPass = width !== null && height !== null && - width >= MIN_RENDERER_VIEWPORT_WIDTH && height >= MIN_RENDERER_VIEWPORT_HEIGHT; - return { - pass: statePass && boundsPass, - bound: true, - windowId: binding.windowId, - state, - width, - height, - reason: !statePass ? "window-not-visible" : !boundsPass ? "window-bounds-too-small" : null, + value = unavailable("codex-native-window-probe-failed", error.message); + } + nativeWindowProbeCache = { checkedAt: Date.now(), value }; + return value; +} + +export async function inspectTargetWindow( + session, + targetId, + browserSession = session, + nativeWindowFallback = inspectCodexNativeWindow, +) { + if (typeof targetId !== "string" || !BROWSER_ID_PATTERN.test(targetId)) { + return { pass: false, bound: false, reason: "invalid-target-id" }; + } + + const commandAttempts = [ + { commandSession: browserSession, params: { targetId } }, + ]; + if (session !== browserSession) { + commandAttempts.push( + { commandSession: session, params: { targetId } }, + { commandSession: session, params: {} }, + ); + } + let lastError = null; + for (const { commandSession, params } of commandAttempts) { + let binding; + try { + binding = await commandSession.send("Browser.getWindowForTarget", params); + } catch (error) { + lastError = error; + continue; + } + if (!Number.isInteger(binding?.windowId) || binding.windowId <= 0) { + return { pass: false, bound: false, reason: "invalid-window-binding" }; + } + + let latest; + try { + latest = await commandSession.send("Browser.getWindowBounds", { windowId: binding.windowId }); + } catch (error) { + lastError = error; + continue; + } + const bounds = { ...(binding.bounds ?? {}), ...(latest?.bounds ?? {}) }; + const state = typeof bounds.windowState === "string" ? bounds.windowState : null; + const width = Number.isFinite(bounds.width) ? Number(bounds.width) : null; + const height = Number.isFinite(bounds.height) ? Number(bounds.height) : null; + const statePass = VISIBLE_WINDOW_STATES.has(state); + const boundsPass = width !== null && height !== null && + width >= MIN_RENDERER_VIEWPORT_WIDTH && height >= MIN_RENDERER_VIEWPORT_HEIGHT; + return { + pass: statePass && boundsPass, + bound: true, + windowId: binding.windowId, + state, + width, + height, + reason: !statePass ? "window-not-visible" : !boundsPass ? "window-bounds-too-small" : null, + }; + } + const unavailable = unavailableNativeWindow(lastError); + const fallback = await nativeWindowFallback(unavailable); + return fallback?.pass ? fallback : { + ...unavailable, + fallbackReason: fallback?.reason ?? null, + fallbackDetail: fallback?.detail ?? null, }; } @@ -835,8 +1154,75 @@ async function connectCodexTargets(port, timeoutMs, expectedBrowserId) { throw new Error(`No verified Codex renderer on 127.0.0.1:${port}: ${lastError?.message ?? "timed out"}`); } -async function applyToSession(session, payload) { - return session.evaluate(payload); +const VIDEO_INPUT_SELECTOR = "#codex-dream-skin-video-input"; +const VIDEO_STATE_KEY = "__CODEX_DREAM_SKIN_STATE__"; +const mediaPolicyTargets = new Set(); + +async function attachBlobVideoToSession(session, loadedPayload) { + if (loadedPayload?.videoTransport?.mode !== "blob" || !loadedPayload.videoPath) return true; + const deadline = Date.now() + 8000; + let lastError = null; + while (Date.now() < deadline) { + try { + const inputReady = await session.evaluate(`Boolean(window[${JSON.stringify(VIDEO_STATE_KEY)}]?.ensureVideoInput?.())`); + if (!inputReady) throw new Error("Renderer did not create the video file input"); + await session.send("DOM.enable"); + const document = await session.send("DOM.getDocument", { depth: -1 }); + const node = await session.send("DOM.querySelector", { + nodeId: document.root.nodeId, + selector: VIDEO_INPUT_SELECTOR, + }); + if (!node.nodeId) throw new Error("Video file input is not attached to the renderer DOM"); + await session.send("DOM.setFileInputFiles", { nodeId: node.nodeId, files: [loadedPayload.videoPath] }); + const attached = await session.evaluate( + `window[${JSON.stringify(VIDEO_STATE_KEY)}]?.attachVideoFile?.() === true`, + ); + if (attached) return true; + const prepared = await session.evaluate(`(() => { + const state = window[${JSON.stringify(VIDEO_STATE_KEY)}]; + const video = document.querySelector("#codex-dream-skin-background-stage video"); + return Boolean(video?.src && !state?.videoFailed); + })()`); + if (prepared) return true; + const handled = await session.evaluate( + `Boolean(window[${JSON.stringify(VIDEO_STATE_KEY)}]?.videoFailed)`, + ); + if (handled) return true; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 120)); + } + throw new Error(`Could not attach the local MP4 through CDP: ${lastError?.message ?? "timed out"}`); +} + +async function syncMediaFetchPolicyForSession(session, loadedPayload) { + const transport = loadedPayload?.videoTransport; + const needsBypass = transport?.mode === "server" || Boolean(transport?.fallbackUrl); + const targetId = session.target?.id; + if (typeof targetId !== "string") return; + const bypassActive = mediaPolicyTargets.has(targetId); + if (needsBypass === bypassActive) return; + if (needsBypass) { + // The renderer fetches the loopback fallback and turns it into a Blob URL. + // Enable this only after the CDP target has passed the Codex identity probe; + // image-only themes and unrelated app pages retain their normal CSP. + await session.send("Page.enable").catch(() => {}); + await session.send("Page.setWebLifecycleState", { state: "active" }).catch(() => {}); + await session.send("Page.setBypassCSP", { enabled: true }); + mediaPolicyTargets.add(targetId); + } else { + await session.send("Page.enable").catch(() => {}); + await session.send("Page.setBypassCSP", { enabled: false }).catch(() => {}); + mediaPolicyTargets.delete(targetId); + } +} + +async function applyToSession(session, payload, loadedPayload = null) { + await syncMediaFetchPolicyForSession(session, loadedPayload); + const result = await session.evaluate(payload); + await attachBlobVideoToSession(session, loadedPayload); + return result; } export function earlyPayloadFor(payload, revision) { @@ -1117,8 +1503,15 @@ export async function verifySession( targetId, expectedThemeId = null, expectedRevision = null, + browserSession = session, + nativeWindowFallback = inspectCodexNativeWindow, ) { - const nativeWindow = await inspectTargetWindow(session, targetId); + const nativeWindow = await inspectTargetWindow( + session, + targetId, + browserSession, + nativeWindowFallback, + ); return session.evaluate(`(() => { const box = (node) => { if (!node) return null; @@ -1179,6 +1572,12 @@ export async function verifySession( expectedVersion: ${JSON.stringify(SKIN_VERSION)}, themeId: runtime?.themeId ?? null, revision: runtime?.revision ?? null, + videoMode: runtime?.videoMode ?? null, + videoReady: runtime?.videoReady ?? null, + videoFailed: runtime?.videoFailed ?? null, + videoError: runtime?.videoError ?? null, + mediaTransitions: Array.isArray(runtime?.mediaTransitions) + ? runtime.mediaTransitions.slice(-16) : [], styleMode: runtime?.styleMode ?? null, stylePresent: Boolean(adopted || fallback), scope: runtime?.scope ?? null, @@ -1225,16 +1624,22 @@ export async function verifySession( const expectedRevision = ${JSON.stringify(expectedRevision)}; const payloadPass = (!expectedThemeId || result.themeId === expectedThemeId) && (!expectedRevision || result.revision === expectedRevision); + const videoPass = !result.videoMode || result.videoReady === true; result.expectedThemeId = expectedThemeId; result.expectedRevision = expectedRevision; + result.payloadPass = payloadPass; + result.videoPass = videoPass; result.readiness = { windowPass, documentPass, viewportPass, structurePass, nativeWindowPass, fallbackWindowPass, }; + result.appliedPass = result.installed && result.version === result.expectedVersion && + result.stylePresent && result.businessClassPollution === 0 && windowPass && + viewportPass && structurePass && payloadPass; result.pass = result.installed && result.version === result.expectedVersion && result.stylePresent && result.businessClassPollution === 0 && windowPass && documentPass && viewportPass && structurePass && - payloadPass && + payloadPass && videoPass && (!result.homePresent || (Boolean(result.homeSurface?.visible && result.hero?.visible) && (!result.suggestionsPresent || (result.cards.length >= 2 && result.cards.length <= 4)))); return result; @@ -1247,15 +1652,23 @@ async function waitForVerifiedSession( timeoutMs, expectedThemeId = null, expectedRevision = null, + browserSession = session, + allowHiddenApplied = false, ) { const deadline = Date.now() + timeoutMs; let lastResult; let lastError; while (Date.now() < deadline) { try { - lastResult = await verifySession(session, targetId, expectedThemeId, expectedRevision); + lastResult = await verifySession( + session, + targetId, + expectedThemeId, + expectedRevision, + browserSession, + ); lastError = null; - if (lastResult.pass) return lastResult; + if (rendererVerificationAccepted(lastResult, allowHiddenApplied)) return lastResult; } catch (error) { lastError = error; } @@ -1265,6 +1678,29 @@ async function waitForVerifiedSession( return lastResult; } +export function isDeferredRendererVerification(result) { + return Boolean( + result?.appliedPass === true && + result?.readiness?.windowPass === true && + result?.readiness?.viewportPass === true && + result?.readiness?.structurePass === true && + result?.payloadPass === true && + result?.readiness?.documentPass === false, + ); +} + +export function rendererVerificationAccepted(result, allowHiddenApplied = false) { + return result?.pass === true || + (allowHiddenApplied === true && isDeferredRendererVerification(result)); +} + +function rendererVerificationError(targetId, result, phase = "update") { + const error = new Error(`renderer ${phase} verification failed for ${targetId}`); + error.deferred = isDeferredRendererVerification(result); + error.verification = result; + return error; +} + async function capture(session, outputPath) { await fs.mkdir(path.dirname(outputPath), { recursive: true }); const result = await session.send("Page.captureScreenshot", { @@ -1315,6 +1751,13 @@ async function runFinishOperation(options) { async function runOneShot(options) { const connected = await connectCodexTargets(options.port, options.timeoutMs, options.browserId); + let browserSession; + try { + browserSession = await connectBrowserSession(options.port, options.browserId); + } catch (error) { + for (const { session } of connected) session.close(); + throw error; + } const operationToken = options.mode === "once" || options.mode === "remove" ? options.operationToken ?? nextOperationToken() : null; @@ -1345,15 +1788,17 @@ async function runOneShot(options) { try { for (const { target, session, probe } of connected) { try { - if (options.mode === "remove") await removeFromSession(session); - else if (options.mode === "once") { + if (options.mode === "remove") { + await removeFromSession(session); + await syncMediaFetchPolicyForSession(session, null); + } else if (options.mode === "once") { if (operationToken) { await bestEffortOperationUi( session, "update", operationToken, "loading", `正在应用「${loadedPayload.theme.name}」…`, ); } - await applyToSession(session, payload); + await applyToSession(session, payload, loadedPayload); await new Promise((resolve) => setTimeout(resolve, 850)); } if (options.reload) { @@ -1366,7 +1811,7 @@ async function runOneShot(options) { `正在应用「${loadedPayload.theme.name}」…`, ); } - await applyToSession(session, payload); + await applyToSession(session, payload, loadedPayload); } } if (operationToken) { @@ -1386,9 +1831,19 @@ async function runOneShot(options) { options.timeoutMs, loadedPayload?.theme.id ?? null, loadedPayload?.revision ?? null, + browserSession, + options.allowHiddenApplied, ) - : await verifySession(session, target.id); - results.push({ targetId: target.id, markers: probe.markers, result: verified }); + : await verifySession(session, target.id, null, null, browserSession); + const acceptedDeferred = options.mode === "verify" && + !verified?.pass && + rendererVerificationAccepted(verified, options.allowHiddenApplied); + results.push({ + targetId: target.id, + markers: probe.markers, + acceptedDeferred, + result: verified, + }); if (operationToken) { const passed = options.mode === "remove" ? verified === true : verified?.pass; await presentOperationUi( @@ -1422,29 +1877,147 @@ async function runOneShot(options) { } } } finally { + browserSession.close(); for (const { session } of connected) session.close(); } console.log(JSON.stringify({ mode: options.mode, port: options.port, targets: results }, null, 2)); const failed = results.length === 0 || results.some((item) => - item.error || (options.mode === "remove" ? item.result !== true : !item.result?.pass)); + item.error || (options.mode === "remove" + ? item.result !== true + : !rendererVerificationAccepted(item.result, options.allowHiddenApplied))); if (failed) process.exitCode = 2; } async function runWatch(options) { - const identityAnchor = await connectBrowserIdentityAnchor(options.port, options.browserId); + const initialAnchor = await connectBrowserIdentityAnchor(options.port, options.browserId); + const mediaServers = new MediaServerController(); + let browserSession = await connectBrowserSession(options.port, options.browserId); + const stagePayload = async (candidateTheme = null, includeMedia = true) => { + const theme = candidateTheme ?? await loadTheme(options.themeDir); + const stagedMedia = includeMedia ? await mediaServers.stage(theme.videoPath) : null; + try { + const videoTransport = theme.videoPath && stagedMedia?.url + ? { mode: "blob", fallbackUrl: stagedMedia.url } + : theme.videoPath ? { mode: "blob" } : null; + const payload = await loadPayload(options.themeDir, theme, videoTransport); + return { theme, payload, stagedMedia }; + } catch (error) { + await mediaServers.abort(stagedMedia); + throw error; + } + }; const sessions = new Map(); const earlyScripts = new Map(); const fallbackTargets = new Map(); const fallbackListeners = new Set(); const targetFailures = new Map(); + let identityState = { + anchor: initialAnchor, + browserId: options.browserId, + generation: 0, + }; + let identityRebind = null; let stopping = false; let listFailures = 0; let lastListErrorLogAt = 0; let lastThemeErrorLogAt = 0; let lastStrongThemeAuditAt = 0; let loadedPayload = null; - let paused = false; + let activeTheme = null; + let rejectedSourceStamp = null; + let deferredSourceStamp = null; + let deferredUntil = 0; + let paused = await fileExists(options.pauseFile); const stop = () => { stopping = true; }; + const cleanupIdentitySessions = async () => { + for (const [id, session] of sessions) { + await removeEarlyPayload(session, earlyScripts.get(id)); + earlyScripts.delete(id); + fallbackTargets.delete(id); + fallbackListeners.delete(id); + session.close(); + sessions.delete(id); + targetFailures.delete(id); + mediaPolicyTargets.delete(id); + } + }; + const rebindIdentity = async () => { + if (identityRebind) return identityRebind; + identityRebind = (async () => { + const current = identityState; + const ownerVerified = await verifyCodexPortOwner(options.port); + if (!ownerVerified) { + throw new CdpIdentityMismatchError("CDP listener ownership is not verified as the official Codex process"); + } + const version = await fetchCdpJson(options.port, "/json/version"); + const nextBrowserId = browserIdFromVersion(version, options.port); + if (nextBrowserId === current.browserId) { + if (current.anchor.closed) { + throw new CdpIdentityMismatchError("Active CDP browser identity closed without a verified replacement"); + } + return false; + } + const candidate = new BrowserIdentityAnchor(validatedDebuggerUrl(version, options.port)); + try { + await candidate.open(); + const confirmedVersion = await fetchCdpJson(options.port, "/json/version"); + const confirmedBrowserId = browserIdFromVersion(confirmedVersion, options.port); + if (confirmedBrowserId !== nextBrowserId) { + throw new CdpIdentityMismatchError("CDP browser identity changed again during rebind"); + } + if (!await verifyCodexPortOwner(options.port)) { + throw new CdpIdentityMismatchError("CDP listener ownership changed during rebind"); + } + const targets = await listAppTargets(options.port, nextBrowserId); + let verifiedTarget = false; + for (const target of targets) { + let session; + try { + session = await connectTarget(target, options.port); + const probe = await waitForCodexProbe(session); + if (probe?.codex) { + verifiedTarget = true; + break; + } + } catch { + // A target that cannot be probed is not a verified Codex renderer. + } finally { + session?.close(); + } + } + if (!verifiedTarget) throw new Error("No verified Codex renderer during CDP identity rebind"); + const nextBrowserSession = await connectBrowserSession(options.port, nextBrowserId); + const previous = identityState; + identityState = { + anchor: candidate, + browserId: nextBrowserId, + generation: previous.generation + 1, + }; + const previousBrowserSession = browserSession; + browserSession = nextBrowserSession; + previousBrowserSession.close(); + previous.anchor.close(); + await cleanupIdentitySessions(); + console.error(`[dream-skin] rebound verified CDP browser identity to ${nextBrowserId}`); + return true; + } catch (error) { + candidate.close(); + throw error; + } + })(); + try { + return await identityRebind; + } finally { + identityRebind = null; + } + }; + const ensureCurrentIdentity = async () => { + const version = await fetchCdpJson(options.port, "/json/version"); + const browserId = browserIdFromVersion(version, options.port); + if (browserId !== identityState.browserId || identityState.anchor.closed || browserSession.closed) { + await rebindIdentity(); + } + }; const rejectTarget = (target, baseDelayMs, error = null) => { const previous = targetFailures.get(target.id) ?? { failures: 0, lastLogAt: 0 }; const failures = previous.failures + 1; @@ -1463,7 +2036,8 @@ async function runWatch(options) { session.on("Page.loadEventFired", () => { if (!fallbackTargets.get(id)) return; setTimeout(() => { - const operation = paused ? removeFromSession(session) : applyToSession(session, loadedPayload.payload); + const operation = paused ? removeFromSession(session) : + applyToSession(session, loadedPayload.payload, loadedPayload); operation.catch((error) => { if (Date.now() - lastReinjectErrorLogAt >= 30000) { console.error(`[dream-skin] reinject failed for ${target.id}: ${error.message}`); @@ -1477,18 +2051,22 @@ async function runWatch(options) { process.on("SIGTERM", stop); try { - loadedPayload = await loadPayload(options.themeDir); + const initial = await stagePayload(null, !paused); + loadedPayload = initial.payload; + activeTheme = initial.theme; lastStrongThemeAuditAt = Date.now(); - paused = await fileExists(options.pauseFile); + await mediaServers.commit(paused ? null : initial.stagedMedia); while (!stopping) { - if (identityAnchor.closed) { - console.error("[dream-skin] original CDP browser identity closed; watcher is stopping instead of reconnecting"); + try { + await ensureCurrentIdentity(); + } catch (error) { + console.error(`[dream-skin] ${new Date().toISOString()} ${error.message}; stopping without unsafe CDP rebinding`); process.exitCode = 3; break; } let targets = []; try { - targets = await listAppTargets(options.port); + targets = await listAppTargets(options.port, identityState.browserId); listFailures = 0; } catch (error) { listFailures += 1; @@ -1503,10 +2081,13 @@ async function runWatch(options) { const nextPaused = await fileExists(options.pauseFile); let nextPayload = loadedPayload; + let nextTheme = activeTheme; + let stagedMedia = null; + let payloadUpdateFailed = false; if (!nextPaused) { try { const now = Date.now(); - let shouldAudit = !loadedPayload || now - lastStrongThemeAuditAt >= STRONG_THEME_AUDIT_MS; + let shouldAudit = !loadedPayload || paused || now - lastStrongThemeAuditAt >= STRONG_THEME_AUDIT_MS; if (!shouldAudit) { try { shouldAudit = await readThemeSourceStamp(loadedPayload) !== loadedPayload.sourceStamp; @@ -1517,13 +2098,45 @@ async function runWatch(options) { if (shouldAudit) { const candidateTheme = await loadTheme(options.themeDir); lastStrongThemeAuditAt = now; - if (!loadedPayload || candidateTheme.fingerprint !== loadedPayload.fingerprint) { - nextPayload = await loadPayload(options.themeDir, candidateTheme); + if (deferredSourceStamp && candidateTheme.sourceStamp !== deferredSourceStamp) { + deferredSourceStamp = null; + deferredUntil = 0; + } + if (deferredSourceStamp && candidateTheme.sourceStamp === deferredSourceStamp && + now < deferredUntil) { + // The exact payload is waiting for its renderer document to + // become visible. Retry at a bounded cadence without restaging + // a large video on every watch tick. + } else if (rejectedSourceStamp && candidateTheme.sourceStamp === rejectedSourceStamp) { + // A renderer already rejected these exact source bytes. Keep the + // verified in-memory payload until the active theme changes + // instead of reapplying the same large video every watch cycle. + } else if (!loadedPayload || candidateTheme.fingerprint !== loadedPayload.fingerprint || + candidateTheme.sourceStamp !== loadedPayload.sourceStamp) { + const staged = await stagePayload(candidateTheme); + nextPayload = staged.payload; + nextTheme = staged.theme; + stagedMedia = staged.stagedMedia; + } else if (paused) { + const staged = await stagePayload(candidateTheme); + nextPayload = staged.payload; + nextTheme = staged.theme; + stagedMedia = staged.stagedMedia; } else { loadedPayload.sourceStamp = candidateTheme.sourceStamp; + activeTheme = candidateTheme; } + } else if (paused && activeTheme) { + const staged = await stagePayload(activeTheme); + nextPayload = staged.payload; + nextTheme = staged.theme; + stagedMedia = staged.stagedMedia; } } catch (error) { + if (stagedMedia) await mediaServers.abort(stagedMedia); + stagedMedia = null; + nextPayload = loadedPayload; + nextTheme = activeTheme; if (Date.now() - lastThemeErrorLogAt >= 30000) { console.error(`[dream-skin] theme update rejected: ${error.message}; keeping the active theme`); lastThemeErrorLogAt = Date.now(); @@ -1532,15 +2145,24 @@ async function runWatch(options) { } const pauseChanged = nextPaused !== paused; const payloadChanged = !nextPaused && nextPayload !== loadedPayload; + const previousPayload = loadedPayload; + const previousPaused = paused; + const previousTheme = activeTheme; loadedPayload = nextPayload; + activeTheme = nextTheme; paused = nextPaused; if (pauseChanged || payloadChanged) { + // Keep the previous media server alive until every renderer verifies the update. + // A failed update can then replay the previous payload without rereading files that + // may already have been replaced in the active theme directory. + let updateError = null; for (const [id, session] of sessions) { try { const previousEarlyScript = earlyScripts.get(id); if (paused) { await removeFromSession(session); + await syncMediaFetchPolicyForSession(session, null); await removeEarlyPayload(session, previousEarlyScript); earlyScripts.delete(id); fallbackTargets.delete(id); @@ -1563,19 +2185,79 @@ async function runWatch(options) { if (nextEarlyScript) earlyScripts.set(id, nextEarlyScript); else earlyScripts.delete(id); await removeEarlyPayload(session, previousEarlyScript); - await applyToSession(session, loadedPayload.payload); + await applyToSession(session, loadedPayload.payload, loadedPayload); + const verified = await waitForVerifiedSession( + session, + id, + options.timeoutMs, + loadedPayload.theme.id, + loadedPayload.revision, + browserSession, + ); + if (!verified?.pass) throw rendererVerificationError(id, verified); } } catch (error) { + updateError = error; console.error(`[dream-skin] live theme update failed for ${id}: ${error.message}`); - await removeEarlyPayload(session, earlyScripts.get(id)); - earlyScripts.delete(id); - fallbackTargets.delete(id); - fallbackListeners.delete(id); - session.close(); - sessions.delete(id); + break; } } - console.log(paused ? "[dream-skin] paused" : `[dream-skin] active theme ${loadedPayload.theme.id}`); + if (updateError && previousPayload) { + if (updateError.deferred) { + deferredSourceStamp = loadedPayload?.sourceStamp ?? null; + deferredUntil = Date.now() + 5000; + await mediaServers.abort(stagedMedia); + stagedMedia = null; + loadedPayload = previousPayload; + activeTheme = previousTheme; + paused = previousPaused; + console.error(`[dream-skin] live theme update deferred for a hidden renderer: ${updateError.message}`); + continue; + } + try { + rejectedSourceStamp = loadedPayload?.sourceStamp ?? null; + await mediaServers.abort(stagedMedia); + stagedMedia = null; + loadedPayload = previousPayload; + activeTheme = previousTheme; + paused = previousPaused; + if (previousPaused) await mediaServers.commit(null); + for (const [id, session] of sessions) { + if (previousPaused) { + await removeFromSession(session); + await syncMediaFetchPolicyForSession(session, null); + } else { + await applyToSession(session, loadedPayload.payload, loadedPayload); + const verified = await waitForVerifiedSession( + session, + id, + options.timeoutMs, + loadedPayload.theme.id, + loadedPayload.revision, + browserSession, + ); + if (!verified?.pass && !isDeferredRendererVerification(verified)) { + throw rendererVerificationError(id, verified, "rollback"); + } + } + } + console.error(`[dream-skin] reverted to active theme after verification failure: ${updateError.message}`); + continue; + } catch (rollbackError) { + console.error(`[dream-skin] theme rollback failed: ${rollbackError.message}`); + throw rollbackError; + } + } + if (updateError) throw updateError; + await mediaServers.commit(paused ? null : stagedMedia); + stagedMedia = null; + rejectedSourceStamp = null; + deferredSourceStamp = null; + deferredUntil = 0; + console.log(paused + ? `[dream-skin] ${new Date().toISOString()} paused` + : `[dream-skin] ${new Date().toISOString()} active theme ${loadedPayload.theme.id} ` + + `revision ${loadedPayload.revision} source ${loadedPayload.sourceStamp}`); } const activeIds = new Set(targets.map((target) => target.id)); @@ -1591,18 +2273,22 @@ async function runWatch(options) { session.close(); sessions.delete(id); targetFailures.delete(id); + mediaPolicyTargets.delete(id); } } + const identityGeneration = identityState.generation; for (const target of targets) { - if (identityAnchor.closed) break; + if (identityState.generation !== identityGeneration || identityState.anchor.closed) break; if (sessions.has(target.id)) continue; if ((targetFailures.get(target.id)?.until ?? 0) > Date.now()) continue; let session; let earlyScriptId = null; try { session = await connectTarget(target, options.port); - if (identityAnchor.closed) throw new CdpIdentityMismatchError("Original CDP browser identity closed"); + if (identityState.generation !== identityGeneration || identityState.anchor.closed) { + throw new CdpIdentityMismatchError("CDP browser identity changed during target setup"); + } let earlyInjectionFallback = false; if (!paused) { try { @@ -1627,9 +2313,12 @@ async function runWatch(options) { session.close(); continue; } + await syncMediaFetchPolicyForSession(session, loadedPayload); fallbackTargets.set(target.id, earlyInjectionFallback); if (earlyInjectionFallback) attachLoadFallback(target.id, target, session); - if (identityAnchor.closed) throw new CdpIdentityMismatchError("Original CDP browser identity closed"); + if (identityState.generation !== identityGeneration || identityState.anchor.closed) { + throw new CdpIdentityMismatchError("CDP browser identity changed during renderer setup"); + } let earlyApplied = false; if (!paused && !earlyInjectionFallback) { earlyApplied = await session.evaluate( @@ -1637,7 +2326,15 @@ async function runWatch(options) { ).catch(() => false); } if (paused) await removeFromSession(session); - else if (!earlyApplied) await applyToSession(session, loadedPayload.payload); + else if (!earlyApplied) await applyToSession(session, loadedPayload.payload, loadedPayload); + else if (loadedPayload.videoTransport?.mode === "server" || loadedPayload.videoTransport?.fallbackUrl) { + // Early payloads can run before CSP is relaxed; replay the full + // media payload after the verified-target media policy is active. + await applyToSession(session, loadedPayload.payload, loadedPayload); + } else { + await syncMediaFetchPolicyForSession(session, loadedPayload); + await attachBlobVideoToSession(session, loadedPayload); + } sessions.set(target.id, session); if (earlyScriptId) earlyScripts.set(target.id, earlyScriptId); targetFailures.delete(target.id); @@ -1647,14 +2344,16 @@ async function runWatch(options) { fallbackTargets.delete(target.id); fallbackListeners.delete(target.id); session?.close(); - if (identityAnchor.closed || error instanceof CdpIdentityMismatchError) break; + mediaPolicyTargets.delete(target.id); + if (identityState.generation !== identityGeneration || identityState.anchor.closed) break; rejectTarget(target, 2500, error); } } await new Promise((resolve) => setTimeout(resolve, 1200)); } } finally { - identityAnchor.close(); + identityState.anchor.close(); + browserSession.close(); for (const [id, session] of sessions) { await removeEarlyPayload(session, earlyScripts.get(id)); session.close(); @@ -1662,6 +2361,7 @@ async function runWatch(options) { earlyScripts.clear(); fallbackTargets.clear(); fallbackListeners.clear(); + await mediaServers.close(); } } @@ -1679,6 +2379,8 @@ if (path.resolve(process.argv[1] || "") === path.resolve(scriptPath)) { `ws://user@127.0.0.1:${options.port}/devtools/page/test`, `ws://127.0.0.1:${options.port}/unexpected/test`, `ws://127.0.0.1:${options.port}/devtools/page/test?query=1`, + `ws://localhost:${options.port}/devtools/page/test`, + `ws://[::1]:${options.port}/devtools/page/test`, ]; for (const value of invalid) { let rejected = false; diff --git a/windows/scripts/media-server.mjs b/windows/scripts/media-server.mjs new file mode 100644 index 00000000..d8d254b9 --- /dev/null +++ b/windows/scripts/media-server.mjs @@ -0,0 +1,211 @@ +import http from "node:http"; +import fs from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import path from "node:path"; +import { createHash, randomUUID } from "node:crypto"; + +export const MAX_VIDEO_BYTES = 100 * 1024 * 1024; +export const TRUSTED_MEDIA_SCHEME = "codex-dream-skin"; +const VIDEO_EXTENSION = ".mp4"; +const VIDEO_MIME = "video/mp4"; +const TRUSTED_ORIGINS = new Set(["app://-", "app://", "null"]); + +export function isMp4Container(bytes, totalSize = bytes?.byteLength ?? 0) { + if (!(bytes instanceof Uint8Array) || bytes.length < 16) return false; + const view = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const firstBoxSize = view.readUInt32BE(0); + return firstBoxSize >= 16 + && firstBoxSize <= totalSize + && view.subarray(4, 8).toString("ascii") === "ftyp"; +} + +function parseRange(value, size) { + if (typeof value !== "string" || !value.startsWith("bytes=")) return null; + const match = /^bytes=(\d*)-(\d*)$/.exec(value.trim()); + if (!match || (!match[1] && !match[2])) return null; + let start; + let end; + if (match[1]) { + start = Number(match[1]); + end = match[2] ? Number(match[2]) : size - 1; + } else { + const suffix = Number(match[2]); + if (!Number.isSafeInteger(suffix) || suffix < 1) return null; + start = Math.max(0, size - suffix); + end = size - 1; + } + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) return null; + if (start >= size) return { unsatisfiable: true }; + return { start, end: Math.min(end, size - 1) }; +} + +async function validateVideoFile(filePath) { + const resolved = path.resolve(filePath); + if (path.extname(resolved).toLowerCase() !== VIDEO_EXTENSION) { + throw new Error("Video backgrounds must use an MP4 file."); + } + const lstat = await fs.lstat(resolved); + if (lstat.isSymbolicLink()) throw new Error("Video background must not be a symbolic link."); + const realPath = await fs.realpath(resolved); + const stat = await fs.stat(realPath); + if (!stat.isFile() || stat.size < 1 || stat.size > MAX_VIDEO_BYTES) { + throw new Error(`Video background must be a non-empty MP4 no larger than ${MAX_VIDEO_BYTES} bytes.`); + } + const bytes = await fs.readFile(realPath); + if (!isMp4Container(bytes)) { + throw new Error("Video background is not a valid MP4 container."); + } + const hash = createHash("sha256"); + hash.update(bytes); + return { filePath: realPath, size: stat.size, identity: hash.digest("hex") }; +} + +export async function createMediaServer(filePath) { + const validated = await validateVideoFile(filePath); + const token = randomUUID().replaceAll("-", ""); + const route = `/media/${token}`; + const protocolUrl = `${TRUSTED_MEDIA_SCHEME}://${route.slice(1)}`; + const sockets = new Set(); + let closed = false; + + const server = http.createServer(async (request, response) => { + const origin = request.headers.origin; + const trustedOrigin = origin && TRUSTED_ORIGINS.has(origin) ? origin : null; + const corsHeaders = { + "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", + "Access-Control-Allow-Headers": "Range, X-Codex-Dream-Skin-Token", + "Access-Control-Allow-Private-Network": "true", + "Access-Control-Expose-Headers": "Accept-Ranges, Content-Length, Content-Range, Content-Type", + "Vary": "Origin", + }; + if (trustedOrigin) corsHeaders["Access-Control-Allow-Origin"] = trustedOrigin; + if (request.url === route && request.method === "OPTIONS") { + if (origin && !trustedOrigin) { + response.writeHead(403, { "Cache-Control": "no-store" }); + response.end(); + return; + } + response.writeHead(204, corsHeaders); + response.end(); + return; + } + if (closed || !["GET", "HEAD"].includes(request.method) || request.url !== route) { + response.writeHead(closed ? 503 : 404, { "Cache-Control": "no-store" }); + response.end(); + return; + } + if ((origin && !trustedOrigin) || request.headers["x-codex-dream-skin-token"] !== token) { + response.writeHead(403, { "Cache-Control": "no-store" }); + response.end(); + return; + } + + try { + const lstat = await fs.lstat(validated.filePath); + if (lstat.isSymbolicLink()) throw new Error("Video path became a symbolic link"); + const stat = await fs.stat(validated.filePath); + if (!stat.isFile() || stat.size !== validated.size || stat.size > MAX_VIDEO_BYTES) { + throw new Error("Video file changed or exceeded the safety limit"); + } + + const currentHash = createHash("sha256"); + currentHash.update(await fs.readFile(validated.filePath)); + if (currentHash.digest("hex") !== validated.identity) { + throw new Error("Video file content changed after staging"); + } + + const rangeHeader = request.headers.range; + const range = parseRange(rangeHeader, stat.size); + const headers = { + ...corsHeaders, + "Content-Type": VIDEO_MIME, + "Accept-Ranges": "bytes", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }; + if (rangeHeader !== undefined && !range) { + headers["Content-Range"] = `bytes */${stat.size}`; + response.writeHead(416, headers); + response.end(); + return; + } + if (!range) { + headers["Content-Length"] = stat.size; + response.writeHead(200, headers); + if (request.method === "HEAD") { response.end(); return; } + createReadStream(validated.filePath).on("error", () => response.destroy()).pipe(response); + return; + } + + headers["Content-Range"] = `bytes ${range.start}-${range.end}/${stat.size}`; + headers["Content-Length"] = range.end - range.start + 1; + response.writeHead(206, headers); + if (request.method === "HEAD") { response.end(); return; } + createReadStream(validated.filePath, { start: range.start, end: range.end }) + .on("error", () => response.destroy()) + .pipe(response); + } catch { + response.writeHead(404, { "Cache-Control": "no-store" }); + response.end(); + } + }); + + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + await new Promise((resolve) => server.close(resolve)); + throw new Error("Local media server did not expose a TCP port."); + } + + return { + filePath: validated.filePath, + size: validated.size, + identity: validated.identity, + token, + route, + protocolUrl, + url: `http://127.0.0.1:${address.port}${route}`, + async close() { + if (closed) return; + closed = true; + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +export class MediaServerController { + #active = null; + + async stage(filePath) { + if (!filePath) return null; + const validated = await validateVideoFile(filePath); + if (this.#active?.filePath === validated.filePath && + this.#active.size === validated.size && this.#active.identity === validated.identity) return this.#active; + return createMediaServer(validated.filePath); + } + + async commit(next) { + if (next === this.#active) return; + const previous = this.#active; + this.#active = next; + await previous?.close(); + } + + async abort(staged) { + if (staged && staged !== this.#active) await staged.close(); + } + + async close() { + const active = this.#active; + this.#active = null; + await active?.close(); + } +} diff --git a/windows/scripts/start-dream-skin.ps1 b/windows/scripts/start-dream-skin.ps1 index 6658ca82..fe5feb60 100644 --- a/windows/scripts/start-dream-skin.ps1 +++ b/windows/scripts/start-dream-skin.ps1 @@ -285,7 +285,7 @@ try { $verify = Invoke-DreamSkinNative -FilePath $node.Path -ArgumentList @( $Injector, '--verify', '--port', "$Port", '--browser-id', $cdpIdentity.BrowserId, '--theme-dir', $themePaths.Active, - '--timeout-ms', '30000') + '--timeout-ms', '30000', '--allow-hidden-applied') Write-DreamSkinUtf8FileAtomically -Path $VerifyPath -Content (($verify.Output -join "`r`n") + "`r`n") if ($verify.ExitCode -eq 0) { break } # A verify can fail while the theme is demonstrably on screen: the diff --git a/windows/scripts/theme-windows.ps1 b/windows/scripts/theme-windows.ps1 index 17434594..44621c8d 100644 --- a/windows/scripts/theme-windows.ps1 +++ b/windows/scripts/theme-windows.ps1 @@ -3,6 +3,7 @@ } $script:DreamSkinMaxImageBytes = 10 * 1024 * 1024 +$script:DreamSkinMaxVideoBytes = 100 * 1024 * 1024 $script:DreamSkinMaxThemeArchiveBytes = 32 * 1024 * 1024 $script:DreamSkinMaxThemeArchiveExpandedBytes = 64 * 1024 * 1024 $script:DreamSkinMaxThemeArchiveEntries = 32 @@ -280,6 +281,47 @@ function Assert-DreamSkinImageFile { } } +function Assert-DreamSkinVideoFile { + param([Parameter(Mandatory = $true)][string]$Path) + $fullPath = [System.IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { + throw "Video does not exist: $fullPath" + } + if ([System.IO.Path]::GetExtension($fullPath).ToLowerInvariant() -ne '.mp4') { + throw 'Unsupported video format. Only MP4 is supported.' + } + $length = (Get-Item -LiteralPath $fullPath -Force).Length + if ($length -lt 1) { throw 'Theme video cannot be empty.' } + if ($length -gt $script:DreamSkinMaxVideoBytes) { + throw 'Theme video exceeds the 100 MB limit.' + } + # An .mp4 extension is not evidence of an MP4. Read the first box header and + # require a real `ftyp` container so a renamed file is rejected before the + # renderer is asked to decode it. + $stream = [System.IO.File]::Open( + $fullPath, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read + ) + try { + $header = New-Object byte[] 16 + $read = $stream.Read($header, 0, $header.Length) + } finally { + $stream.Dispose() + } + $firstBoxSize = if ($read -ge 4) { + ([uint64]$header[0] * 16777216) + + ([uint64]$header[1] * 65536) + + ([uint64]$header[2] * 256) + + [uint64]$header[3] + } else { 0 } + $boxType = if ($read -ge 8) { [System.Text.Encoding]::ASCII.GetString($header, 4, 4) } else { '' } + if ($read -lt 16 -or $boxType -cne 'ftyp' -or $firstBoxSize -lt 16 -or $firstBoxSize -gt $length) { + throw 'Theme video is not a valid MP4 container.' + } +} + function Assert-DreamSkinSafeCssFile { param([Parameter(Mandatory = $true)][string]$Path) $fullPath = [System.IO.Path]::GetFullPath($Path) @@ -374,10 +416,26 @@ function Read-DreamSkinTheme { throw 'Theme image must remain inside its theme directory and exist.' } Assert-DreamSkinImageFile -Path $imagePath -SkipImageMetadata:$SkipImageMetadata + $videoPath = $null + if ($null -ne $theme.video -and "$($theme.video)") { + $video = "$($theme.video)" + if ([System.IO.Path]::IsPathRooted($video) -or + [System.IO.Path]::GetFileName($video) -ne $video -or + [System.IO.Path]::GetExtension($video).ToLowerInvariant() -ne '.mp4') { + throw 'Theme video path must be a relative MP4 filename.' + } + $videoPath = [System.IO.Path]::GetFullPath((Join-Path $directory $video)) + if (-not (Test-DreamSkinThemePathWithin -Path $videoPath -Root $directory) -or + -not (Test-Path -LiteralPath $videoPath -PathType Leaf)) { + throw 'Theme video must remain inside its theme directory and exist.' + } + Assert-DreamSkinVideoFile -Path $videoPath + } return [pscustomobject]@{ Directory = $directory ThemePath = $themePath ImagePath = $imagePath + VideoPath = $videoPath Theme = $theme } } @@ -491,6 +549,8 @@ function Initialize-DreamSkinThemeStore { elseif ($activeId -ceq 'preset-gothic-void-crusade' -and (Test-Path -LiteralPath $gothicSourceTheme -PathType Leaf)) { $refreshSource = $gothicSource } if ($null -ne $refreshSource) { + $activeVideoPath = $null + try { $activeVideoPath = (Read-DreamSkinTheme -ThemeDirectory $paths.Active).VideoPath } catch {} $sourcePack = Read-DreamSkinTheme -ThemeDirectory $refreshSource $sourceJson = Read-DreamSkinUtf8File -Path $sourcePack.ThemePath $activeJson = Read-DreamSkinUtf8File -Path $activeTheme @@ -502,6 +562,17 @@ function Initialize-DreamSkinThemeStore { Assert-DreamSkinNoReparseComponents -Path $refreshedImage Assert-DreamSkinImageFile -Path $refreshedImage Copy-Item -LiteralPath $sourcePack.ThemePath -Destination $activeTheme -Force + if ($activeVideoPath) { + # Preset metadata refresh must not discard a user-imported video. + # The active video is already inside the managed theme directory; + # reattach its relative filename to the refreshed theme manifest. + Assert-DreamSkinNoReparseComponents -Path $activeVideoPath + $refreshed = Read-DreamSkinTheme -ThemeDirectory $paths.Active + $refreshedTheme = $refreshed.Theme | ConvertTo-Json -Depth 8 | ConvertFrom-Json + $refreshedTheme | Add-Member -NotePropertyName video ` + -NotePropertyValue ([System.IO.Path]::GetFileName($activeVideoPath)) -Force + Write-DreamSkinTheme -ThemeDirectory $paths.Active -Theme $refreshedTheme + } } } } @@ -518,6 +589,7 @@ function New-DreamSkinThemeImageName { function Set-DreamSkinActiveTheme { param( [Parameter(Mandatory = $true)][string]$ImagePath, + [string]$VideoPath, [AllowNull()][object]$Theme, [string]$Name, [AllowNull()][string]$SafeCssPath, @@ -529,9 +601,16 @@ function Set-DreamSkinActiveTheme { Ensure-DreamSkinManagedDirectory -Path $paths.Images -Root $paths.Root $source = [System.IO.Path]::GetFullPath($ImagePath) Assert-DreamSkinImageFile -Path $source + $videoSource = if ($VideoPath) { [System.IO.Path]::GetFullPath($VideoPath) } else { $null } + if ($videoSource) { Assert-DreamSkinVideoFile -Path $videoSource } $extension = [System.IO.Path]::GetExtension($source).ToLowerInvariant() $oldImage = $null - try { $oldImage = (Read-DreamSkinTheme -ThemeDirectory $paths.Active).ImagePath } catch {} + $oldVideo = $null + try { + $oldTheme = Read-DreamSkinTheme -ThemeDirectory $paths.Active + $oldImage = $oldTheme.ImagePath + $oldVideo = $oldTheme.VideoPath + } catch {} if ($null -eq $Theme) { $Theme = [pscustomobject]@{ id = 'custom' @@ -544,6 +623,9 @@ function Set-DreamSkinActiveTheme { $imageName = New-DreamSkinThemeImageName -Extension $extension $target = Join-Path $paths.Active $imageName $temporary = Join-Path $paths.Active ('.dream-tmp-' + [guid]::NewGuid().ToString('N') + $extension) + $videoName = 'background.mp4' + $videoTarget = Join-Path $paths.Active $videoName + $videoTemporary = Join-Path $paths.Active ('.dream-video-tmp-' + [guid]::NewGuid().ToString('N') + '.mp4') $temporaryCss = $null try { if ($SafeCssPath) { @@ -562,6 +644,17 @@ function Set-DreamSkinActiveTheme { Move-Item -LiteralPath $temporary -Destination $target -Force Assert-DreamSkinNoReparseComponents -Path $target Assert-DreamSkinImageFile -Path $target + if ($videoSource) { + Assert-DreamSkinNoReparseComponents -Path $videoTarget + Assert-DreamSkinNoReparseComponents -Path $videoTemporary + Copy-Item -LiteralPath $videoSource -Destination $videoTemporary -Force + Assert-DreamSkinVideoFile -Path $videoTemporary + Move-Item -LiteralPath $videoTemporary -Destination $videoTarget -Force + Assert-DreamSkinVideoFile -Path $videoTarget + $Theme | Add-Member -NotePropertyName video -NotePropertyValue $videoName -Force + } elseif ($Theme.PSObject.Properties['video']) { + $Theme.PSObject.Properties.Remove('video') + } $Theme | Add-Member -NotePropertyName image -NotePropertyValue $imageName -Force if ($Name) { $Theme | Add-Member -NotePropertyName name -NotePropertyValue $Name -Force } if (-not $Theme.id) { $Theme | Add-Member -NotePropertyName id -NotePropertyValue 'custom' -Force } @@ -585,6 +678,7 @@ function Set-DreamSkinActiveTheme { Write-DreamSkinTheme -ThemeDirectory $paths.Active -Theme $Theme } finally { Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $videoTemporary -Force -ErrorAction SilentlyContinue if ($temporaryCss) { Remove-Item -LiteralPath $temporaryCss -Force -ErrorAction SilentlyContinue } } $sameImage = $oldImage -and ([System.IO.Path]::GetFullPath($oldImage) -ieq [System.IO.Path]::GetFullPath($target)) @@ -592,11 +686,21 @@ function Set-DreamSkinActiveTheme { (Test-DreamSkinThemePathWithin -Path $oldImage -Root $paths.Active)) { Remove-Item -LiteralPath $oldImage -Force -ErrorAction SilentlyContinue } + if ($oldVideo -and (-not $videoSource -or [System.IO.Path]::GetFullPath($oldVideo) -ine [System.IO.Path]::GetFullPath($videoTarget)) -and + (Test-DreamSkinThemePathWithin -Path $oldVideo -Root $paths.Active)) { + Remove-Item -LiteralPath $oldVideo -Force -ErrorAction SilentlyContinue + } $imageArchive = Join-Path $paths.Images $imageName Assert-DreamSkinNoReparseComponents -Path $imageArchive Copy-Item -LiteralPath $target -Destination $imageArchive -Force Assert-DreamSkinNoReparseComponents -Path $imageArchive Assert-DreamSkinImageFile -Path $imageArchive + if ($videoSource) { + $videoArchive = Join-Path $paths.Images $videoName + Assert-DreamSkinNoReparseComponents -Path $videoArchive + Copy-Item -LiteralPath $videoTarget -Destination $videoArchive -Force + Assert-DreamSkinVideoFile -Path $videoArchive + } return Read-DreamSkinTheme -ThemeDirectory $paths.Active } @@ -627,6 +731,13 @@ function Save-DreamSkinCurrentTheme { $theme.id = $id $theme.name = $trimmed $theme.image = $imageName + if ($active.VideoPath) { + $destinationVideo = Join-Path $destination 'background.mp4' + Assert-DreamSkinNoReparseComponents -Path $destinationVideo + Copy-Item -LiteralPath $active.VideoPath -Destination $destinationVideo -Force + Assert-DreamSkinVideoFile -Path $destinationVideo + $theme.video = 'background.mp4' + } Write-DreamSkinTheme -ThemeDirectory $destination -Theme $theme $activeCss = Join-Path $paths.Active 'theme.css' if (Test-Path -LiteralPath $activeCss -PathType Leaf) { @@ -739,6 +850,14 @@ function Get-DreamSkinThemeRuntimeContentFingerprint { $hasher.Dispose() } $imageHash = (Get-FileHash -LiteralPath $loaded.ImagePath -Algorithm SHA256).Hash.ToLowerInvariant() + $videoIdentity = 'absent' + if ($loaded.VideoPath) { + Assert-DreamSkinNoReparseComponents -Path $loaded.VideoPath + if ((Get-Item -LiteralPath $loaded.VideoPath -Force).Length -gt $script:DreamSkinMaxVideoBytes) { + throw 'Theme video exceeds the 100 MB limit.' + } + $videoIdentity = (Get-FileHash -LiteralPath $loaded.VideoPath -Algorithm SHA256).Hash.ToLowerInvariant() + } $cssPath = Join-Path $loaded.Directory 'theme.css' $cssIdentity = 'absent' if (Test-Path -LiteralPath $cssPath -PathType Leaf) { @@ -748,7 +867,7 @@ function Get-DreamSkinThemeRuntimeContentFingerprint { } $cssIdentity = (Get-FileHash -LiteralPath $cssPath -Algorithm SHA256).Hash.ToLowerInvariant() } - $identity = "dreamskin-runtime-theme/1`0theme.json`0$themeHash`0image`0$imageHash`0theme.css`0$cssIdentity" + $identity = "dreamskin-runtime-theme/1`0theme.json`0$themeHash`0image`0$imageHash`0theme.css`0$cssIdentity`0video`0$videoIdentity" $identityBytes = [System.Text.Encoding]::UTF8.GetBytes($identity) $identityHasher = [System.Security.Cryptography.SHA256]::Create() try { @@ -1133,9 +1252,20 @@ function Import-DreamSkinThemeZip { Assert-DreamSkinNoReparseComponents -Path $stagedImage Copy-Item -LiteralPath $source.ImagePath -Destination $stagedImage -Force Assert-DreamSkinImageFile -Path $stagedImage + $videoName = $null + if ($source.VideoPath) { + $videoName = 'background.mp4' + $stagedVideo = Join-Path $publishStage $videoName + Assert-DreamSkinNoReparseComponents -Path $stagedVideo + Copy-Item -LiteralPath $source.VideoPath -Destination $stagedVideo -Force + Assert-DreamSkinNoReparseComponents -Path $stagedVideo + Assert-DreamSkinVideoFile -Path $stagedVideo + } $theme = $source.Theme | ConvertTo-Json -Depth 8 | ConvertFrom-Json $theme.id = $id $theme.image = $imageName + if ($videoName) { $theme.video = $videoName } + elseif ($theme.PSObject.Properties['video']) { $theme.PSObject.Properties.Remove('video') } Write-DreamSkinTheme -ThemeDirectory $publishStage -Theme $theme foreach ($auxiliaryName in @('theme.css', 'LICENSE.txt')) { $auxiliarySource = Join-Path $sourceRoot $auxiliaryName @@ -1225,7 +1355,7 @@ function Use-DreamSkinSavedTheme { $safeCssPath = Join-Path $directory 'theme.css' if (-not (Test-Path -LiteralPath $safeCssPath -PathType Leaf)) { $safeCssPath = $null } if ($safeCssPath) { Assert-DreamSkinSafeCssFile -Path $safeCssPath } - return Set-DreamSkinActiveTheme -ImagePath $saved.ImagePath -Theme $theme ` + return Set-DreamSkinActiveTheme -ImagePath $saved.ImagePath -VideoPath $saved.VideoPath -Theme $theme ` -SafeCssPath $safeCssPath -StateRoot $StateRoot } diff --git a/windows/scripts/tray-dream-skin.ps1 b/windows/scripts/tray-dream-skin.ps1 index 7f2f3c94..dafffc80 100644 --- a/windows/scripts/tray-dream-skin.ps1 +++ b/windows/scripts/tray-dream-skin.ps1 @@ -49,6 +49,15 @@ try { function Show-DreamSkinTrayError { param([string]$Message) + if ($Message -like '*Another Codex Dream Skin install, start, restore, or verify operation is already running.*') { + $notify.ShowBalloonTip( + 2400, + 'Codex Dream Skin', + '正在应用或校验皮肤,请等待当前操作完成后再切换背景。', + [System.Windows.Forms.ToolTipIcon]::Warning + ) + return + } [void][System.Windows.Forms.MessageBox]::Show( $Message, 'Codex Dream Skin', @@ -57,6 +66,21 @@ try { ) } + function Test-DreamSkinWatcherActive { + $statePath = Join-Path $StateRoot 'state.json' + if (-not (Test-Path -LiteralPath $statePath -PathType Leaf)) { return $false } + try { + $state = Read-DreamSkinState -Path $statePath + if ($null -eq $state -or -not $state.injectorPid) { return $false } + if (-not (Test-DreamSkinRecordedInjector -State $state)) { return $false } + if (-not $state.port -or -not $state.codexExe) { return $false } + $codex = [pscustomobject]@{ Executable = "$($state.codexExe)" } + return Test-DreamSkinCodexPortOwner -Port ([int]$state.port) -Codex $codex + } catch { + return $false + } + } + function Start-DreamSkinPowerShell { param([Parameter(Mandatory = $true)][string]$Script, [string[]]$Arguments = @()) $scriptToken = ConvertTo-DreamSkinProcessArgument -Value $Script @@ -65,6 +89,28 @@ try { Start-Process -FilePath $powershell -ArgumentList $argumentLine -WindowStyle Hidden | Out-Null } + function Ensure-DreamSkinWatcher { + if (Test-DreamSkinWatcherActive) { return $true } + Start-DreamSkinPowerShell -Script $startScript -Arguments @('-Port', "$Port", '-PromptRestart') + return $false + } + + function Confirm-DreamSkinThemeApplied { + param([int]$TimeoutMs = 12000) + $session = Get-DreamSkinLiveSessionContext -StateRoot $StateRoot + if ($null -eq $session) { return $false } + $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMs) + do { + $probe = Invoke-DreamSkinNative -FilePath $session.NodePath -ArgumentList @( + $session.Injector, '--verify', '--port', "$($session.Port)", + '--browser-id', $session.BrowserId, '--theme-dir', $session.Paths.Active, + '--timeout-ms', '2500') -DiscardStderr + if ($probe.ExitCode -eq 0) { return $true } + Start-Sleep -Milliseconds 400 + } while ([DateTime]::UtcNow -lt $deadline) + return $false + } + function Add-DreamSkinTrayItem { param( [Parameter(Mandatory = $true)] @@ -114,19 +160,20 @@ try { function Rebuild-DreamSkinTrayMenu { $menu.Items.Clear() + $operationActive = Test-DreamSkinOperationActive $paused = Test-DreamSkinPaused -StateRoot $StateRoot $state = $null try { $state = Read-DreamSkinState -Path $paths.State } catch {} $active = $null try { $active = Read-DreamSkinTheme -ThemeDirectory $paths.Active -SkipImageMetadata } catch {} - $status = if ($paused) { '状态:已暂停' } elseif ($state) { '状态:运行中' } else { '状态:未运行' } + $status = if ($operationActive) { '状态:正在应用或校验' } elseif ($paused) { '状态:已暂停' } elseif ($state) { '状态:运行中' } else { '状态:未运行' } if ($null -ne $active -and $null -ne $active.Theme -and $active.Theme.name) { $status += " · $($active.Theme.name)" } $null = Add-DreamSkinTrayItem -Items $menu.Items -Text $status -Action $null -Enabled $false [void]$menu.Items.Add([System.Windows.Forms.ToolStripSeparator]::new()) - $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '应用或重新应用' -Action { + $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '应用或重新应用' -Enabled:(-not $operationActive) -Action { $session = Get-DreamSkinLiveSessionContext -StateRoot $StateRoot $begin = $null if ($null -ne $session) { @@ -143,7 +190,7 @@ try { # Match macOS menubar: pause = mark + live remove; resume lets the serialized # start path clear pause only after its safety checks and any restart consent. if ($paused) { - $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '继续显示皮肤' -Action { + $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '继续显示皮肤' -Enabled:(-not $operationActive) -Action { # Keep pause set while the start path validates and prompts; show in-window # loading when the existing CDP session is still reachable. $session = Get-DreamSkinLiveSessionContext -StateRoot $StateRoot @@ -164,7 +211,7 @@ try { ) } } else { - $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '暂停皮肤' -Action { + $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '暂停皮肤' -Enabled:(-not $operationActive) -Action { # Match macOS pause: marker + live remove with in-window loading / result. $removal = Invoke-DreamSkinTrayThemeOperation -Action { Set-DreamSkinPaused -Paused $true -StateRoot $StateRoot | Out-Null @@ -181,7 +228,7 @@ try { } } } - $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '更换背景图' -Action { + $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '更换背景图' -Enabled:(-not $operationActive) -Action { $dialog = [System.Windows.Forms.OpenFileDialog]::new() $dialog.Title = '选择 Codex Dream Skin 背景图' $dialog.Filter = 'Image files|*.png;*.jpg;*.jpeg;*.webp|All files|*.*' @@ -193,13 +240,36 @@ try { -StateRoot $StateRoot Set-DreamSkinPaused -Paused $false -StateRoot $StateRoot | Out-Null } - $notify.ShowBalloonTip(1800, 'Codex Dream Skin', '背景图已更新。', [System.Windows.Forms.ToolTipIcon]::Info) + $watcherReady = Ensure-DreamSkinWatcher + $message = if ($watcherReady) { '背景图已更新。' } else { '背景图已更新,正在重新启动 Dream Skin…' } + $notify.ShowBalloonTip(1800, 'Codex Dream Skin', $message, [System.Windows.Forms.ToolTipIcon]::Info) + } + } finally { + $dialog.Dispose() + } + } + $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '一键更换视频背景' -Enabled:(-not $operationActive) -Action { + $dialog = [System.Windows.Forms.OpenFileDialog]::new() + $dialog.Title = '选择 Codex Dream Skin 视频背景' + $dialog.Filter = 'MP4 video|*.mp4' + $dialog.Multiselect = $false + try { + if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { + $null = Invoke-DreamSkinTrayThemeOperation -Action { + $active = Read-DreamSkinTheme -ThemeDirectory $paths.Active + $null = Set-DreamSkinActiveTheme -ImagePath $active.ImagePath -VideoPath $dialog.FileName ` + -Theme $active.Theme -StateRoot $StateRoot + Set-DreamSkinPaused -Paused $false -StateRoot $StateRoot | Out-Null + } + $watcherReady = Ensure-DreamSkinWatcher + $message = if ($watcherReady) { '视频背景已更新。' } else { '视频背景已更新,正在重新启动 Dream Skin…' } + $notify.ShowBalloonTip(1800, 'Codex Dream Skin', $message, [System.Windows.Forms.ToolTipIcon]::Info) } } finally { $dialog.Dispose() } } - $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '导入主题 ZIP…' -Action { + $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '导入主题 ZIP…' -Enabled:(-not $operationActive) -Action { $dialog = [System.Windows.Forms.OpenFileDialog]::new() $dialog.Title = '选择 Codex Dream Skin 主题 ZIP' $dialog.Filter = 'Dream Skin theme ZIP|*.zip' @@ -224,7 +294,7 @@ try { $dialog.Dispose() } } - $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '保存当前主题' -Action { + $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '保存当前主题' -Enabled:(-not $operationActive) -Action { $name = [Microsoft.VisualBasic.Interaction]::InputBox('输入主题名称:', '保存 Codex Dream Skin 主题', '') if ($name.Trim()) { $saved = Invoke-DreamSkinTrayThemeOperation -Action { @@ -249,9 +319,19 @@ try { $null = Use-DreamSkinSavedTheme -ThemeDirectory $savedPath -StateRoot $StateRoot Set-DreamSkinPaused -Paused $false -StateRoot $StateRoot | Out-Null } - $notify.ShowBalloonTip(1800, 'Codex Dream Skin', "已应用:$savedName", [System.Windows.Forms.ToolTipIcon]::Info) + $watcherReady = Ensure-DreamSkinWatcher + if ($watcherReady -and (Confirm-DreamSkinThemeApplied)) { + $notify.ShowBalloonTip(1800, 'Codex Dream Skin', "已应用:$savedName", [System.Windows.Forms.ToolTipIcon]::Info) + } elseif ($watcherReady) { + $notify.ShowBalloonTip(3200, 'Codex Dream Skin', + "主题文件已更新,但当前窗口尚未确认应用:$savedName。", [System.Windows.Forms.ToolTipIcon]::Warning) + } else { + $notify.ShowBalloonTip(3200, 'Codex Dream Skin', + "主题文件已更新,但当前窗口尚未应用;正在重新启动 Dream Skin:$savedName。", + [System.Windows.Forms.ToolTipIcon]::Warning) + } }.GetNewClosure() - $null = Add-DreamSkinTrayItem -Items $savedMenu.DropDownItems -Text $savedName -Action $savedAction + $null = Add-DreamSkinTrayItem -Items $savedMenu.DropDownItems -Text $savedName -Enabled:(-not $operationActive) -Action $savedAction } } [void]$menu.Items.Add($savedMenu) diff --git a/windows/tests/injector-bootstrap.test.mjs b/windows/tests/injector-bootstrap.test.mjs index c6ea3657..0935e7fb 100644 --- a/windows/tests/injector-bootstrap.test.mjs +++ b/windows/tests/injector-bootstrap.test.mjs @@ -1,14 +1,29 @@ import assert from "node:assert/strict"; import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import vm from "node:vm"; import { fileURLToPath } from "node:url"; -import { earlyPayloadFor } from "../scripts/injector.mjs"; +import { + earlyPayloadFor, + loadTheme, +} from "../scripts/injector.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); const injectorPath = path.resolve(here, "../scripts/injector.mjs"); const source = await fs.readFile(injectorPath, "utf8"); +function mp4Fixture(marker) { + const fileTypeBox = Buffer.alloc(24); + fileTypeBox.writeUInt32BE(fileTypeBox.length, 0); + fileTypeBox.write("ftyp", 4, "ascii"); + fileTypeBox.write("isom", 8, "ascii"); + fileTypeBox.writeUInt32BE(512, 12); + fileTypeBox.write("isom", 16, "ascii"); + fileTypeBox.write("mp41", 20, "ascii"); + return Buffer.concat([fileTypeBox, Buffer.from(marker, "ascii")]); +} + function createFixture() { const domReady = []; const timers = new Map(); @@ -103,5 +118,66 @@ assert.match(source, /if \(!fallbackTargets\.get\(id\)\) return;/, "Fallback listeners must stay inert after a successful early registration."); assert.match(source, /Page\.removeScriptToEvaluateOnNewDocument/, "Watcher shutdown and theme refresh must unregister persistent Page scripts."); +assert.match(source, /DOM\.setFileInputFiles/, + "Windows video themes must use CDP file injection instead of exposing a local file URL."); +assert.match(source, /videoTransport[\s\S]*mode: "blob"/, + "Windows video payloads must prefer Blob transport when no fallback URL is supplied."); +assert.match(source, /Page\.setBypassCSP[\s\S]*enabled: true/, + "Windows video fallback fetches must enable CSP bypass only through the verified injector."); +assert.match(source, /Page\.enable[\s\S]*Page\.setBypassCSP/, + "Windows video fallback fetches must enable CSP bypass before replaying media payloads."); +const mediaPolicyStart = source.indexOf("async function enableMediaFetchForSession"); +const mediaPolicyEnd = source.indexOf("async function applyToSession", mediaPolicyStart); +assert.doesNotMatch(source.slice(mediaPolicyStart, mediaPolicyEnd), /Page\.reload/, + "Windows video theme imports must not force a full page reload and visible flash."); +assert.match(source, /mode: "blob", fallbackUrl: stagedMedia\.url/, + "Windows watch mode must prefer CDP file injection with a controlled fallback transport."); +assert.match(source, /videoHandle\.createReadStream[\s\S]*fingerprintHash\.update\(chunk\)/, + "Windows theme fingerprints must stream video content instead of retaining a full MP4 buffer."); +assert.doesNotMatch(source, /videoBytes\s*=\s*videoPath\s*\?\s*await fs\.readFile/, + "Windows watcher audits must not allocate the entire video solely to fingerprint it."); +assert.match(source, /rejectedSourceStamp[\s\S]*candidateTheme\.sourceStamp === rejectedSourceStamp/, + "A renderer-rejected video revision must not be reapplied until its source files change."); +assert.match(source, /verifyCodexPortOwner\(port\)[\s\S]*DreamSkinNativeWindowProbe[\s\S]*EnumChildWindows/, + "Electron window fallback must bind a visible native window to the verified official Codex executable."); +assert.match(source, /source: "verified-codex-process-window"/, + "Native-window fallback evidence must identify its process-window binding source."); +assert.match(source, /updateError\.deferred[\s\S]*live theme update deferred for a hidden renderer/, + "A hidden renderer must defer a live switch instead of terminating the watcher during rollback."); +assert.match(source, /rendererVerificationAccepted\(lastResult, allowHiddenApplied\)/, + "Startup verification must be able to accept only the explicit hidden-renderer defer state."); +const updateCommit = source.indexOf("Keep the previous media server alive until every renderer verifies the update"); +const updateApply = source.indexOf("await applyToSession(session, loadedPayload.payload, loadedPayload);", updateCommit); +const commitIndex = source.indexOf("await mediaServers.commit(paused ? null : stagedMedia);", updateApply); +assert.ok(updateCommit >= 0 && updateApply > updateCommit && commitIndex > updateApply, + "Windows theme updates must keep the previous media server until renderer verification succeeds, then publish staged media."); +assert.match(source, /async function verifyCodexPortOwner\(port\)/, + "Windows identity rebind must verify the loopback listener belongs to the recorded Codex executable."); +assert.match(source, /rebound verified CDP browser identity/, + "Windows watch mode must report a successful verified identity rebind."); +assert.match(source, /listAppTargets\(options\.port, identityState\.browserId\)/, + "Windows watch mode must bind target discovery to the current browser identity."); +assert.match(source, /identityState\.generation !== identityGeneration/, + "Windows target setup must abort when the browser identity rotates mid-connection."); +assert.match(source, /candidate\.open\(\)[\s\S]*confirmedBrowserId !== nextBrowserId/, + "Identity rebinding must reopen and revalidate the candidate browser identity."); +assert.match(source, /await cleanupIdentitySessions\(\)/, + "Identity rebinding must discard sessions anchored to the old browser identity."); +assert.doesNotMatch(source, /LOOPBACK_HOSTS = new Set\(\["127\.0\.0\.1", "localhost"/, + "Windows CDP validation must not widen beyond the loopback address required by the startup contract."); -console.log("PASS: Windows early injection is L0-ready, generation-safe, ordered before probing, and fallback-scoped."); +const fingerprintRoot = await fs.mkdtemp(path.join(os.tmpdir(), "dream-skin-fingerprint-")); +try { + await fs.copyFile(path.resolve(here, "../assets/dream-reference.jpg"), path.join(fingerprintRoot, "background.jpg")); + await fs.writeFile(path.join(fingerprintRoot, "background.mp4"), mp4Fixture("AAAA")); + await fs.writeFile(path.join(fingerprintRoot, "theme.json"), JSON.stringify({ + id: "fingerprint-fixture", name: "Fingerprint fixture", image: "background.jpg", video: "background.mp4", + })); + const first = await loadTheme(fingerprintRoot); + await fs.writeFile(path.join(fingerprintRoot, "background.mp4"), mp4Fixture("BBBB")); + const second = await loadTheme(fingerprintRoot); + assert.notEqual(first.fingerprint, second.fingerprint, + "Replacing an MP4 under the same filename must change the theme fingerprint."); +} finally { + await fs.rm(fingerprintRoot, { recursive: true, force: true }); +} diff --git a/windows/tests/injector-window-readiness.test.mjs b/windows/tests/injector-window-readiness.test.mjs index 6940e229..dd71f632 100644 --- a/windows/tests/injector-window-readiness.test.mjs +++ b/windows/tests/injector-window-readiness.test.mjs @@ -4,7 +4,11 @@ import path from "node:path"; import test from "node:test"; import vm from "node:vm"; import { fileURLToPath } from "node:url"; -import { verifySession } from "../scripts/injector.mjs"; +import { + isDeferredRendererVerification, + rendererVerificationAccepted, + verifySession, +} from "../scripts/injector.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); const startPath = path.resolve(here, "../scripts/start-dream-skin.ps1"); @@ -56,6 +60,8 @@ function makeDomFixture({ hidden = false, viewportWidth = 1280, viewportHeight = 800, + videoMode = null, + videoReady = null, } = {}) { const styleNode = {}; const documentElement = { @@ -86,6 +92,10 @@ function makeDomFixture({ version: "1.5.6", themeId: "fixture-theme", revision: "fixture-revision", + videoMode, + videoReady, + videoFailed: false, + videoError: null, styleMode: "style", styleNode, scope, @@ -130,12 +140,22 @@ function makeSession({ } async function verify(overrides = {}) { - const session = makeSession(overrides); + const { + nativeWindowFallback = async () => ({ + pass: false, + bound: false, + reason: "fixture-native-window-unavailable", + }), + ...sessionOverrides + } = overrides; + const session = makeSession(sessionOverrides); const result = await verifySession( session, "page-main", "fixture-theme", "fixture-revision", + session, + nativeWindowFallback, ); return { result, session }; } @@ -277,6 +297,29 @@ test("unrecognized window transport failures still fail closed", async () => { "An invalid target binding must not be reused for a bounds query."); }); +test("verified official-process window fallback supports Electron targets without Browser bindings", async () => { + const fallback = await verify({ + bindingError: new Error("Browser window not found (-32000)"), + nativeWindowFallback: async () => ({ + pass: true, + bound: true, + targetBound: false, + processBound: true, + source: "verified-codex-process-window", + processId: 101, + windowHandle: 202, + state: "normal", + width: 1280, + height: 800, + reason: null, + }), + }); + assert.equal(fallback.result.pass, true); + assert.equal(fallback.result.nativeWindow.targetBound, false); + assert.equal(fallback.result.nativeWindow.processBound, true); + assert.equal(fallback.result.nativeWindow.source, "verified-codex-process-window"); +}); + test("minimized and undersized native windows fail closed", async () => { const minimized = await verify({ currentBounds: { width: 1280, height: 800, windowState: "minimized" }, @@ -301,12 +344,33 @@ test("hidden documents and unreasonable viewports cannot pass", async () => { const hidden = await verify({ dom: makeDomFixture({ visibilityState: "hidden", hidden: true }) }); assert.equal(hidden.result.pass, false); assert.equal(hidden.result.readiness.documentPass, false); + assert.equal(hidden.result.appliedPass, true); + assert.equal(isDeferredRendererVerification(hidden.result), true, + "An exact payload in a verified native window must defer while its document is hidden."); + assert.equal(rendererVerificationAccepted(hidden.result), false, + "Normal verification must remain strict for hidden renderers."); + assert.equal(rendererVerificationAccepted(hidden.result, true), true, + "Startup may retain an exact applied payload while its renderer is temporarily hidden."); const tiny = await verify({ dom: makeDomFixture({ viewportWidth: 319, viewportHeight: 239 }), }); assert.equal(tiny.result.pass, false); assert.equal(tiny.result.readiness.viewportPass, false); + assert.equal(isDeferredRendererVerification(tiny.result), false); +}); + +test("a visible video that cannot become ready is a real failure, not a hidden-window defer", async () => { + const failedVideo = await verify({ + dom: makeDomFixture({ videoMode: "blob", videoReady: false }), + }); + assert.equal(failedVideo.result.pass, false); + assert.equal(failedVideo.result.appliedPass, true); + assert.equal(failedVideo.result.videoPass, false); + assert.equal(failedVideo.result.videoFailed, false); + assert.equal(isDeferredRendererVerification(failedVideo.result), false); + assert.equal(rendererVerificationAccepted(failedVideo.result, true), false, + "The startup exception must not accept a visible video decode failure."); }); test("zero-size and CSS-hidden shell anchors cannot satisfy L1", async () => { @@ -344,4 +408,6 @@ test("start cannot announce active after renderer verification exhausts its dead "A nonzero verify result must reach the startup rollback after its bounded retry window."); assert.ok(stateCleanup > startupCatch && rethrow > stateCleanup && activeMessage > rethrow, "Verification failure must clear transient state and rethrow before the active message."); + assert.match(source, /--verify[\s\S]*--allow-hidden-applied/, + "Startup verification must preserve an exact applied payload while the renderer is hidden."); }); diff --git a/windows/tests/media-server.test.mjs b/windows/tests/media-server.test.mjs new file mode 100644 index 00000000..81d55fea --- /dev/null +++ b/windows/tests/media-server.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { MediaServerController } from "../scripts/media-server.mjs"; + +function mp4Fixture(marker) { + const fileTypeBox = Buffer.alloc(24); + fileTypeBox.writeUInt32BE(fileTypeBox.length, 0); + fileTypeBox.write("ftyp", 4, "ascii"); + fileTypeBox.write("isom", 8, "ascii"); + fileTypeBox.writeUInt32BE(512, 12); + fileTypeBox.write("isom", 16, "ascii"); + fileTypeBox.write("mp41", 20, "ascii"); + return Buffer.concat([fileTypeBox, Buffer.from(marker, "ascii")]); +} + +const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-dream-skin-media-")); +try { + const invalidVideoPath = path.join(root, "renamed.mp4"); + await fs.writeFile(invalidVideoPath, Buffer.from("not-an-mp4")); + await assert.rejects( + new MediaServerController().stage(invalidVideoPath), + /not a valid MP4 container/, + ); + + const videoPath = path.join(root, "background.mp4"); + const bytes = mp4Fixture("AAAA"); + await fs.writeFile(videoPath, bytes); + + const media = new MediaServerController(); + const staged = await media.stage(videoPath); + media.commit(staged); + + assert.match(staged.protocolUrl, /^codex-dream-skin:\/\/media\/[a-f0-9]{32}$/); + + const denied = await fetch(staged.url); + assert.equal(denied.status, 403); + + const full = await fetch(staged.url, { + headers: { "X-Codex-Dream-Skin-Token": staged.token }, + }); + assert.equal(full.status, 200); + assert.equal(full.headers.get("content-type"), "video/mp4"); + assert.equal(Buffer.compare(Buffer.from(await full.arrayBuffer()), bytes), 0); + + const replaced = mp4Fixture("BBBB"); + assert.equal(replaced.length, bytes.length); + await fs.writeFile(videoPath, replaced); + const changed = await fetch(staged.url, { + headers: { "X-Codex-Dream-Skin-Token": staged.token }, + }); + assert.equal(changed.status, 404, "same-size content replacement must invalidate the staged server"); + await fs.writeFile(videoPath, bytes); + + const range = await fetch(staged.url, { + headers: { + Range: "bytes=4-7", + "X-Codex-Dream-Skin-Token": staged.token, + }, + }); + assert.equal(range.status, 206); + assert.equal(Buffer.from(await range.arrayBuffer()).toString(), "ftyp"); + + const options = await fetch(staged.url, { + method: "OPTIONS", + headers: { + Origin: "app://-", + "Access-Control-Request-Headers": "X-Codex-Dream-Skin-Token, Range", + "Access-Control-Request-Private-Network": "true", + }, + }); + assert.equal(options.status, 204); + assert.equal(options.headers.get("access-control-allow-private-network"), "true"); + + const wrongOrigin = await fetch(staged.url, { + headers: { + Origin: "https://example.invalid", + "X-Codex-Dream-Skin-Token": staged.token, + }, + }); + assert.equal(wrongOrigin.status, 403); + + const missing = await fetch(`${staged.url}/other`); + assert.equal(missing.status, 404); + await media.close(); +} finally { + await fs.rm(root, { recursive: true, force: true }); +} + +console.log("PASS: loopback media server range and route isolation."); diff --git a/windows/tests/run-tests.ps1 b/windows/tests/run-tests.ps1 index e25cf5b1..a4b22155 100644 --- a/windows/tests/run-tests.ps1 +++ b/windows/tests/run-tests.ps1 @@ -10,6 +10,29 @@ $temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) "codex-dream-skin-t New-Item -ItemType Directory -Path $temporaryRoot | Out-Null try { + $validVideoFixture = Join-Path $temporaryRoot 'valid-video.mp4' + $invalidVideoFixture = Join-Path $temporaryRoot 'renamed-video.mp4' + [System.IO.File]::WriteAllBytes($validVideoFixture, [byte[]]@( + 0, 0, 0, 24, 102, 116, 121, 112, + 105, 115, 111, 109, 0, 0, 2, 0, + 105, 115, 111, 109, 109, 112, 52, 49, + 65, 65, 65, 65 + )) + [System.IO.File]::WriteAllBytes( + $invalidVideoFixture, + [System.Text.Encoding]::ASCII.GetBytes('renamed-as-mp4') + ) + Assert-DreamSkinVideoFile -Path $validVideoFixture + $invalidVideoRejected = $false + try { + Assert-DreamSkinVideoFile -Path $invalidVideoFixture + } catch { + $invalidVideoRejected = $_.Exception.Message -match 'not a valid MP4 container' + } + if (-not $invalidVideoRejected) { + throw 'Video validation accepted a non-MP4 file that only used the .mp4 extension.' + } + $runtimeSourceName = 'runtime source ' + (-join @([char]0x6D4B, [char]0x8BD5)) $runtimeSourceRoot = Join-Path $temporaryRoot $runtimeSourceName $runtimeStateRoot = Join-Path $temporaryRoot 'runtime-state' @@ -631,6 +654,8 @@ try { } foreach ($unsafe in @( 'ws://example.com:9335/devtools/page/test', + 'ws://localhost:9335/devtools/page/test', + 'ws://[::1]:9335/devtools/page/test', 'ws://127.0.0.1:9336/devtools/page/test', 'wss://127.0.0.1:9335/devtools/page/test', 'ws://user@127.0.0.1:9335/devtools/page/test', @@ -1008,6 +1033,28 @@ try { throw 'Theme-store initialization overwrote the active custom theme or duplicated its bundled presets.' } + $videoTheme = Set-DreamSkinActiveTheme -ImagePath (Join-Path $Root 'assets\dream-reference.jpg') ` + -VideoPath $validVideoFixture -Theme $null -Name '视频主题' -StateRoot $themeStateRoot + if (-not $videoTheme.VideoPath -or + -not (Test-Path -LiteralPath (Join-Path $themePaths.Active 'background.mp4') -PathType Leaf) -or + (Get-FileHash -LiteralPath $videoTheme.VideoPath -Algorithm SHA256).Hash -cne + (Get-FileHash -LiteralPath $validVideoFixture -Algorithm SHA256).Hash) { + throw 'Writing a video theme did not commit the validated MP4 into the active theme.' + } + if ($videoTheme.Theme.video -cne 'background.mp4') { + throw 'Writing a video theme did not record background.mp4 in theme.json.' + } + $videoFingerprint = Get-DreamSkinThemeRuntimeContentFingerprint -ThemeDirectory $themePaths.Active + $null = Set-DreamSkinActiveTheme -ImagePath (Join-Path $Root 'assets\dream-reference.jpg') ` + -Theme $null -Name '临时图片主题' -StateRoot $themeStateRoot + $imageOnlyFingerprint = Get-DreamSkinThemeRuntimeContentFingerprint -ThemeDirectory $themePaths.Active + if ($imageOnlyFingerprint -ceq $videoFingerprint) { + throw 'Switching from a video theme to an image theme must change the runtime fingerprint.' + } + if (Test-Path -LiteralPath (Join-Path $themePaths.Active 'background.mp4') -PathType Leaf) { + throw 'Switching to an image-only theme left the previous background.mp4 in the active theme.' + } + $releaseFixtureRoot = Join-Path $temporaryRoot 'release-theme-fixture' $releaseFixtureAssets = Join-Path $releaseFixtureRoot 'assets' $releaseFixtureScripts = Join-Path $releaseFixtureRoot 'scripts' @@ -1032,6 +1079,7 @@ try { Copy-Item -LiteralPath (Join-Path $Root 'scripts\image-metadata.mjs') -Destination $releaseFixtureScripts -Force Copy-Item -LiteralPath (Join-Path $Root 'scripts\injector.mjs') -Destination $releaseFixtureScripts -Force Copy-Item -LiteralPath (Join-Path $Root 'scripts\install-dream-skin.ps1') -Destination $releaseFixtureScripts -Force + Copy-Item -LiteralPath (Join-Path $Root 'scripts\media-server.mjs') -Destination $releaseFixtureScripts -Force Copy-Item -LiteralPath (Join-Path $Root 'scripts\restore-dream-skin.ps1') -Destination $releaseFixtureScripts -Force Copy-Item -LiteralPath (Join-Path $Root 'scripts\start-dream-skin.ps1') -Destination $releaseFixtureScripts -Force Copy-Item -LiteralPath (Join-Path $Root 'scripts\theme-windows.ps1') -Destination $releaseFixtureScripts -Force @@ -1163,6 +1211,7 @@ try { throw 'macOS and Windows selector contract assets are not byte-identical.' } $traySource = Read-DreamSkinUtf8File -Path (Join-Path $Root 'scripts\tray-dream-skin.ps1') + $commonSource = Read-DreamSkinUtf8File -Path (Join-Path $Root 'scripts\common-windows.ps1') foreach ($requiredTrayAction in @('System.Windows.Forms.NotifyIcon', '暂停皮肤', '继续显示皮肤', '更换背景图', '已保存主题', '完全恢复 Codex')) { if (-not $traySource.Contains($requiredTrayAction)) { throw "Tray action is missing: $requiredTrayAction" } } @@ -1172,6 +1221,14 @@ try { -not $traySource.Contains('[System.Windows.Forms.Application]::Exit()')) { throw 'Tray pause/resume no longer mirrors macOS live-remove and re-apply semantics.' } + if (-not $traySource.Contains("-Enabled:(-not `$operationActive)") -or + -not $commonSource.Contains('function Test-DreamSkinOperationActive')) { + throw 'Tray does not disable mutating actions while a serialized Dream Skin operation is active.' + } + if (-not $traySource.Contains('正在应用或校验皮肤,请等待当前操作完成后再切换背景。')) { + throw 'Tray lock contention does not provide a non-modal busy notification.' + } + if (-not $traySource.Contains('一键更换视频背景')) { throw 'Tray video background action is missing.' } $themeWindowsSource = Read-DreamSkinUtf8File -Path (Join-Path $Root 'scripts\theme-windows.ps1') foreach ($requiredLiveRemoveToken in @( 'function Invoke-DreamSkinLiveRemove', From 770216204d62cdc599e5a5bcafbc16e59f91d06b Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Tue, 28 Jul 2026 01:29:55 +0800 Subject: [PATCH 2/2] fix(windows): gate active-theme writes with a short update marker Write .theme-update-in-progress only around the final image/video/json/css commit so the watcher cannot load a mixed stamp, then clear it on success. Stale markers expire so a crashed writer cannot freeze theme loads forever. --- windows/CHANGELOG.md | 4 +++ windows/scripts/injector.mjs | 37 +++++++++++++++++++++-- windows/scripts/theme-windows.ps1 | 28 +++++++++++++++-- windows/tests/injector-bootstrap.test.mjs | 19 ++++++++++++ windows/tests/run-tests.ps1 | 3 ++ 5 files changed, 86 insertions(+), 5 deletions(-) diff --git a/windows/CHANGELOG.md b/windows/CHANGELOG.md index 06e410a5..369537a3 100644 --- a/windows/CHANGELOG.md +++ b/windows/CHANGELOG.md @@ -2,6 +2,10 @@ ## 未发布 +### 修复 + +- 活动主题写入增加短窗口 `.theme-update-in-progress` 标记:大文件先备妥,仅在提交 image/video/theme.json/css 的瞬间挡住观察者,避免读到半套文件;成功提交后清除标记,过期标记不永久卡住观察者。 + ### 功能 - 支持将本机 MP4 设为动态视频背景:托盘新增「一键更换视频背景」,主题包 `theme.json` 可选 `video` 字段;视频经本机回环媒体服务传输(`127.0.0.1`、一次性 token、Range/`206`),图片仍作 poster 与失败回退。导入校验要求真实 `ftyp` 容器头、非空且不超过 100 MiB;热切换仅在渲染器确认可播放后提交,失败则恢复切换前主题。 diff --git a/windows/scripts/injector.mjs b/windows/scripts/injector.mjs index 0d2f4567..6ed0fe72 100644 --- a/windows/scripts/injector.mjs +++ b/windows/scripts/injector.mjs @@ -7,6 +7,8 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { readImageMetadata } from "./image-metadata.mjs"; import { MAX_VIDEO_BYTES, MediaServerController, isMp4Container } from "./media-server.mjs"; +const THEME_UPDATE_MARKER_NAME = ".theme-update-in-progress"; +const THEME_UPDATE_MARKER_MAX_AGE_MS = 120000; import { normalizeThemeColor, normalizeThemeText, @@ -552,7 +554,34 @@ async function loadSafeCss(themeRoot) { } } + +export async function isThemeUpdateInProgress(themeDir, now = Date.now()) { + const markerPath = path.join(themeDir, THEME_UPDATE_MARKER_NAME); + try { + const markerStat = await fs.stat(markerPath); + if (!markerStat.isFile()) return false; + const ageMs = now - markerStat.mtimeMs; + return ageMs < 0 || ageMs <= THEME_UPDATE_MARKER_MAX_AGE_MS; + } catch (error) { + if (error?.code === "ENOENT") return false; + throw error; + } +} + +async function waitForThemeUpdateCommit(themeDir, timeoutMs = 15000) { + const deadline = Date.now() + timeoutMs; + while (await isThemeUpdateInProgress(themeDir)) { + if (Date.now() >= deadline) { + throw new Error("Active theme update did not commit before the watcher startup deadline"); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + export async function loadTheme(themeDir) { + if (await isThemeUpdateInProgress(themeDir)) { + throw new Error("Active theme update is still being committed"); + } const realThemeDir = await fs.realpath(themeDir); const themePath = path.join(realThemeDir, "theme.json"); const themeText = await fs.readFile(themePath, "utf8"); @@ -709,7 +738,8 @@ export async function loadTheme(themeDir) { if (!sameFileStat(themeStat, themeAfter) || !sameFileStat(imageStat, imageAfter) || (videoStat ? !videoAfter || !sameFileStat(videoStat, videoAfter) : videoAfter !== null) || - (safeCss?.stat ? !cssAfter || !sameFileStat(safeCss.stat, cssAfter) : cssAfter !== null)) { + (safeCss?.stat ? !cssAfter || !sameFileStat(safeCss.stat, cssAfter) : cssAfter !== null) || + await isThemeUpdateInProgress(realThemeDir)) { throw new Error("Active theme changed while its committed snapshot was being loaded"); } const fingerprint = fingerprintHash @@ -2051,6 +2081,7 @@ async function runWatch(options) { process.on("SIGTERM", stop); try { + await waitForThemeUpdateCommit(options.themeDir); const initial = await stagePayload(null, !paused); loadedPayload = initial.payload; activeTheme = initial.theme; @@ -2084,7 +2115,9 @@ async function runWatch(options) { let nextTheme = activeTheme; let stagedMedia = null; let payloadUpdateFailed = false; - if (!nextPaused) { + const themeUpdatePending = !nextPaused && + await isThemeUpdateInProgress(options.themeDir); + if (!nextPaused && !themeUpdatePending) { try { const now = Date.now(); let shouldAudit = !loadedPayload || paused || now - lastStrongThemeAuditAt >= STRONG_THEME_AUDIT_MS; diff --git a/windows/scripts/theme-windows.ps1 b/windows/scripts/theme-windows.ps1 index 44621c8d..108ddc9c 100644 --- a/windows/scripts/theme-windows.ps1 +++ b/windows/scripts/theme-windows.ps1 @@ -9,6 +9,7 @@ $script:DreamSkinMaxThemeArchiveExpandedBytes = 64 * 1024 * 1024 $script:DreamSkinMaxThemeArchiveEntries = 32 $script:DreamSkinCommunityApiOrigin = 'https://api.dreamskin.cc' $script:DreamSkinMaxCommunityMetadataBytes = 64 * 1024 +$script:DreamSkinThemeUpdateMarkerName = '.theme-update-in-progress' function Test-DreamSkinCommunityVersionId { param([AllowNull()][string]$Value) @@ -627,6 +628,10 @@ function Set-DreamSkinActiveTheme { $videoTarget = Join-Path $paths.Active $videoName $videoTemporary = Join-Path $paths.Active ('.dream-video-tmp-' + [guid]::NewGuid().ToString('N') + '.mp4') $temporaryCss = $null + $updateMarker = Join-Path $paths.Active $script:DreamSkinThemeUpdateMarkerName + $updateToken = [guid]::NewGuid().ToString('N') + $markerWritten = $false + $themeCommitted = $false try { if ($SafeCssPath) { $safeCssSource = [System.IO.Path]::GetFullPath($SafeCssPath) @@ -641,14 +646,23 @@ function Set-DreamSkinActiveTheme { Copy-Item -LiteralPath $source -Destination $temporary -Force Assert-DreamSkinNoReparseComponents -Path $temporary Assert-DreamSkinImageFile -Path $temporary + if ($videoSource) { + Assert-DreamSkinNoReparseComponents -Path $videoTemporary + Copy-Item -LiteralPath $videoSource -Destination $videoTemporary -Force + Assert-DreamSkinVideoFile -Path $videoTemporary + } + + # Prepare and validate large files first. The marker covers only the short + # commit window so the watcher cannot load an image/video/manifest mixture. + Assert-DreamSkinNoReparseComponents -Path $updateMarker + Write-DreamSkinUtf8FileAtomically -Path $updateMarker -Content ($updateToken + "`r`n") + $markerWritten = $true + Move-Item -LiteralPath $temporary -Destination $target -Force Assert-DreamSkinNoReparseComponents -Path $target Assert-DreamSkinImageFile -Path $target if ($videoSource) { Assert-DreamSkinNoReparseComponents -Path $videoTarget - Assert-DreamSkinNoReparseComponents -Path $videoTemporary - Copy-Item -LiteralPath $videoSource -Destination $videoTemporary -Force - Assert-DreamSkinVideoFile -Path $videoTemporary Move-Item -LiteralPath $videoTemporary -Destination $videoTarget -Force Assert-DreamSkinVideoFile -Path $videoTarget $Theme | Add-Member -NotePropertyName video -NotePropertyValue $videoName -Force @@ -676,10 +690,18 @@ function Set-DreamSkinActiveTheme { Remove-Item -LiteralPath $activeCss -Force -ErrorAction SilentlyContinue } Write-DreamSkinTheme -ThemeDirectory $paths.Active -Theme $Theme + $themeCommitted = $true } finally { Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $videoTemporary -Force -ErrorAction SilentlyContinue if ($temporaryCss) { Remove-Item -LiteralPath $temporaryCss -Force -ErrorAction SilentlyContinue } + if ($markerWritten -and $themeCommitted -and + (Test-Path -LiteralPath $updateMarker -PathType Leaf)) { + $currentMarker = (Read-DreamSkinUtf8File -Path $updateMarker).Trim() + if ($currentMarker -ceq $updateToken) { + Remove-Item -LiteralPath $updateMarker -Force -ErrorAction Stop + } + } } $sameImage = $oldImage -and ([System.IO.Path]::GetFullPath($oldImage) -ieq [System.IO.Path]::GetFullPath($target)) if ($oldImage -and -not $sameImage -and diff --git a/windows/tests/injector-bootstrap.test.mjs b/windows/tests/injector-bootstrap.test.mjs index 0935e7fb..9e0ce09e 100644 --- a/windows/tests/injector-bootstrap.test.mjs +++ b/windows/tests/injector-bootstrap.test.mjs @@ -6,6 +6,7 @@ import vm from "node:vm"; import { fileURLToPath } from "node:url"; import { earlyPayloadFor, + isThemeUpdateInProgress, loadTheme, } from "../scripts/injector.mjs"; @@ -146,6 +147,8 @@ assert.match(source, /updateError\.deferred[\s\S]*live theme update deferred for "A hidden renderer must defer a live switch instead of terminating the watcher during rollback."); assert.match(source, /rendererVerificationAccepted\(lastResult, allowHiddenApplied\)/, "Startup verification must be able to accept only the explicit hidden-renderer defer state."); +assert.match(source, /themeUpdatePending[\s\S]*isThemeUpdateInProgress\(options\.themeDir\)/, + "The watcher must not load a partially committed active-theme transaction."); const updateCommit = source.indexOf("Keep the previous media server alive until every renderer verifies the update"); const updateApply = source.indexOf("await applyToSession(session, loadedPayload.payload, loadedPayload);", updateCommit); const commitIndex = source.indexOf("await mediaServers.commit(paused ? null : stagedMedia);", updateApply); @@ -178,6 +181,22 @@ try { const second = await loadTheme(fingerprintRoot); assert.notEqual(first.fingerprint, second.fingerprint, "Replacing an MP4 under the same filename must change the theme fingerprint."); + + const updateMarker = path.join(fingerprintRoot, ".theme-update-in-progress"); + await fs.writeFile(updateMarker, "fixture-update"); + assert.equal(await isThemeUpdateInProgress(fingerprintRoot), true, + "A fresh active-theme marker must hold the watcher on its verified payload."); + await assert.rejects( + loadTheme(fingerprintRoot), + /still being committed/, + "Theme loading must reject a writer's in-progress active files.", + ); + const staleMarkerTime = new Date(Date.now() - 180000); + await fs.utimes(updateMarker, staleMarkerTime, staleMarkerTime); + assert.equal(await isThemeUpdateInProgress(fingerprintRoot), false, + "A stale marker must not freeze the watcher indefinitely after a crashed writer."); + await fs.unlink(updateMarker); + assert.equal(await isThemeUpdateInProgress(fingerprintRoot), false); } finally { await fs.rm(fingerprintRoot, { recursive: true, force: true }); } diff --git a/windows/tests/run-tests.ps1 b/windows/tests/run-tests.ps1 index a4b22155..596486cb 100644 --- a/windows/tests/run-tests.ps1 +++ b/windows/tests/run-tests.ps1 @@ -1044,6 +1044,9 @@ try { if ($videoTheme.Theme.video -cne 'background.mp4') { throw 'Writing a video theme did not record background.mp4 in theme.json.' } + if (Test-Path -LiteralPath (Join-Path $themePaths.Active '.theme-update-in-progress')) { + throw 'A successful active-theme commit left its watcher transaction marker behind.' + } $videoFingerprint = Get-DreamSkinThemeRuntimeContentFingerprint -ThemeDirectory $themePaths.Active $null = Set-DreamSkinActiveTheme -ImagePath (Join-Path $Root 'assets\dream-reference.jpg') ` -Theme $null -Name '临时图片主题' -StateRoot $themeStateRoot