diff --git a/README.md b/README.md
index 1841193..1519ca8 100644
--- a/README.md
+++ b/README.md
@@ -156,7 +156,7 @@ bash macos/tests/run-tests.sh
这是 Wallpaper Engine 的替代品吗?
-不是。它只控制 Codex 应用内部的主题背景。Windows 管理器可以导入普通 MP4/WebM,但不渲染 Wallpaper Engine 的 Scene、Web 或打包项目。
+不是。它只控制 Codex 应用内部的主题背景。Windows 管理器可以扫描并直接引用 Wallpaper Engine 已下载到本地的 `type: video` MP4/WebM 项目;超过 1080p 时会生成本地流畅代理。`type: scene` 的 `scene.pkg` 目前可通过独立 GPL-3.0 侧车进行开发预览;Web 壁纸和项目脚本仍不会执行。
diff --git a/docs/platforms.md b/docs/platforms.md
index a376ab7..85d5f5e 100644
--- a/docs/platforms.md
+++ b/docs/platforms.md
@@ -77,6 +77,7 @@ Windows 启动、失败回滚与恢复重开均从已注册的 `OpenAI.Codex`
- macOS 的选图脚本会把这些字段写入主题库,可通过 `--appearance`、`--focus-x`、`--focus-y`、`--safe-area`、`--task-mode` 设置。
- Windows 安装会把运行所需的 `assets/` 与 `scripts/` 原子复制到 `%LOCALAPPDATA%\CodexDreamSkin\engine`,所有快捷方式均指向该受管副本,因此安装后可移动或删除源码目录。安装还会初始化 `active-theme`、`themes` 与 `images`,并把「桥本有菜」同时设为首次默认和可切换的已保存主题。系统托盘支持更换背景、保存当前主题、从「已保存主题」切换、暂停和恢复;图片与 `theme.json` 保存在主题仓库中,不写进 Codex 的 `config.toml`。安装会保留用户已有的 `appearanceTheme`;仅在识别到旧版精确托管的浅色三元组时按备份迁移。
- Windows 渲染器仍支持在注入前用 `window.__CODEX_DREAM_SKIN_CONFIG__` 提供内存级可选覆盖(形状同上,颜色覆盖使用 `palette.accent`),但普通用户应优先使用持久化主题仓库与托盘。
+- Windows 管理器的「Wallpaper Engine」入口会扫描用户已经通过 Steam / Wallpaper Engine 下载到本机的 `steamapps\workshop\content\431960` 项目,并读取 Steam 的 `libraryfolders.vdf` 发现第二库。每个 `type: video` 的 MP4/WebM 或 `type: scene` 的 `scene.pkg` Workshop 项目只列出一次;用户可多选导入主页,之后从主页切换。视频继续直接引用或生成本地 1080p 流畅代理;Scene 由独立 GPL-3.0 侧车在本机渲染成 H.264 fragmented MP4,再通过带随机令牌的 `127.0.0.1` HTTP 流交给 Chromium MSE,MIT 主程序不链接或内嵌 GPL 代码。侧车支持多 renderer 客户端和标准输入优雅退出,临时流位于 `%LOCALAPPDATA%\CodexDreamSkin\scene-stream`。Web、脚本壁纸暂不执行。每次启动或刷新主页都会重新校验 Workshop ID、库根目录、相对路径和文件存在性,取消订阅、移动或删除的条目会自动消失。
## 预设与图片类型
diff --git a/windows/CHANGELOG.md b/windows/CHANGELOG.md
index 5798fe2..67821ac 100644
--- a/windows/CHANGELOG.md
+++ b/windows/CHANGELOG.md
@@ -1,5 +1,14 @@
# Windows Changelog
+## Unreleased
+
+### 修复
+
+- 图形管理器启动 Codex 后不再等待被后台注入器继承的 stdout/stderr 管道,避免界面长期停在“正在启动”并禁用壁纸切换。
+- 应用视频壁纸或把管理器收进托盘时立即停止 WPF 视频预览,避免隐藏窗口与 Codex 同时解码同一媒体。
+- Codex 页面隐藏或壁纸完全遮盖时中止 Scene 的 Chromium 流读取并释放 MediaSource,重新显示时再建立回环连接。
+- Scene 侧车启动时立即排空 stderr,关闭管理器时不再在 UI 线程同步等待异步退出;旧的场景流临时文件会在后续启动时清理。
+
## 1.1.0 — 2026-07-19
### 新增
diff --git a/windows/README.md b/windows/README.md
index e7f2dd5..0111eb8 100644
--- a/windows/README.md
+++ b/windows/README.md
@@ -21,6 +21,7 @@ Codex 动态壁纸通过本机回环 CDP 给官方 Codex Windows 桌面应用加
管理器支持:
- 浏览、搜索并预览桌面壁纸库中的 PNG、JPEG、WebP、MP4 和 WebM。
+- 扫描 Wallpaper Engine 本地 Workshop 中的视频和 `scene.pkg` 场景;Scene 通过独立 GPL-3.0 本地渲染侧车输出回环 fMP4 流,Web 壁纸暂不支持。
- 一键启动或重新应用动态壁纸、切换壁纸、暂停/恢复以及还原官方外观。
- 调整「壁纸透出」程度,100% 时视频保持原始画面且整页主题蒙层为零。
- 系统托盘控制和可选的当前用户开机启动。
@@ -103,7 +104,9 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify-dream-s
导入图片必须是纯背景,不要使用包含窗口、侧栏、输入框、文字或按钮的效果截图。图片上限为 16 MB;宽或高不能超过 16384 像素,总像素不能超过 5000 万。
-动态壁纸始终静音、循环且不拦截鼠标操作;Codex 页面隐藏时会自动暂停。视频上限为 128 MB,并通过有界 CDP 分块传入当前 renderer,不会上传到网络,也不会把整段视频嵌入启动脚本。当前 MVP 不提供声音、播放列表或 Live2D。
+动态壁纸始终静音、循环且不拦截鼠标操作;Codex 页面隐藏时会暂停普通视频,并中止 Scene 在 Chromium 中的流读取,重新显示后再连接。视频上限为 128 MB,并通过有界 CDP 分块传入当前 renderer,不会上传到网络,也不会把整段视频嵌入启动脚本。Wallpaper Engine 的超 1080p 视频在首次应用时会生成本地 1080p 流畅代理,缓存位于 `%LOCALAPPDATA%\CodexDreamSkin\media-cache`;原 Workshop 文件不会被修改,仍会在每次刷新时校验。当前 MVP 不提供声音、播放列表或 Live2D。
+
+Scene 支持目前是开发预览:`SceneViewer.exe` 必须作为独立组件放在 `%LOCALAPPDATA%\CodexDreamSkin\scene-runtime`,并随附 GPL-3.0 许可证与对应源码信息。主程序只通过标准输入和带随机令牌的 `127.0.0.1` HTTP 接口与其通信;不会把 GPL 二进制嵌入 MIT 管理器。发布前仍需完成独立侧车包、源码提供说明和更多场景兼容性验证。
## 恢复与卸载快捷方式
diff --git a/windows/app/CodexDreamSkin.Manager/CodexDreamSkin.Manager.csproj b/windows/app/CodexDreamSkin.Manager/CodexDreamSkin.Manager.csproj
index 241941a..b49aab6 100644
--- a/windows/app/CodexDreamSkin.Manager/CodexDreamSkin.Manager.csproj
+++ b/windows/app/CodexDreamSkin.Manager/CodexDreamSkin.Manager.csproj
@@ -9,7 +9,7 @@
enable
CodexDreamSkinManager
CodexDreamSkin.Manager
- 1.1.0
+ 1.1.1
Codex 动态壁纸
One-click wallpaper manager for the official Codex Windows app.
CCDawn
diff --git a/windows/app/CodexDreamSkin.Manager/DreamSkinService.cs b/windows/app/CodexDreamSkin.Manager/DreamSkinService.cs
index 3827c09..842a3eb 100644
--- a/windows/app/CodexDreamSkin.Manager/DreamSkinService.cs
+++ b/windows/app/CodexDreamSkin.Manager/DreamSkinService.cs
@@ -3,26 +3,53 @@
namespace CodexDreamSkin.Manager;
-internal sealed class DreamSkinService
+internal sealed class DreamSkinService : IDisposable
{
private readonly RuntimeProvisioner _runtime;
private readonly PowerShellRunner _runner;
+ private readonly SceneStreamHost _sceneStream;
public DreamSkinService(RuntimeProvisioner runtime)
{
_runtime = runtime;
_runner = new PowerShellRunner(runtime);
+ _sceneStream = new SceneStreamHost(runtime);
}
+ public Func? ConfirmCodexRestart { get; set; }
+
public async Task StartAsync(CancellationToken cancellationToken = default)
{
+ var arguments = new List { "-Port", "9335" };
+ var status = GetStatus();
+ if (!status.WatcherRunning && status.CodexRunning)
+ {
+ if (ConfirmCodexRestart?.Invoke(
+ "Codex 需要重启一次以启用动态壁纸,未保存的输入可能丢失。现在重启吗?") != true)
+ {
+ throw new OperationCanceledException("已取消启动;Codex 未发生更改。");
+ }
+ arguments.Add("-RestartExisting");
+ }
var result = await _runner.RunScriptAsync(
_runtime.StartScript,
- new[] { "-Port", "9335", "-PromptRestart" },
- cancellationToken);
+ arguments,
+ cancellationToken,
+ captureOutput: false);
result.ThrowIfFailed("启动 Codex 动态壁纸");
}
+ public async Task RestoreActiveSceneAsync(CancellationToken cancellationToken = default)
+ {
+ var scenePath = await GetActiveScenePathAsync(cancellationToken);
+ if (string.IsNullOrWhiteSpace(scenePath) || !File.Exists(scenePath))
+ {
+ return false;
+ }
+ await ApplyWallpaperEngineAsync(scenePath, cancellationToken);
+ return true;
+ }
+
public async Task ApplyWallpaperAsync(string path, CancellationToken cancellationToken = default)
{
if (!File.Exists(path) || !WallpaperCatalog.IsSupported(path))
@@ -42,6 +69,124 @@ public async Task ApplyWallpaperAsync(string path, CancellationToken cancellatio
}
}
+ public async Task> ListWallpaperEngineAsync(
+ CancellationToken cancellationToken = default)
+ {
+ await _sceneStream.StopAsync();
+ var result = await RunManagerCommandAsync(
+ new[] { "-Action", "ListWallpaperEngine" },
+ cancellationToken);
+ result.ThrowIfFailed("读取 Wallpaper Engine 本地壁纸");
+ using var response = JsonDocument.Parse(result.StandardOutput);
+ if (!response.RootElement.TryGetProperty("items", out var items) ||
+ items.ValueKind != JsonValueKind.Array)
+ {
+ throw new InvalidOperationException("Wallpaper Engine 壁纸列表响应无效。");
+ }
+ return items.EnumerateArray().Select(ParseWallpaperEngineItem).ToArray();
+ }
+
+ public async Task ApplyWallpaperEngineAsync(
+ string mediaPath,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(mediaPath))
+ {
+ throw new ArgumentException("Wallpaper Engine 媒体路径不能为空。", nameof(mediaPath));
+ }
+ ProcessResult result;
+ if (Path.GetFileName(mediaPath).Equals("scene.pkg", StringComparison.OrdinalIgnoreCase))
+ {
+ var streamUrl = await _sceneStream.StartAsync(mediaPath, cancellationToken);
+ try
+ {
+ result = await RunManagerCommandAsync(
+ new[] { "-Action", "UseSceneStream", "-Path", mediaPath, "-StreamUrl", streamUrl },
+ cancellationToken);
+ }
+ catch
+ {
+ await _sceneStream.StopAsync();
+ throw;
+ }
+ }
+ else
+ {
+ await _sceneStream.StopAsync();
+ result = await RunManagerCommandAsync(
+ new[] { "-Action", "UseWallpaperEngine", "-Path", mediaPath },
+ cancellationToken);
+ }
+ try
+ {
+ result.ThrowIfFailed("应用 Wallpaper Engine 本地壁纸");
+ var theme = ParseThemeResult(result.StandardOutput);
+ if (!GetStatus().WatcherRunning)
+ {
+ await StartAsync(cancellationToken);
+ }
+ return theme;
+ }
+ catch
+ {
+ if (Path.GetFileName(mediaPath).Equals("scene.pkg", StringComparison.OrdinalIgnoreCase))
+ {
+ await _sceneStream.StopAsync();
+ }
+ throw;
+ }
+ }
+
+ public async Task> ListSavedThemesAsync(
+ CancellationToken cancellationToken = default)
+ {
+ var result = await RunManagerCommandAsync(new[] { "-Action", "ListThemes" }, cancellationToken);
+ result.ThrowIfFailed("读取已保存主题");
+ using var response = JsonDocument.Parse(result.StandardOutput);
+ if (!response.RootElement.TryGetProperty("themes", out var themes) ||
+ themes.ValueKind != JsonValueKind.Array)
+ {
+ throw new InvalidOperationException("主题列表响应无效。");
+ }
+ return themes.EnumerateArray().Select(ParseSavedTheme).ToArray();
+ }
+
+ public async Task SaveCurrentThemeAsync(
+ string name,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentException("主题名称不能为空。", nameof(name));
+ }
+ var result = await RunManagerCommandAsync(
+ new[] { "-Action", "SaveTheme", "-Name", name.Trim() },
+ cancellationToken);
+ result.ThrowIfFailed("保存当前主题");
+ return ParseThemeResult(result.StandardOutput);
+ }
+
+ public async Task ApplySavedThemeAsync(
+ string themeId,
+ CancellationToken cancellationToken = default)
+ {
+ await _sceneStream.StopAsync();
+ if (string.IsNullOrWhiteSpace(themeId))
+ {
+ throw new ArgumentException("主题标识不能为空。", nameof(themeId));
+ }
+ var result = await RunManagerCommandAsync(
+ new[] { "-Action", "UseTheme", "-ThemeId", themeId },
+ cancellationToken);
+ result.ThrowIfFailed("应用已保存主题");
+ var theme = ParseThemeResult(result.StandardOutput);
+ if (!GetStatus().WatcherRunning)
+ {
+ await StartAsync(cancellationToken);
+ }
+ return theme;
+ }
+
public async Task SetRevealAsync(int percent, CancellationToken cancellationToken = default)
{
if (percent is < 0 or > 100)
@@ -56,17 +201,40 @@ public async Task SetRevealAsync(int percent, CancellationToken cancellationToke
public async Task SetPausedAsync(bool paused, CancellationToken cancellationToken = default)
{
+ if (!paused)
+ {
+ var scenePath = await GetActiveScenePathAsync(cancellationToken);
+ if (!string.IsNullOrWhiteSpace(scenePath) && File.Exists(scenePath))
+ {
+ await ApplyWallpaperEngineAsync(scenePath, cancellationToken);
+ }
+ }
var result = await RunManagerCommandAsync(
new[] { "-Action", paused ? "Pause" : "Resume" },
cancellationToken);
result.ThrowIfFailed(paused ? "暂停皮肤" : "恢复皮肤");
+ if (paused)
+ {
+ await _sceneStream.StopAsync();
+ }
}
public async Task RestoreAsync(CancellationToken cancellationToken = default)
{
+ await _sceneStream.StopAsync();
+ var arguments = new List { "-Port", "9335", "-RestoreBaseTheme" };
+ if (GetStatus().CodexRunning)
+ {
+ if (ConfirmCodexRestart?.Invoke(
+ "恢复官方外观将关闭 Codex、移除动态壁纸,然后重新打开官方应用。是否继续?") != true)
+ {
+ throw new OperationCanceledException("已取消恢复;Codex 未发生更改。");
+ }
+ arguments.Add("-ForceRestart");
+ }
var result = await _runner.RunScriptAsync(
_runtime.RestoreScript,
- new[] { "-Port", "9335", "-RestoreBaseTheme", "-PromptRestart" },
+ arguments,
cancellationToken);
result.ThrowIfFailed("恢复 Codex 官方外观");
}
@@ -87,6 +255,10 @@ public DreamSkinStatus GetStatus()
if (File.Exists(statePath))
{
using var state = JsonDocument.Parse(File.ReadAllText(statePath));
+ var cdpPort = state.RootElement.TryGetProperty("port", out var portElement) &&
+ portElement.TryGetInt32(out var savedPort)
+ ? savedPort
+ : 0;
if (state.RootElement.TryGetProperty("injectorPid", out var pidElement) &&
pidElement.TryGetInt32(out var pid))
{
@@ -94,7 +266,13 @@ public DreamSkinStatus GetStatus()
{
using var process = Process.GetProcessById(pid);
watcherRunning = !process.HasExited &&
- process.ProcessName.Equals("node", StringComparison.OrdinalIgnoreCase);
+ process.ProcessName.Equals("node", StringComparison.OrdinalIgnoreCase) &&
+ cdpPort is >= 1024 and <= 65535 &&
+ System.Net.NetworkInformation.IPGlobalProperties.GetIPGlobalProperties()
+ .GetActiveTcpListeners()
+ .Any(endpoint =>
+ endpoint.Port == cdpPort &&
+ System.Net.IPAddress.IsLoopback(endpoint.Address));
}
catch
{
@@ -125,6 +303,19 @@ public DreamSkinStatus GetStatus()
{
reveal = Math.Clamp((int)Math.Round(opacity * 100), 0, 100);
}
+ if (theme.RootElement.TryGetProperty("media", out mediaElement) &&
+ mediaElement.TryGetProperty("type", out var mediaTypeElement) &&
+ mediaTypeElement.ValueKind == JsonValueKind.String &&
+ mediaTypeElement.GetString()?.Equals("scene", StringComparison.OrdinalIgnoreCase) == true)
+ {
+ mediaKind = WallpaperKind.Scene;
+ if (mediaElement.TryGetProperty("scenePath", out var scenePathElement) &&
+ scenePathElement.ValueKind == JsonValueKind.String &&
+ File.Exists(scenePathElement.GetString()))
+ {
+ mediaPath = scenePathElement.GetString();
+ }
+ }
}
}
catch
@@ -149,6 +340,88 @@ private Task RunManagerCommandAsync(
CancellationToken cancellationToken) =>
_runner.RunScriptAsync(_runtime.ManagerCommandScript, arguments, cancellationToken);
+ private static SavedTheme ParseThemeResult(string json)
+ {
+ using var response = JsonDocument.Parse(json);
+ return ParseSavedTheme(response.RootElement);
+ }
+
+ public void Dispose()
+ {
+ _sceneStream.Dispose();
+ }
+
+ private async Task GetActiveScenePathAsync(CancellationToken cancellationToken)
+ {
+ var themePath = Path.Combine(_runtime.StateRoot, "active-theme", "theme.json");
+ if (!File.Exists(themePath))
+ {
+ return null;
+ }
+ using var theme = JsonDocument.Parse(await File.ReadAllTextAsync(themePath, cancellationToken));
+ if (!theme.RootElement.TryGetProperty("media", out var media) ||
+ !media.TryGetProperty("type", out var mediaType) ||
+ !mediaType.ValueEquals("scene") ||
+ !media.TryGetProperty("scenePath", out var scenePathElement))
+ {
+ return null;
+ }
+ return scenePathElement.GetString();
+ }
+
+ private static SavedTheme ParseSavedTheme(JsonElement element)
+ {
+ return new SavedTheme(
+ RequireJsonString(element, "themeId", "id"),
+ RequireJsonString(element, "name"),
+ ParseWallpaperKind(RequireJsonString(element, "mediaType")));
+ }
+
+ private static WallpaperEngineItem ParseWallpaperEngineItem(JsonElement element)
+ {
+ if (!element.TryGetProperty("length", out var lengthElement) || !lengthElement.TryGetInt64(out var length) ||
+ length < 1)
+ {
+ throw new InvalidOperationException("Wallpaper Engine 条目缺少有效的媒体大小。");
+ }
+ return new WallpaperEngineItem(
+ RequireJsonString(element, "workshopId"),
+ RequireJsonString(element, "name"),
+ RequireJsonString(element, "workshopRoot"),
+ RequireJsonString(element, "projectDirectory"),
+ RequireJsonString(element, "relativePath"),
+ RequireJsonString(element, "mediaPath"),
+ ParseWallpaperKind(RequireJsonString(element, "mediaType")),
+ length);
+ }
+
+ private static string RequireJsonString(
+ JsonElement element,
+ string property,
+ string? fallbackProperty = null)
+ {
+ if (element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String &&
+ !string.IsNullOrWhiteSpace(value.GetString()))
+ {
+ return value.GetString()!;
+ }
+ if (fallbackProperty is not null && element.TryGetProperty(fallbackProperty, out value) &&
+ value.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(value.GetString()))
+ {
+ return value.GetString()!;
+ }
+ throw new InvalidOperationException($"主题响应缺少有效字段:{property}。");
+ }
+
+ private static WallpaperKind ParseWallpaperKind(string mediaType) =>
+ mediaType.Equals("video", StringComparison.OrdinalIgnoreCase)
+ ? WallpaperKind.Video
+ : mediaType.Equals("scene", StringComparison.OrdinalIgnoreCase)
+ ? WallpaperKind.Scene
+ : mediaType.Equals("image", StringComparison.OrdinalIgnoreCase)
+ ? WallpaperKind.Image
+ : throw new InvalidOperationException($"主题响应包含未知媒体类型:{mediaType}。");
+
private static bool IsAnyProcessRunning(string processName)
{
var processes = Process.GetProcessesByName(processName);
diff --git a/windows/app/CodexDreamSkin.Manager/MainForm.cs b/windows/app/CodexDreamSkin.Manager/MainForm.cs
index 81d10c2..07adc7d 100644
--- a/windows/app/CodexDreamSkin.Manager/MainForm.cs
+++ b/windows/app/CodexDreamSkin.Manager/MainForm.cs
@@ -40,6 +40,8 @@ internal sealed class MainForm : Form
private readonly Button _startButton = new();
private readonly Button _importButton = new();
private readonly Button _pauseButton = new();
+ private readonly Button _themesButton = new();
+ private readonly Button _wallpaperEngineButton = new();
private readonly Button _applyButton = new();
private readonly Button _restoreButton = new();
private readonly TrackBar _revealSlider = new();
@@ -81,6 +83,7 @@ public MainForm(DreamSkinService service, SettingsStore settingsStore, bool mini
SetDarkTitleBar(Handle);
BuildLayout();
+ _service.ConfirmCodexRestart = ConfirmCodexRestart;
_trayIcon = BuildTrayIcon();
_statusTimer.Tick += (_, _) => RefreshStatus();
_searchTimer.Tick += async (_, _) =>
@@ -93,6 +96,18 @@ public MainForm(DreamSkinService service, SettingsStore settingsStore, bool mini
{
_initializing = false;
_autoStartCheckBox.Checked = AutoStartManager.IsEnabled();
+ try
+ {
+ await _service.RestoreActiveSceneAsync(_lifetime.Token);
+ }
+ catch (OperationCanceledException exception)
+ {
+ ShowMessage(exception.Message, false);
+ }
+ catch (Exception exception)
+ {
+ ShowError(exception);
+ }
RefreshStatus();
await ReloadLibraryAsync();
_statusTimer.Start();
@@ -115,6 +130,7 @@ protected override void Dispose(bool disposing)
StopVideoPreview();
_trayIcon.Dispose();
_appIcon.Dispose();
+ _service.Dispose();
_lifetime.Dispose();
_libraryLoad?.Dispose();
}
@@ -234,6 +250,22 @@ await RunOperationAsync(
left.Controls.Add(_pauseButton);
left.Controls.SetChildIndex(_pauseButton, 0);
+ ConfigureButton(_themesButton, "已保存主题", false);
+ _themesButton.Dock = DockStyle.Top;
+ _themesButton.Height = 38;
+ _themesButton.AccessibleName = "管理已保存主题";
+ _themesButton.Click += (_, _) => ShowSavedThemes();
+ left.Controls.Add(_themesButton);
+ left.Controls.SetChildIndex(_themesButton, 0);
+
+ ConfigureButton(_wallpaperEngineButton, "Wallpaper Engine", false);
+ _wallpaperEngineButton.Dock = DockStyle.Top;
+ _wallpaperEngineButton.Height = 38;
+ _wallpaperEngineButton.AccessibleName = "选择本机已下载的 Wallpaper Engine 视频";
+ _wallpaperEngineButton.Click += async (_, _) => await ShowWallpaperEngineAsync();
+ left.Controls.Add(_wallpaperEngineButton);
+ left.Controls.SetChildIndex(_wallpaperEngineButton, 0);
+
ConfigureButton(_restoreButton, "恢复官方外观", false, danger: true);
_restoreButton.Dock = DockStyle.Top;
_restoreButton.Height = 38;
@@ -395,7 +427,7 @@ private Control BuildPreviewPanel()
panel.Controls.Add(_applyButton);
panel.Controls.SetChildIndex(_applyButton, 0);
- var revealHeading = CreateLabel("壁纸透出", 10f, FontStyle.Bold, TextMuted);
+ var revealHeading = CreateLabel("主题透明度", 10f, FontStyle.Bold, TextMuted);
revealHeading.Dock = DockStyle.Top;
revealHeading.Height = 44;
revealHeading.Padding = new Padding(0, 18, 0, 0);
@@ -405,7 +437,7 @@ private Control BuildPreviewPanel()
_revealLabel.Dock = DockStyle.Top;
_revealLabel.Height = 28;
_revealLabel.ForeColor = TextPrimary;
- _revealLabel.Text = "100% · 原始壁纸画面";
+ _revealLabel.Text = "100% · 完全透明(原始壁纸)";
panel.Controls.Add(_revealLabel);
panel.Controls.SetChildIndex(_revealLabel, 0);
@@ -417,7 +449,7 @@ private Control BuildPreviewPanel()
_revealSlider.TickFrequency = 10;
_revealSlider.SmallChange = 5;
_revealSlider.LargeChange = 10;
- _revealSlider.AccessibleName = "壁纸透出程度";
+ _revealSlider.AccessibleName = "主题透明度,零为完全不透明,一百为完全透明";
_revealSlider.Scroll += (_, _) => UpdateRevealLabel();
_revealSlider.MouseUp += async (_, _) => await CommitRevealAsync();
_revealSlider.KeyUp += async (_, _) => await CommitRevealAsync();
@@ -443,12 +475,28 @@ private async Task ReloadLibraryAsync()
_librarySummary.Text = "正在读取壁纸库…";
try
{
- var items = await _catalog.LoadAsync(_settings.LibraryPath, _searchBox.Text, token);
+ var libraryItems = await _catalog.LoadAsync(_settings.LibraryPath, _searchBox.Text, token);
token.ThrowIfCancellationRequested();
+ var importedItems = WallpaperEngineReferenceCatalog.ResolveAndPrune(
+ _settings.WallpaperEngineImports,
+ _searchBox.Text,
+ out var importsChanged);
+ if (importsChanged)
+ {
+ _settingsStore.Save(_settings);
+ }
+ var items = libraryItems
+ .Concat(importedItems)
+ .GroupBy(item => item.Path, StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.First())
+ .OrderByDescending(item => item.LastWriteTime)
+ .ThenBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase)
+ .Take(500)
+ .ToArray();
ClearLibraryCards();
- _librarySummary.Text = items.Count == 0
+ _librarySummary.Text = items.Length == 0
? "暂无壁纸 · 点击左侧「添加壁纸」导入"
- : $"共 {items.Count} 个可直接使用的壁纸";
+ : $"共 {items.Length} 个可直接使用的壁纸";
foreach (var item in items)
{
@@ -482,7 +530,8 @@ private async Task LoadCardThumbnailsAsync(
foreach (var item in items)
{
token.ThrowIfCancellationRequested();
- var thumbnail = await Task.Run(() => ShellThumbnail.Get(item.Path, 280, 170), token);
+ var displayPath = WallpaperEngineReferenceCatalog.GetPreviewPath(item);
+ var thumbnail = await Task.Run(() => ShellThumbnail.Get(displayPath, 280, 170), token);
if (thumbnail is null)
{
continue;
@@ -519,7 +568,7 @@ private Panel CreateWallpaperCard(WallpaperItem item)
BackColor = Surface,
Cursor = Cursors.Hand,
Tag = item,
- AccessibleName = $"{item.Name},{item.TypeLabel}",
+ AccessibleName = $"{item.Name},{item.SourceLabel}",
};
var image = new PictureBox
{
@@ -536,7 +585,7 @@ private Panel CreateWallpaperCard(WallpaperItem item)
title.AutoEllipsis = true;
title.Cursor = Cursors.Hand;
card.Controls.Add(title);
- var meta = CreateLabel($"{item.TypeLabel} · {item.SizeLabel}", 8.5f, FontStyle.Regular, TextMuted);
+ var meta = CreateLabel($"{item.SourceLabel} · {item.SizeLabel}", 8.5f, FontStyle.Regular, TextMuted);
meta.Location = new Point(10, 162);
meta.Size = new Size(204, 20);
meta.Cursor = Cursors.Hand;
@@ -577,7 +626,7 @@ private void SelectWallpaper(WallpaperItem item, Control card)
_selectedCard = card;
card.BackColor = Color.FromArgb(67, 43, 52);
_selectionTitle.Text = item.Name;
- _selectionMeta.Text = $"{item.TypeLabel} · {item.Extension} · {item.SizeLabel}";
+ _selectionMeta.Text = $"{item.SourceLabel} · {item.Extension} · {item.SizeLabel}";
_applyButton.Enabled = !_busy;
ShowPreview(item);
}
@@ -599,7 +648,10 @@ private void ShowPreview(WallpaperItem item)
{
_videoHost.Visible = false;
_previewImage.Visible = true;
- _previewImage.Image = ShellThumbnail.Get(item.Path, 720, 480);
+ _previewImage.Image = ShellThumbnail.Get(
+ WallpaperEngineReferenceCatalog.GetPreviewPath(item),
+ 720,
+ 480);
}
}
@@ -624,17 +676,26 @@ private async Task ApplySelectedAsync()
}
await RunOperationAsync(
$"正在应用 {_selectedWallpaper.Name}…",
- () => _service.ApplyWallpaperAsync(_selectedWallpaper.Path, _lifetime.Token),
+ () => _selectedWallpaper.Source == WallpaperSource.WallpaperEngine
+ ? _service.ApplyWallpaperEngineAsync(_selectedWallpaper.Path, _lifetime.Token)
+ : _service.ApplyWallpaperAsync(_selectedWallpaper.Path, _lifetime.Token),
$"已应用:{_selectedWallpaper.Name}");
+ StopVideoPreview();
+ _videoHost.Visible = false;
}
private async Task CommitRevealAsync()
{
var value = _revealSlider.Value;
await RunOperationAsync(
- $"正在设置壁纸透出 {value}%…",
+ $"正在设置主题透明度 {value}%…",
() => _service.SetRevealAsync(value, _lifetime.Token),
- value == 100 ? "壁纸已按原始画面显示。" : $"壁纸透出已设为 {value}%。",
+ value switch
+ {
+ 0 => "主题已完全不透明。",
+ 100 => "主题已完全透明,正在显示原始壁纸画面。",
+ _ => $"主题透明度已设为 {value}%。"
+ },
refreshLibrary: false);
}
@@ -658,6 +719,36 @@ await RunOperationAsync(
refreshLibrary: false);
}
+ private void ShowSavedThemes()
+ {
+ using var dialog = new SavedThemesDialog(_service, _lifetime.Token);
+ dialog.ShowDialog(this);
+ if (dialog.LastAppliedTheme is not null)
+ {
+ ShowMessage($"已应用:{dialog.LastAppliedTheme.Name}", false);
+ RefreshStatus();
+ }
+ }
+
+ private async Task ShowWallpaperEngineAsync()
+ {
+ using var dialog = new WallpaperEngineDialog(_service, _lifetime.Token);
+ if (dialog.ShowDialog(this) == DialogResult.OK && dialog.ImportedItems.Count > 0)
+ {
+ var added = WallpaperEngineReferenceCatalog.AddImports(
+ _settings.WallpaperEngineImports,
+ dialog.ImportedItems);
+ if (added > 0)
+ {
+ _settingsStore.Save(_settings);
+ }
+ await ReloadLibraryAsync();
+ ShowMessage(
+ added == 0 ? "所选 Wallpaper Engine 视频已在主页中。" : $"已导入主页:{added} 个 Wallpaper Engine 视频。",
+ false);
+ }
+ }
+
private async Task ImportWallpapersAsync()
{
using var dialog = new OpenFileDialog
@@ -798,6 +889,10 @@ private async Task RunOperationAsync(
{
// App shutdown owns cancellation.
}
+ catch (OperationCanceledException exception)
+ {
+ ShowMessage(exception.Message, false);
+ }
catch (Exception exception)
{
ShowError(exception);
@@ -808,6 +903,15 @@ private async Task RunOperationAsync(
}
}
+ private bool ConfirmCodexRestart(string message) =>
+ MessageBox.Show(
+ this,
+ message,
+ DisplayName,
+ MessageBoxButtons.YesNo,
+ MessageBoxIcon.Warning,
+ MessageBoxDefaultButton.Button2) == DialogResult.Yes;
+
private void RefreshStatus()
{
var status = _service.GetStatus();
@@ -832,9 +936,12 @@ private void RefreshStatus()
private void UpdateRevealLabel()
{
- _revealLabel.Text = _revealSlider.Value == 100
- ? "100% · 原始壁纸画面"
- : $"{_revealSlider.Value}% · 主题蒙层仍保留";
+ _revealLabel.Text = _revealSlider.Value switch
+ {
+ 0 => "0% · 完全不透明",
+ 100 => "100% · 完全透明(原始壁纸)",
+ var value => $"{value}% · 壁纸逐步透出"
+ };
}
private void SetBusy(bool busy)
@@ -843,6 +950,8 @@ private void SetBusy(bool busy)
_startButton.Enabled = !busy;
_importButton.Enabled = !busy;
_restoreButton.Enabled = !busy;
+ _themesButton.Enabled = !busy;
+ _wallpaperEngineButton.Enabled = !busy;
_applyButton.Enabled = !busy && _selectedWallpaper is not null;
_revealSlider.Enabled = !busy;
_searchBox.Enabled = !busy;
@@ -918,6 +1027,8 @@ private void OnFormClosing(object? sender, FormClosingEventArgs args)
return;
}
args.Cancel = true;
+ StopVideoPreview();
+ _videoHost.Visible = false;
Hide();
_trayIcon.ShowBalloonTip(
1800,
diff --git a/windows/app/CodexDreamSkin.Manager/Models.cs b/windows/app/CodexDreamSkin.Manager/Models.cs
index 6175c0f..1abd8d7 100644
--- a/windows/app/CodexDreamSkin.Manager/Models.cs
+++ b/windows/app/CodexDreamSkin.Manager/Models.cs
@@ -7,6 +7,21 @@ internal enum WallpaperKind
{
Image,
Video,
+ Scene,
+}
+
+internal enum WallpaperSource
+{
+ Library,
+ WallpaperEngine,
+}
+
+internal sealed class WallpaperEngineReference
+{
+ public string WorkshopId { get; set; } = string.Empty;
+ public string WorkshopRoot { get; set; } = string.Empty;
+ public string RelativePath { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
}
internal sealed record WallpaperItem(
@@ -15,12 +30,58 @@ internal sealed record WallpaperItem(
string Extension,
WallpaperKind Kind,
long Length,
- DateTime LastWriteTime)
+ DateTime LastWriteTime,
+ WallpaperSource Source = WallpaperSource.Library,
+ WallpaperEngineReference? WallpaperEngineReference = null)
+{
+ public string TypeLabel => Kind switch
+ {
+ WallpaperKind.Video => "动态壁纸",
+ WallpaperKind.Scene => "场景壁纸",
+ _ => "静态壁纸",
+ };
+ public string SourceLabel => Source == WallpaperSource.WallpaperEngine
+ ? $"Wallpaper Engine · {TypeLabel}"
+ : TypeLabel;
+ public string SizeLabel => Length >= 1024L * 1024L
+ ? $"{Length / 1024d / 1024d:0.#} MB"
+ : $"{Math.Max(1, Length / 1024d):0} KB";
+}
+
+internal sealed record SavedTheme(string Id, string Name, WallpaperKind Kind)
{
- public string TypeLabel => Kind == WallpaperKind.Video ? "动态壁纸" : "静态壁纸";
+ public string TypeLabel => Kind switch
+ {
+ WallpaperKind.Video => "动态壁纸",
+ WallpaperKind.Scene => "场景壁纸",
+ _ => "静态壁纸",
+ };
+
+ public override string ToString() => $"{Name} · {TypeLabel}";
+}
+
+internal sealed record WallpaperEngineItem(
+ string WorkshopId,
+ string Name,
+ string WorkshopRoot,
+ string ProjectDirectory,
+ string RelativePath,
+ string MediaPath,
+ WallpaperKind Kind,
+ long Length)
+{
+ public string TypeLabel => Kind switch
+ {
+ WallpaperKind.Video => "动态壁纸",
+ WallpaperKind.Scene => "场景壁纸",
+ _ => "静态壁纸",
+ };
+
public string SizeLabel => Length >= 1024L * 1024L
? $"{Length / 1024d / 1024d:0.#} MB"
: $"{Math.Max(1, Length / 1024d):0} KB";
+
+ public override string ToString() => $"{Name} · {TypeLabel} · {SizeLabel}";
}
internal sealed record DreamSkinStatus(
@@ -55,6 +116,7 @@ internal sealed class AppSettings
{
public string LibraryPath { get; set; } = SettingsStore.DefaultLibraryPath;
public bool StartWithWindows { get; set; }
+ public List WallpaperEngineImports { get; set; } = new();
}
internal sealed class SettingsStore
@@ -94,6 +156,7 @@ public AppSettings Load()
{
settings.LibraryPath = DefaultLibraryPath;
}
+ settings.WallpaperEngineImports ??= new List();
return settings;
}
catch
diff --git a/windows/app/CodexDreamSkin.Manager/PowerShellRunner.cs b/windows/app/CodexDreamSkin.Manager/PowerShellRunner.cs
index ccbe317..55f8d61 100644
--- a/windows/app/CodexDreamSkin.Manager/PowerShellRunner.cs
+++ b/windows/app/CodexDreamSkin.Manager/PowerShellRunner.cs
@@ -39,7 +39,8 @@ public PowerShellRunner(RuntimeProvisioner runtime)
public async Task RunScriptAsync(
string script,
IEnumerable arguments,
- CancellationToken cancellationToken = default)
+ CancellationToken cancellationToken = default,
+ bool captureOutput = true)
{
var startInfo = new ProcessStartInfo
{
@@ -47,11 +48,14 @@ public async Task RunScriptAsync(
WorkingDirectory = _runtime.PayloadRoot,
UseShellExecute = false,
CreateNoWindow = true,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- StandardOutputEncoding = System.Text.Encoding.UTF8,
- StandardErrorEncoding = System.Text.Encoding.UTF8,
};
+ if (captureOutput)
+ {
+ startInfo.RedirectStandardOutput = true;
+ startInfo.RedirectStandardError = true;
+ startInfo.StandardOutputEncoding = System.Text.Encoding.UTF8;
+ startInfo.StandardErrorEncoding = System.Text.Encoding.UTF8;
+ }
startInfo.ArgumentList.Add("-NoProfile");
startInfo.ArgumentList.Add("-ExecutionPolicy");
startInfo.ArgumentList.Add("Bypass");
@@ -68,9 +72,29 @@ public async Task RunScriptAsync(
using var process = new Process { StartInfo = startInfo };
process.Start();
- var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
- var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
- await process.WaitForExitAsync(cancellationToken);
+ var outputTask = captureOutput
+ ? process.StandardOutput.ReadToEndAsync(cancellationToken)
+ : Task.FromResult(string.Empty);
+ var errorTask = captureOutput
+ ? process.StandardError.ReadToEndAsync(cancellationToken)
+ : Task.FromResult(string.Empty);
+ try
+ {
+ await process.WaitForExitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ if (!process.HasExited)
+ {
+ process.Kill(entireProcessTree: true);
+ await process.WaitForExitAsync(CancellationToken.None);
+ }
+ throw;
+ }
+ if (!captureOutput)
+ {
+ return new ProcessResult(process.ExitCode, string.Empty, string.Empty);
+ }
return new ProcessResult(
process.ExitCode,
await outputTask,
diff --git a/windows/app/CodexDreamSkin.Manager/Program.cs b/windows/app/CodexDreamSkin.Manager/Program.cs
index 88683cd..af8029e 100644
--- a/windows/app/CodexDreamSkin.Manager/Program.cs
+++ b/windows/app/CodexDreamSkin.Manager/Program.cs
@@ -82,11 +82,86 @@ public static int Run(RuntimeProvisioner runtime)
}
var version = Assembly.GetExecutingAssembly().GetName().Version;
- return version is null ? 3 : 0;
+ if (version is null)
+ {
+ return 3;
+ }
+ return RunnerReturnsAfterParentExit(runtime) ? 0 : 5;
}
catch
{
return 4;
}
}
+
+ private static bool RunnerReturnsAfterParentExit(RuntimeProvisioner runtime)
+ {
+ var temporary = Path.Combine(
+ Path.GetTempPath(),
+ "codex-dream-skin-runner-test-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(temporary);
+ var script = Path.Combine(temporary, "parent-exits-first.ps1");
+ int? childProcessId = null;
+ try
+ {
+ File.WriteAllText(
+ script,
+ """
+ $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
+ $stdout = Join-Path $PSScriptRoot 'child-stdout.log'
+ $stderr = Join-Path $PSScriptRoot 'child-stderr.log'
+ $pidFile = Join-Path $PSScriptRoot 'child.pid'
+ $child = Start-Process -FilePath $powershell -ArgumentList @(
+ '-NoProfile', '-Command', 'Start-Sleep -Seconds 5'
+ ) -WindowStyle Hidden -PassThru `
+ -RedirectStandardOutput $stdout -RedirectStandardError $stderr
+ Set-Content -LiteralPath $pidFile -Value $child.Id -Encoding Ascii
+ """,
+ new System.Text.UTF8Encoding(false));
+
+ using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(8));
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
+ var result = new PowerShellRunner(runtime)
+ .RunScriptAsync(
+ script,
+ Array.Empty(),
+ cancellation.Token,
+ captureOutput: false)
+ .GetAwaiter()
+ .GetResult();
+ stopwatch.Stop();
+
+ var pidFile = Path.Combine(temporary, "child.pid");
+ if (File.Exists(pidFile) &&
+ int.TryParse(File.ReadAllText(pidFile).Trim(), out var parsedProcessId))
+ {
+ childProcessId = parsedProcessId;
+ }
+
+ return result.ExitCode == 0 &&
+ childProcessId.HasValue &&
+ stopwatch.Elapsed < TimeSpan.FromSeconds(3);
+ }
+ finally
+ {
+ if (childProcessId.HasValue)
+ {
+ try
+ {
+ using var child = System.Diagnostics.Process.GetProcessById(childProcessId.Value);
+ if (!child.HasExited &&
+ child.ProcessName.Equals("powershell", StringComparison.OrdinalIgnoreCase))
+ {
+ child.Kill(entireProcessTree: true);
+ child.WaitForExit(1000);
+ }
+ }
+ catch
+ {
+ // The short-lived test child may have already exited.
+ }
+ }
+ Directory.Delete(temporary, true);
+ }
+ }
}
diff --git a/windows/app/CodexDreamSkin.Manager/SavedThemesDialog.cs b/windows/app/CodexDreamSkin.Manager/SavedThemesDialog.cs
new file mode 100644
index 0000000..a30aec6
--- /dev/null
+++ b/windows/app/CodexDreamSkin.Manager/SavedThemesDialog.cs
@@ -0,0 +1,292 @@
+namespace CodexDreamSkin.Manager;
+
+internal sealed class SavedThemesDialog : Form
+{
+ private static readonly Color Canvas = Color.FromArgb(17, 19, 24);
+ private static readonly Color Surface = Color.FromArgb(25, 28, 35);
+ private static readonly Color SurfaceRaised = Color.FromArgb(34, 38, 47);
+ private static readonly Color SurfaceHover = Color.FromArgb(44, 48, 58);
+ private static readonly Color Accent = Color.FromArgb(225, 105, 131);
+ private static readonly Color AccentHover = Color.FromArgb(240, 124, 149);
+ private static readonly Color TextPrimary = Color.FromArgb(244, 240, 243);
+ private static readonly Color TextMuted = Color.FromArgb(167, 170, 180);
+
+ private readonly DreamSkinService _service;
+ private readonly CancellationToken _cancellationToken;
+ private readonly ListBox _themes = new();
+ private readonly TextBox _nameBox = new();
+ private readonly Label _statusLabel = new();
+ private readonly Button _applyButton = new();
+ private readonly Button _saveButton = new();
+ private bool _busy;
+
+ public SavedThemesDialog(DreamSkinService service, CancellationToken cancellationToken)
+ {
+ _service = service;
+ _cancellationToken = cancellationToken;
+
+ Text = "已保存主题";
+ StartPosition = FormStartPosition.CenterParent;
+ FormBorderStyle = FormBorderStyle.FixedDialog;
+ MaximizeBox = false;
+ MinimizeBox = false;
+ ShowInTaskbar = false;
+ ClientSize = new Size(520, 430);
+ BackColor = Canvas;
+ ForeColor = TextPrimary;
+ Font = new Font("Segoe UI Variable Text", 10f, FontStyle.Regular, GraphicsUnit.Point);
+
+ BuildLayout();
+ Shown += async (_, _) => await ReloadThemesAsync();
+ }
+
+ public SavedTheme? LastAppliedTheme { get; private set; }
+
+ private void BuildLayout()
+ {
+ var layout = new TableLayoutPanel
+ {
+ Dock = DockStyle.Fill,
+ ColumnCount = 1,
+ RowCount = 5,
+ Padding = new Padding(20),
+ BackColor = Canvas,
+ };
+ layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 38));
+ layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
+ layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 32));
+ layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 62));
+ layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 46));
+
+ var heading = new Label
+ {
+ Dock = DockStyle.Fill,
+ Text = "已保存主题",
+ Font = new Font(Font.FontFamily, 14f, FontStyle.Bold),
+ ForeColor = TextPrimary,
+ TextAlign = ContentAlignment.MiddleLeft,
+ };
+ layout.Controls.Add(heading, 0, 0);
+
+ _themes.Dock = DockStyle.Fill;
+ _themes.BorderStyle = BorderStyle.FixedSingle;
+ _themes.BackColor = Surface;
+ _themes.ForeColor = TextPrimary;
+ _themes.IntegralHeight = false;
+ _themes.AccessibleName = "已保存主题列表";
+ _themes.AccessibleDescription = "选择静态或动态主题后可应用到 Codex。";
+ _themes.SelectedIndexChanged += (_, _) => UpdateActionState();
+ layout.Controls.Add(_themes, 0, 1);
+
+ _statusLabel.Dock = DockStyle.Fill;
+ _statusLabel.ForeColor = TextMuted;
+ _statusLabel.Text = "正在读取已保存主题…";
+ _statusLabel.TextAlign = ContentAlignment.MiddleLeft;
+ _statusLabel.AutoEllipsis = true;
+ layout.Controls.Add(_statusLabel, 0, 2);
+
+ var savePanel = new TableLayoutPanel
+ {
+ Dock = DockStyle.Fill,
+ ColumnCount = 2,
+ RowCount = 1,
+ Margin = new Padding(0, 4, 0, 4),
+ };
+ savePanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
+ savePanel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 128));
+ _nameBox.Dock = DockStyle.Fill;
+ _nameBox.PlaceholderText = "输入名称以保存当前主题(最多 80 字符)";
+ _nameBox.BackColor = SurfaceRaised;
+ _nameBox.ForeColor = TextPrimary;
+ _nameBox.BorderStyle = BorderStyle.FixedSingle;
+ _nameBox.AccessibleName = "当前主题名称";
+ _nameBox.TextChanged += (_, _) => UpdateActionState();
+ savePanel.Controls.Add(_nameBox, 0, 0);
+ ConfigureButton(_saveButton, "保存当前主题", false);
+ _saveButton.Dock = DockStyle.Fill;
+ _saveButton.AccessibleName = "保存当前主题";
+ _saveButton.Click += async (_, _) => await SaveCurrentThemeAsync();
+ savePanel.Controls.Add(_saveButton, 1, 0);
+ layout.Controls.Add(savePanel, 0, 3);
+
+ var actions = new FlowLayoutPanel
+ {
+ Dock = DockStyle.Fill,
+ FlowDirection = FlowDirection.RightToLeft,
+ WrapContents = false,
+ BackColor = Canvas,
+ Padding = new Padding(0, 4, 0, 0),
+ };
+ var closeButton = new Button { DialogResult = DialogResult.Cancel, Width = 92, Height = 34 };
+ ConfigureButton(closeButton, "关闭", false);
+ closeButton.AccessibleName = "关闭已保存主题窗口";
+ ConfigureButton(_applyButton, "应用主题", true);
+ _applyButton.Width = 112;
+ _applyButton.Height = 34;
+ _applyButton.AccessibleName = "应用选中的主题";
+ _applyButton.Click += async (_, _) => await ApplySelectedThemeAsync();
+ actions.Controls.Add(closeButton);
+ actions.Controls.Add(_applyButton);
+ layout.Controls.Add(actions, 0, 4);
+
+ CancelButton = closeButton;
+ Controls.Add(layout);
+ UpdateActionState();
+ }
+
+ private async Task ReloadThemesAsync(string? selectedId = null)
+ {
+ SetBusy(true);
+ _statusLabel.ForeColor = TextMuted;
+ _statusLabel.Text = "正在读取已保存主题…";
+ try
+ {
+ var themes = await _service.ListSavedThemesAsync(_cancellationToken);
+ if (IsDisposed)
+ {
+ return;
+ }
+ _themes.BeginUpdate();
+ try
+ {
+ _themes.Items.Clear();
+ foreach (var theme in themes)
+ {
+ _themes.Items.Add(theme);
+ }
+ var selectedIndex = string.IsNullOrWhiteSpace(selectedId)
+ ? -1
+ : themes.ToList().FindIndex(theme => theme.Id.Equals(selectedId, StringComparison.Ordinal));
+ if (selectedIndex >= 0)
+ {
+ _themes.SelectedIndex = selectedIndex;
+ }
+ }
+ finally
+ {
+ _themes.EndUpdate();
+ }
+ _statusLabel.Text = themes.Count == 0
+ ? "暂无已保存主题。"
+ : $"共 {themes.Count} 个主题,支持静态和动态壁纸。";
+ }
+ catch (OperationCanceledException)
+ {
+ _statusLabel.Text = "读取已取消。";
+ }
+ catch (Exception exception)
+ {
+ ShowError(exception);
+ }
+ finally
+ {
+ if (!IsDisposed)
+ {
+ SetBusy(false);
+ }
+ }
+ }
+
+ private async Task SaveCurrentThemeAsync()
+ {
+ var name = _nameBox.Text.Trim();
+ if (name.Length == 0)
+ {
+ _nameBox.Focus();
+ return;
+ }
+ SetBusy(true);
+ _statusLabel.ForeColor = TextMuted;
+ _statusLabel.Text = "正在保存当前主题…";
+ try
+ {
+ var saved = await _service.SaveCurrentThemeAsync(name, _cancellationToken);
+ _nameBox.Clear();
+ await ReloadThemesAsync(saved.Id);
+ if (!IsDisposed)
+ {
+ _statusLabel.Text = $"已保存:{saved.Name}";
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ _statusLabel.Text = "保存已取消。";
+ }
+ catch (Exception exception)
+ {
+ ShowError(exception);
+ }
+ finally
+ {
+ if (!IsDisposed)
+ {
+ SetBusy(false);
+ }
+ }
+ }
+
+ private async Task ApplySelectedThemeAsync()
+ {
+ if (_themes.SelectedItem is not SavedTheme selectedTheme)
+ {
+ return;
+ }
+ SetBusy(true);
+ _statusLabel.ForeColor = TextMuted;
+ _statusLabel.Text = $"正在应用:{selectedTheme.Name}…";
+ try
+ {
+ LastAppliedTheme = await _service.ApplySavedThemeAsync(selectedTheme.Id, _cancellationToken);
+ _statusLabel.Text = $"已应用:{LastAppliedTheme.Name}";
+ }
+ catch (OperationCanceledException)
+ {
+ _statusLabel.Text = "应用已取消。";
+ }
+ catch (Exception exception)
+ {
+ ShowError(exception);
+ }
+ finally
+ {
+ if (!IsDisposed)
+ {
+ SetBusy(false);
+ }
+ }
+ }
+
+ private void SetBusy(bool busy)
+ {
+ _busy = busy;
+ _themes.Enabled = !busy;
+ _nameBox.Enabled = !busy;
+ UpdateActionState();
+ UseWaitCursor = busy;
+ }
+
+ private void UpdateActionState()
+ {
+ _applyButton.Enabled = !_busy && _themes.SelectedItem is SavedTheme;
+ _saveButton.Enabled = !_busy && !string.IsNullOrWhiteSpace(_nameBox.Text);
+ }
+
+ private void ShowError(Exception exception)
+ {
+ _statusLabel.Text = exception.Message;
+ _statusLabel.ForeColor = Color.FromArgb(246, 128, 128);
+ MessageBox.Show(exception.Message, "Codex 动态壁纸", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+
+ private static void ConfigureButton(Button button, string text, bool primary)
+ {
+ button.Text = text;
+ button.FlatStyle = FlatStyle.Flat;
+ button.FlatAppearance.BorderSize = primary ? 0 : 1;
+ button.FlatAppearance.BorderColor = Color.FromArgb(70, 75, 88);
+ button.BackColor = primary ? Accent : SurfaceRaised;
+ button.ForeColor = primary ? Color.White : TextPrimary;
+ button.Cursor = Cursors.Hand;
+ button.FlatAppearance.MouseOverBackColor = primary ? AccentHover : SurfaceHover;
+ }
+}
diff --git a/windows/app/CodexDreamSkin.Manager/SceneStreamHost.cs b/windows/app/CodexDreamSkin.Manager/SceneStreamHost.cs
new file mode 100644
index 0000000..6462121
--- /dev/null
+++ b/windows/app/CodexDreamSkin.Manager/SceneStreamHost.cs
@@ -0,0 +1,351 @@
+using System.Diagnostics;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace CodexDreamSkin.Manager;
+
+internal sealed class SceneStreamHost : IDisposable
+{
+ private readonly RuntimeProvisioner _runtime;
+ private Process? _process;
+ private string? _streamFile;
+ private bool _disposed;
+
+ public SceneStreamHost(RuntimeProvisioner runtime)
+ {
+ _runtime = runtime;
+ }
+
+ public async Task StartAsync(string scenePath, CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ await StopAsync();
+
+ var fullScenePath = Path.GetFullPath(scenePath);
+ if (!File.Exists(fullScenePath) ||
+ !Path.GetFileName(fullScenePath).Equals("scene.pkg", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException("Wallpaper Engine 场景文件无效。");
+ }
+ var projectDirectory = Path.GetDirectoryName(fullScenePath)
+ ?? throw new InvalidOperationException("Wallpaper Engine 场景目录无效。");
+ var workshopRoot = Directory.GetParent(projectDirectory)?.FullName
+ ?? throw new InvalidOperationException("Wallpaper Engine Workshop 目录无效。");
+ if (!Path.GetFileName(projectDirectory).All(char.IsDigit) ||
+ !workshopRoot.EndsWith(
+ Path.Combine("steamapps", "workshop", "content", "431960"),
+ StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException("场景文件不在受支持的 Wallpaper Engine Workshop 目录中。");
+ }
+
+ var steamApps = Directory.GetParent(
+ Directory.GetParent(
+ Directory.GetParent(workshopRoot)?.FullName
+ ?? string.Empty)?.FullName
+ ?? string.Empty)?.FullName;
+ var assetsPath = string.IsNullOrWhiteSpace(steamApps)
+ ? null
+ : Path.Combine(steamApps, "common", "wallpaper_engine", "assets");
+ if (string.IsNullOrWhiteSpace(assetsPath) || !Directory.Exists(assetsPath))
+ {
+ throw new InvalidOperationException("未找到 Wallpaper Engine assets 目录,请确认 Wallpaper Engine 已安装。");
+ }
+
+ var viewerPath = ResolveViewerPath();
+ var streamDirectory = Path.Combine(_runtime.StateRoot, "scene-stream");
+ Directory.CreateDirectory(streamDirectory);
+ CleanupStaleStreamFiles(streamDirectory);
+ _streamFile = Path.Combine(streamDirectory, $"{Guid.NewGuid():N}.mp4");
+ var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(24)).ToLowerInvariant();
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = viewerPath,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ StandardInputEncoding = new UTF8Encoding(false),
+ StandardOutputEncoding = new UTF8Encoding(false),
+ StandardErrorEncoding = new UTF8Encoding(false),
+ };
+ startInfo.ArgumentList.Add("--stdin-json");
+ startInfo.ArgumentList.Add("--resolution");
+ startInfo.ArgumentList.Add("1280x720");
+ startInfo.ArgumentList.Add("--fps");
+ startInfo.ArgumentList.Add("15");
+ startInfo.ArgumentList.Add(assetsPath);
+ startInfo.ArgumentList.Add(fullScenePath);
+ startInfo.Environment["WP_HEADLESS"] = "1";
+ startInfo.Environment["WP_OFFSCREEN"] = "1";
+ startInfo.Environment["WP_ENCODE_MP4"] = _streamFile;
+ startInfo.Environment["WP_STREAM_PORT"] = "0";
+ startInfo.Environment["WP_STREAM_TOKEN"] = token;
+
+ var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
+ try
+ {
+ if (!process.Start())
+ {
+ throw new InvalidOperationException("场景渲染器未能启动。");
+ }
+ _process = process;
+ var errorLines = new Queue();
+ var errorLock = new object();
+ var errorDrainTask = DrainErrorAsync(process.StandardError, errorLines, errorLock);
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(TimeSpan.FromSeconds(30));
+ var readyPrefix = "scene-viewer: stream ready ";
+ string? streamUrl = null;
+ while (!timeout.IsCancellationRequested && !process.HasExited)
+ {
+ var line = await process.StandardOutput.ReadLineAsync(timeout.Token);
+ if (line is null)
+ {
+ break;
+ }
+ if (line.StartsWith(readyPrefix, StringComparison.Ordinal))
+ {
+ streamUrl = line[readyPrefix.Length..].Trim();
+ break;
+ }
+ }
+ if (streamUrl is null ||
+ !Uri.TryCreate(streamUrl, UriKind.Absolute, out var uri) ||
+ uri.Scheme != Uri.UriSchemeHttp ||
+ !uri.Host.Equals("127.0.0.1", StringComparison.Ordinal) ||
+ uri.Port < 1 ||
+ !uri.AbsolutePath.Equals($"/{token}/stream.mp4", StringComparison.Ordinal))
+ {
+ if (process.HasExited)
+ {
+ await Task.WhenAny(errorDrainTask, Task.Delay(500, CancellationToken.None));
+ }
+ var error = SnapshotErrorLines(errorLines, errorLock);
+ if (string.IsNullOrWhiteSpace(error))
+ {
+ error = "场景渲染器未在 30 秒内提供有效的回环流。";
+ }
+ throw new InvalidOperationException($"场景渲染器启动失败:{error.Trim()}");
+ }
+ _ = DrainAsync(process.StandardOutput);
+ return streamUrl;
+ }
+ catch
+ {
+ await StopAsync();
+ throw;
+ }
+ }
+
+ public async Task StopAsync()
+ {
+ var process = _process;
+ _process = null;
+ if (process is not null)
+ {
+ try
+ {
+ if (!process.HasExited)
+ {
+ await process.StandardInput.WriteLineAsync("{\"command\":\"shutdown\"}");
+ await process.StandardInput.FlushAsync();
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ try
+ {
+ await process.WaitForExitAsync(timeout.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ process.Kill(true);
+ await process.WaitForExitAsync();
+ }
+ }
+ }
+ catch
+ {
+ if (!process.HasExited)
+ {
+ process.Kill(true);
+ await process.WaitForExitAsync();
+ }
+ }
+ finally
+ {
+ process.Dispose();
+ }
+ }
+ var streamFile = _streamFile;
+ _streamFile = null;
+ if (!string.IsNullOrWhiteSpace(streamFile))
+ {
+ await DeleteStreamFileWithRetryAsync(streamFile);
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+ _disposed = true;
+ DisposeProcessImmediately();
+ }
+
+ private string ResolveViewerPath()
+ {
+ var configured = Environment.GetEnvironmentVariable("CODEX_DREAM_SKIN_SCENE_VIEWER");
+ var candidates = new[]
+ {
+ configured,
+ Path.Combine(_runtime.StateRoot, "scene-runtime", "SceneViewer.exe"),
+ Path.Combine(AppContext.BaseDirectory, "scene-runtime", "SceneViewer.exe"),
+ };
+ var viewerPath = candidates.FirstOrDefault(path =>
+ path is not null && !string.IsNullOrWhiteSpace(path) && File.Exists(Path.GetFullPath(path)));
+ return viewerPath is null
+ ? throw new InvalidOperationException(
+ "未安装场景渲染侧车。请将 SceneViewer.exe 放到 " +
+ Path.Combine(_runtime.StateRoot, "scene-runtime") + "。")
+ : Path.GetFullPath(viewerPath);
+ }
+
+ private static async Task DrainAsync(TextReader reader)
+ {
+ try
+ {
+ while (await reader.ReadLineAsync() is not null)
+ {
+ }
+ }
+ catch
+ {
+ // The process owns the pipe; closure during wallpaper switching is expected.
+ }
+ }
+
+ private static async Task DrainErrorAsync(
+ TextReader reader,
+ Queue lines,
+ object gate)
+ {
+ try
+ {
+ while (await reader.ReadLineAsync() is { } line)
+ {
+ lock (gate)
+ {
+ lines.Enqueue(line);
+ while (lines.Count > 40)
+ {
+ lines.Dequeue();
+ }
+ }
+ }
+ }
+ catch
+ {
+ // Process shutdown closes redirected pipes while the drain is pending.
+ }
+ }
+
+ private static string SnapshotErrorLines(Queue lines, object gate)
+ {
+ lock (gate)
+ {
+ return string.Join(Environment.NewLine, lines);
+ }
+ }
+
+ private static void CleanupStaleStreamFiles(string directory)
+ {
+ var cutoff = DateTime.UtcNow - TimeSpan.FromMinutes(10);
+ foreach (var path in Directory.EnumerateFiles(directory, "*.mp4", SearchOption.TopDirectoryOnly))
+ {
+ try
+ {
+ if (File.GetLastWriteTimeUtc(path) < cutoff)
+ {
+ File.Delete(path);
+ }
+ }
+ catch (IOException)
+ {
+ // Another stream host may still own the file; a future start can retry.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Keep startup available even if an old file has restrictive permissions.
+ }
+ }
+ }
+
+ private static async Task DeleteStreamFileWithRetryAsync(string path)
+ {
+ for (var attempt = 0; attempt < 5; attempt += 1)
+ {
+ try
+ {
+ File.Delete(path);
+ return;
+ }
+ catch (IOException)
+ {
+ if (attempt == 4)
+ {
+ return;
+ }
+ await Task.Delay(100 * (attempt + 1));
+ }
+ catch (UnauthorizedAccessException)
+ {
+ if (attempt == 4)
+ {
+ return;
+ }
+ await Task.Delay(100 * (attempt + 1));
+ }
+ }
+ }
+
+ private void DisposeProcessImmediately()
+ {
+ var process = _process;
+ _process = null;
+ if (process is not null)
+ {
+ try
+ {
+ if (!process.HasExited)
+ {
+ process.Kill(entireProcessTree: true);
+ }
+ }
+ catch
+ {
+ // Disposal is best-effort and must not block the WinForms UI thread.
+ }
+ finally
+ {
+ process.Dispose();
+ }
+ }
+
+ var streamFile = _streamFile;
+ _streamFile = null;
+ if (!string.IsNullOrWhiteSpace(streamFile))
+ {
+ try
+ {
+ File.Delete(streamFile);
+ }
+ catch
+ {
+ // Startup cleanup removes any file still held by a closing stream client.
+ }
+ }
+ }
+}
diff --git a/windows/app/CodexDreamSkin.Manager/WallpaperEngineDialog.cs b/windows/app/CodexDreamSkin.Manager/WallpaperEngineDialog.cs
new file mode 100644
index 0000000..7f81838
--- /dev/null
+++ b/windows/app/CodexDreamSkin.Manager/WallpaperEngineDialog.cs
@@ -0,0 +1,211 @@
+namespace CodexDreamSkin.Manager;
+
+internal sealed class WallpaperEngineDialog : Form
+{
+ private static readonly Color Canvas = Color.FromArgb(17, 19, 24);
+ private static readonly Color Surface = Color.FromArgb(25, 28, 35);
+ private static readonly Color SurfaceRaised = Color.FromArgb(34, 38, 47);
+ private static readonly Color SurfaceHover = Color.FromArgb(44, 48, 58);
+ private static readonly Color Accent = Color.FromArgb(225, 105, 131);
+ private static readonly Color AccentHover = Color.FromArgb(240, 124, 149);
+ private static readonly Color TextPrimary = Color.FromArgb(244, 240, 243);
+ private static readonly Color TextMuted = Color.FromArgb(167, 170, 180);
+
+ private readonly DreamSkinService _service;
+ private readonly CancellationToken _cancellationToken;
+ private readonly ListBox _items = new();
+ private readonly Label _statusLabel = new();
+ private readonly Button _importButton = new();
+ private bool _busy;
+
+ public WallpaperEngineDialog(DreamSkinService service, CancellationToken cancellationToken)
+ {
+ _service = service;
+ _cancellationToken = cancellationToken;
+
+ Text = "Wallpaper Engine 本地壁纸";
+ StartPosition = FormStartPosition.CenterParent;
+ FormBorderStyle = FormBorderStyle.FixedDialog;
+ MaximizeBox = false;
+ MinimizeBox = false;
+ ShowInTaskbar = false;
+ ClientSize = new Size(590, 440);
+ BackColor = Canvas;
+ ForeColor = TextPrimary;
+ Font = new Font("Segoe UI Variable Text", 10f, FontStyle.Regular, GraphicsUnit.Point);
+
+ BuildLayout();
+ Shown += async (_, _) => await ReloadItemsAsync();
+ }
+
+ public IReadOnlyList ImportedItems { get; private set; } = Array.Empty();
+
+ private void BuildLayout()
+ {
+ var layout = new TableLayoutPanel
+ {
+ Dock = DockStyle.Fill,
+ ColumnCount = 1,
+ RowCount = 5,
+ Padding = new Padding(20),
+ BackColor = Canvas,
+ };
+ layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 38));
+ layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 42));
+ layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
+ layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 34));
+ layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 46));
+
+ var heading = new Label
+ {
+ Dock = DockStyle.Fill,
+ Text = "Wallpaper Engine 已下载壁纸",
+ Font = new Font(Font.FontFamily, 14f, FontStyle.Bold),
+ ForeColor = TextPrimary,
+ TextAlign = ContentAlignment.MiddleLeft,
+ };
+ layout.Controls.Add(heading, 0, 0);
+
+ var hint = new Label
+ {
+ Dock = DockStyle.Fill,
+ Text = "视频和 Scene 场景各显示一次;Scene 由独立本地渲染侧车播放,Web 暂不支持。",
+ ForeColor = TextMuted,
+ AutoEllipsis = true,
+ TextAlign = ContentAlignment.MiddleLeft,
+ };
+ layout.Controls.Add(hint, 0, 1);
+
+ _items.Dock = DockStyle.Fill;
+ _items.BorderStyle = BorderStyle.FixedSingle;
+ _items.BackColor = Surface;
+ _items.ForeColor = TextPrimary;
+ _items.IntegralHeight = false;
+ _items.SelectionMode = SelectionMode.MultiExtended;
+ _items.AccessibleName = "Wallpaper Engine 本地壁纸列表";
+ _items.AccessibleDescription = "选择已下载的视频或场景壁纸后,将其导入 Codex 壁纸主页。";
+ _items.SelectedIndexChanged += (_, _) => UpdateActionState();
+ layout.Controls.Add(_items, 0, 2);
+
+ _statusLabel.Dock = DockStyle.Fill;
+ _statusLabel.ForeColor = TextMuted;
+ _statusLabel.Text = "正在扫描本机 Steam 库…";
+ _statusLabel.TextAlign = ContentAlignment.MiddleLeft;
+ _statusLabel.AutoEllipsis = true;
+ layout.Controls.Add(_statusLabel, 0, 3);
+
+ var actions = new FlowLayoutPanel
+ {
+ Dock = DockStyle.Fill,
+ FlowDirection = FlowDirection.RightToLeft,
+ WrapContents = false,
+ BackColor = Canvas,
+ Padding = new Padding(0, 4, 0, 0),
+ };
+ var closeButton = new Button { DialogResult = DialogResult.Cancel, Width = 92, Height = 34 };
+ ConfigureButton(closeButton, "关闭", false);
+ closeButton.AccessibleName = "关闭 Wallpaper Engine 本地壁纸窗口";
+ ConfigureButton(_importButton, "导入到主页", true);
+ _importButton.Width = 112;
+ _importButton.Height = 34;
+ _importButton.AccessibleName = "将选中的 Wallpaper Engine 壁纸导入主页";
+ _importButton.Click += (_, _) => ImportSelected();
+ actions.Controls.Add(closeButton);
+ actions.Controls.Add(_importButton);
+ layout.Controls.Add(actions, 0, 4);
+
+ CancelButton = closeButton;
+ Controls.Add(layout);
+ UpdateActionState();
+ }
+
+ private async Task ReloadItemsAsync()
+ {
+ SetBusy(true);
+ _statusLabel.ForeColor = TextMuted;
+ _statusLabel.Text = "正在扫描本机 Steam 库…";
+ try
+ {
+ var items = await _service.ListWallpaperEngineAsync(_cancellationToken);
+ if (IsDisposed)
+ {
+ return;
+ }
+ _items.BeginUpdate();
+ try
+ {
+ _items.Items.Clear();
+ foreach (var item in items)
+ {
+ _items.Items.Add(item);
+ }
+ }
+ finally
+ {
+ _items.EndUpdate();
+ }
+ _statusLabel.Text = items.Count == 0
+ ? "未找到兼容的视频或 Scene 场景。请先在 Wallpaper Engine 中完成订阅和下载。"
+ : $"找到 {items.Count} 个兼容壁纸;每个 Workshop 项目仅显示一次。";
+ }
+ catch (OperationCanceledException)
+ {
+ _statusLabel.Text = "扫描已取消。";
+ }
+ catch (Exception exception)
+ {
+ ShowError(exception);
+ }
+ finally
+ {
+ if (!IsDisposed)
+ {
+ SetBusy(false);
+ }
+ }
+ }
+
+ private void ImportSelected()
+ {
+ var selected = _items.SelectedItems.Cast().ToArray();
+ if (selected.Length == 0)
+ {
+ return;
+ }
+ ImportedItems = selected;
+ DialogResult = DialogResult.OK;
+ Close();
+ }
+
+ private void SetBusy(bool busy)
+ {
+ _busy = busy;
+ _items.Enabled = !busy;
+ UpdateActionState();
+ UseWaitCursor = busy;
+ }
+
+ private void UpdateActionState()
+ {
+ _importButton.Enabled = !_busy && _items.SelectedItems.Count > 0;
+ }
+
+ private void ShowError(Exception exception)
+ {
+ _statusLabel.Text = exception.Message;
+ _statusLabel.ForeColor = Color.FromArgb(246, 128, 128);
+ MessageBox.Show(exception.Message, "Wallpaper Engine 本地壁纸", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+
+ private static void ConfigureButton(Button button, string text, bool primary)
+ {
+ button.Text = text;
+ button.FlatStyle = FlatStyle.Flat;
+ button.FlatAppearance.BorderSize = primary ? 0 : 1;
+ button.FlatAppearance.BorderColor = Color.FromArgb(70, 75, 88);
+ button.BackColor = primary ? Accent : SurfaceRaised;
+ button.ForeColor = primary ? Color.White : TextPrimary;
+ button.Cursor = Cursors.Hand;
+ button.FlatAppearance.MouseOverBackColor = primary ? AccentHover : SurfaceHover;
+ }
+}
diff --git a/windows/app/CodexDreamSkin.Manager/WallpaperEngineReferenceCatalog.cs b/windows/app/CodexDreamSkin.Manager/WallpaperEngineReferenceCatalog.cs
new file mode 100644
index 0000000..271f89a
--- /dev/null
+++ b/windows/app/CodexDreamSkin.Manager/WallpaperEngineReferenceCatalog.cs
@@ -0,0 +1,165 @@
+namespace CodexDreamSkin.Manager;
+
+internal static class WallpaperEngineReferenceCatalog
+{
+ private static readonly string WorkshopContentSuffix = Path.Combine(
+ "steamapps", "workshop", "content", "431960");
+
+ public static int AddImports(
+ IList imports,
+ IEnumerable items)
+ {
+ var added = 0;
+ foreach (var item in items)
+ {
+ if (imports.Any(import => SameReference(import, item)))
+ {
+ continue;
+ }
+ imports.Add(new WallpaperEngineReference
+ {
+ WorkshopId = item.WorkshopId,
+ WorkshopRoot = item.WorkshopRoot,
+ RelativePath = item.RelativePath,
+ Name = item.Name,
+ });
+ added++;
+ }
+ return added;
+ }
+
+ public static IReadOnlyList ResolveAndPrune(
+ IList imports,
+ string? search,
+ out bool changed)
+ {
+ changed = false;
+ var normalizedSearch = search?.Trim();
+ var resolved = new List();
+ for (var index = imports.Count - 1; index >= 0; index--)
+ {
+ if (!TryResolve(imports[index], out var item))
+ {
+ imports.RemoveAt(index);
+ changed = true;
+ continue;
+ }
+ if (string.IsNullOrWhiteSpace(normalizedSearch) ||
+ item.Name.Contains(normalizedSearch, StringComparison.CurrentCultureIgnoreCase) ||
+ item.Path.Contains(normalizedSearch, StringComparison.CurrentCultureIgnoreCase))
+ {
+ resolved.Add(item);
+ }
+ }
+ return resolved
+ .OrderByDescending(item => item.LastWriteTime)
+ .ThenBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase)
+ .ToArray();
+ }
+
+ public static string GetPreviewPath(WallpaperItem item)
+ {
+ if (item.Kind != WallpaperKind.Scene)
+ {
+ return item.Path;
+ }
+ try
+ {
+ var directory = Path.GetDirectoryName(item.Path);
+ if (directory is null)
+ {
+ return item.Path;
+ }
+ var supported = new HashSet(
+ new[] { ".jpg", ".jpeg", ".png", ".webp", ".gif" },
+ StringComparer.OrdinalIgnoreCase);
+ var preview = Directory.EnumerateFiles(directory, "preview.*", SearchOption.TopDirectoryOnly)
+ .FirstOrDefault(path => supported.Contains(Path.GetExtension(path)));
+ return preview ?? item.Path;
+ }
+ catch (IOException)
+ {
+ return item.Path;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return item.Path;
+ }
+ }
+
+ private static bool SameReference(WallpaperEngineReference import, WallpaperEngineItem item) =>
+ import.WorkshopId.Equals(item.WorkshopId, StringComparison.Ordinal) &&
+ import.WorkshopRoot.Equals(item.WorkshopRoot, StringComparison.OrdinalIgnoreCase) &&
+ import.RelativePath.Equals(item.RelativePath, StringComparison.OrdinalIgnoreCase);
+
+ private static bool TryResolve(
+ WallpaperEngineReference import,
+ out WallpaperItem item)
+ {
+ item = null!;
+ try
+ {
+ if (!IsSafeReference(import))
+ {
+ return false;
+ }
+ var workshopRoot = Path.GetFullPath(import.WorkshopRoot).TrimEnd(Path.DirectorySeparatorChar);
+ var workshopDirectory = Path.Combine(workshopRoot, import.WorkshopId);
+ var mediaPath = Path.GetFullPath(Path.Combine(workshopDirectory, import.RelativePath));
+ var isScene = Path.GetFileName(mediaPath).Equals("scene.pkg", StringComparison.OrdinalIgnoreCase);
+ if (!IsInside(workshopRoot, workshopDirectory) || !IsInside(workshopDirectory, mediaPath) ||
+ !File.Exists(mediaPath) || (File.GetAttributes(mediaPath) & FileAttributes.ReparsePoint) != 0 ||
+ (!isScene &&
+ (!WallpaperCatalog.IsSupported(mediaPath) ||
+ WallpaperCatalog.GetKind(mediaPath) != WallpaperKind.Video)))
+ {
+ return false;
+ }
+ var name = IsSafeName(import.Name)
+ ? import.Name.Trim()
+ : Path.GetFileNameWithoutExtension(mediaPath);
+ var info = new FileInfo(mediaPath);
+ item = new WallpaperItem(
+ info.FullName,
+ name,
+ info.Extension.ToUpperInvariant(),
+ isScene ? WallpaperKind.Scene : WallpaperKind.Video,
+ info.Length,
+ info.LastWriteTime,
+ WallpaperSource.WallpaperEngine,
+ import);
+ return true;
+ }
+ catch (IOException)
+ {
+ return false;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return false;
+ }
+ }
+
+ private static bool IsSafeReference(WallpaperEngineReference import)
+ {
+ if (string.IsNullOrWhiteSpace(import.WorkshopRoot) || string.IsNullOrWhiteSpace(import.WorkshopId) ||
+ string.IsNullOrWhiteSpace(import.RelativePath) || !Path.IsPathRooted(import.WorkshopRoot) ||
+ Path.IsPathRooted(import.RelativePath) || !System.Text.RegularExpressions.Regex.IsMatch(import.WorkshopId, "^\\d{1,20}$"))
+ {
+ return false;
+ }
+ var normalizedRoot = Path.GetFullPath(import.WorkshopRoot).TrimEnd(Path.DirectorySeparatorChar);
+ return normalizedRoot.EndsWith(WorkshopContentSuffix, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsSafeName(string? value) => !string.IsNullOrWhiteSpace(value) &&
+ value.Trim().Length <= 120 && !value.Any(char.IsControl);
+
+ private static bool IsInside(string root, string candidate)
+ {
+ var relative = Path.GetRelativePath(root, candidate);
+ return !string.IsNullOrWhiteSpace(relative) && !Path.IsPathRooted(relative) &&
+ !relative.Equals("..", StringComparison.Ordinal) &&
+ !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal);
+ }
+}
diff --git a/windows/app/build-manager.ps1 b/windows/app/build-manager.ps1
index d3b5ac9..cbf056d 100644
--- a/windows/app/build-manager.ps1
+++ b/windows/app/build-manager.ps1
@@ -16,10 +16,23 @@ $project = Join-Path $PSScriptRoot 'CodexDreamSkin.Manager\CodexDreamSkin.Manage
function Get-ManagerDotNet {
$installed = Get-Command dotnet.exe -ErrorAction SilentlyContinue
+ $candidates = @()
if ($null -ne $installed) {
- $sdks = & $installed.Source --list-sdks
+ $candidates += $installed.Source
+ }
+
+ foreach ($programFiles in @(
+ [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFiles),
+ [System.Environment]::GetEnvironmentVariable('ProgramFiles(x86)')
+ ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) {
+ $candidates += Join-Path $programFiles 'dotnet\dotnet.exe'
+ }
+
+ foreach ($candidate in $candidates | Select-Object -Unique) {
+ if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue }
+ $sdks = & $candidate --list-sdks
if (@($sdks | Where-Object { $_ -match '^8\.' }).Count -gt 0) {
- return $installed.Source
+ return $candidate
}
}
@@ -67,6 +80,13 @@ if (-not $SkipWindowsTests) {
$staging = Join-Path $env:TEMP ('codex-dream-skin-manager-publish-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $staging -Force | Out-Null
try {
+ & $dotnet clean $project `
+ --configuration Release `
+ --runtime win-x64 `
+ "-p:NodeExe=$($node.Path)" `
+ "-p:NodeLicense=$nodeLicense"
+ if ($LASTEXITCODE -ne 0) { throw '.NET clean failed.' }
+
& $dotnet restore $project `
"-p:NodeExe=$($node.Path)" `
"-p:NodeLicense=$nodeLicense"
diff --git a/windows/assets/dream-skin.css b/windows/assets/dream-skin.css
index d3bad4f..2c7ec66 100644
--- a/windows/assets/dream-skin.css
+++ b/windows/assets/dream-skin.css
@@ -35,8 +35,14 @@
--dream-task-immersive-edge: color-mix(in oklab, var(--dream-task-immersive-edge-base) var(--dream-wallpaper-cover), transparent);
--dream-task-immersive-mid: color-mix(in oklab, var(--dream-task-immersive-mid-base) var(--dream-wallpaper-cover), transparent);
--dream-task-immersive-far: color-mix(in oklab, var(--dream-task-immersive-far-base) var(--dream-wallpaper-cover), transparent);
- --dream-immersive-composer: color-mix(in oklab, var(--dream-surface-raised) 76%, var(--dream-accent) 4%);
- --dream-immersive-line: color-mix(in oklab, var(--dream-line) 76%, transparent);
+ --dream-immersive-composer-base: color-mix(in oklab, var(--dream-surface-raised) 76%, var(--dream-accent) 4%);
+ --dream-immersive-control-base: color-mix(in oklab, var(--dream-surface-raised) 58%, transparent);
+ --dream-immersive-line-base: color-mix(in oklab, var(--dream-line) 76%, transparent);
+ --dream-immersive-shadow-base: color-mix(in oklab, var(--dream-canvas) 20%, transparent);
+ --dream-immersive-composer: color-mix(in oklab, var(--dream-immersive-composer-base) var(--dream-wallpaper-cover), transparent);
+ --dream-immersive-control: color-mix(in oklab, var(--dream-immersive-control-base) var(--dream-wallpaper-cover), transparent);
+ --dream-immersive-line: color-mix(in oklab, var(--dream-immersive-line-base) var(--dream-wallpaper-cover), transparent);
+ --dream-immersive-shadow: color-mix(in oklab, var(--dream-immersive-shadow-base) var(--dream-wallpaper-cover), transparent);
}
:root.codex-dream-skin.dream-theme-dark {
@@ -62,8 +68,8 @@
--dream-task-immersive-edge-base: color-mix(in oklab, var(--dream-surface) 82%, transparent);
--dream-task-immersive-mid-base: color-mix(in oklab, var(--dream-surface) 74%, transparent);
--dream-task-immersive-far-base: color-mix(in oklab, var(--dream-surface) 60%, transparent);
- --dream-immersive-composer: color-mix(in oklab, var(--dream-surface-raised) 88%, var(--dream-accent) 5%);
- --dream-immersive-line: color-mix(in oklab, var(--dream-line) 82%, transparent);
+ --dream-immersive-composer-base: color-mix(in oklab, var(--dream-surface-raised) 88%, var(--dream-accent) 5%);
+ --dream-immersive-line-base: color-mix(in oklab, var(--dream-line) 82%, transparent);
}
html.codex-dream-skin body {
@@ -133,15 +139,14 @@ html.codex-dream-skin [class~="group/application-menu-top-bar"] {
background: color-mix(in oklab,
color-mix(in oklab, var(--dream-sidebar) 88%, transparent)
var(--dream-wallpaper-cover), transparent) !important;
- border-bottom: 1px solid var(--dream-line-soft) !important;
- box-shadow: 0 1px 12px color-mix(in oklab, var(--dream-canvas) 72%, transparent) !important;
+ border-bottom: 1px solid var(--dream-immersive-line) !important;
+ box-shadow: 0 1px 12px var(--dream-immersive-shadow) !important;
backdrop-filter: blur(18px) saturate(.88) !important;
}
html.codex-dream-skin.dream-art-video [class~="group/application-menu-top-bar"] {
- background: color-mix(in oklab,
- color-mix(in oklab, var(--dream-sidebar) 94%, transparent)
- var(--dream-wallpaper-cover), transparent) !important;
+ background: var(--dream-immersive-composer) !important;
+ backdrop-filter: none !important;
}
html.codex-dream-skin [class~="group/application-menu-top-bar"] button,
@@ -445,8 +450,8 @@ html.codex-dream-skin article {
/* A 16:9 or wider image is painted once on the native window. The sidebar,
title bar and route surface become coordinated readability layers instead
of repeating or cropping the image inside individual panels. */
-html.codex-dream-skin.dream-art-wide:has(main.main-surface.dream-home-shell) body,
-html.codex-dream-skin.dream-art-wide:is(.dream-task-ambient, .dream-task-banner):has(main.main-surface:not(.dream-home-shell)) body {
+html.codex-dream-skin.dream-art-wide.dream-route-home body,
+html.codex-dream-skin.dream-art-wide.dream-route-task:is(.dream-task-ambient, .dream-task-banner) body {
background-color: var(--dream-canvas) !important;
background-image:
linear-gradient(var(--dream-media-overlay), var(--dream-media-overlay)),
@@ -457,9 +462,9 @@ html.codex-dream-skin.dream-art-wide:is(.dream-task-ambient, .dream-task-banner)
background-attachment: fixed !important;
}
-html.codex-dream-skin.dream-art-wide:has(main.main-surface.dream-home-shell)
+html.codex-dream-skin.dream-art-wide.dream-route-home
aside.app-shell-left-panel,
-html.codex-dream-skin.dream-art-wide:is(.dream-task-ambient, .dream-task-banner):has(main.main-surface:not(.dream-home-shell))
+html.codex-dream-skin.dream-art-wide.dream-route-task:is(.dream-task-ambient, .dream-task-banner)
aside.app-shell-left-panel {
background: linear-gradient(90deg,
var(--dream-immersive-sidebar),
@@ -470,16 +475,16 @@ html.codex-dream-skin.dream-art-wide:is(.dream-task-ambient, .dream-task-banner)
backdrop-filter: none !important;
}
-html.codex-dream-skin.dream-art-wide:is(.dream-task-ambient, .dream-task-banner):has(main.main-surface:not(.dream-home-shell))
+html.codex-dream-skin.dream-art-wide.dream-route-task:is(.dream-task-ambient, .dream-task-banner)
aside.app-shell-left-panel {
background: linear-gradient(90deg,
var(--dream-task-immersive-sidebar),
var(--dream-task-immersive-edge)) !important;
}
-html.codex-dream-skin.dream-art-wide:has(main.main-surface.dream-home-shell)
+html.codex-dream-skin.dream-art-wide.dream-route-home
aside.app-shell-left-panel::after,
-html.codex-dream-skin.dream-art-wide:is(.dream-task-ambient, .dream-task-banner):has(main.main-surface:not(.dream-home-shell))
+html.codex-dream-skin.dream-art-wide.dream-route-task:is(.dream-task-ambient, .dream-task-banner)
aside.app-shell-left-panel::after {
background: transparent !important;
}
@@ -490,6 +495,9 @@ html.codex-dream-skin.dream-art-wide:is(.dream-task-ambient, .dream-task-banner)
var(--dream-immersive-edge),
var(--dream-immersive-mid) 64%,
var(--dream-immersive-far)) !important;
+ background-position: 0 var(--height-token-toolbar, 46px) !important;
+ background-size: 100% calc(100% - var(--height-token-toolbar, 46px)) !important;
+ background-repeat: no-repeat !important;
border: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
@@ -500,6 +508,9 @@ html.codex-dream-skin.dream-art-wide:is(.dream-task-ambient, .dream-task-banner)
var(--dream-task-immersive-edge),
var(--dream-task-immersive-mid) 64%,
var(--dream-task-immersive-far)) !important;
+ background-position: 0 var(--height-token-toolbar, 46px) !important;
+ background-size: 100% calc(100% - var(--height-token-toolbar, 46px)) !important;
+ background-repeat: no-repeat !important;
}
html.codex-dream-skin.dream-art-wide:is(.dream-task-ambient, .dream-task-banner) .dream-task {
@@ -523,17 +534,14 @@ html.codex-dream-skin.dream-art-wide main.main-surface > header.app-header-tint
filter: drop-shadow(0 1px 2px color-mix(in oklab, var(--dream-canvas) 72%, transparent));
}
-/* Unlike a static image, a video can invalidate foreground contrast on every
- frame. Keep the secondary Codex toolbar translucent but independently
- readable instead of relying on text shadows over the moving media. */
+/* Keep both Codex toolbar rows on the same compositing plane. The route veil
+ starts below this fixed header, so the toolbar never receives a double tint. */
html.codex-dream-skin.dream-art-video main.main-surface > header.app-header-tint {
color: var(--dream-text) !important;
- background: color-mix(in oklab,
- color-mix(in oklab, var(--dream-surface) 86%, transparent)
- var(--dream-wallpaper-cover), transparent) !important;
- border-bottom: 1px solid var(--dream-line-soft) !important;
- box-shadow: 0 1px 12px color-mix(in oklab, var(--dream-canvas) 44%, transparent) !important;
- backdrop-filter: blur(16px) saturate(.9) !important;
+ background: var(--dream-immersive-composer) !important;
+ border-bottom: 1px solid var(--dream-immersive-line) !important;
+ box-shadow: 0 1px 10px var(--dream-immersive-shadow) !important;
+ backdrop-filter: none !important;
text-shadow: 0 1px 2px color-mix(in oklab, var(--dream-canvas) 86%, transparent),
0 0 8px color-mix(in oklab, var(--dream-canvas) 58%, transparent) !important;
}
@@ -562,8 +570,8 @@ html.codex-dream-skin.dream-art-wide main.main-surface.dream-home-shell
html.codex-dream-skin.dream-art-wide main.main-surface.dream-home-shell
.dream-home .group\/home-suggestions button {
- background: color-mix(in oklab, var(--dream-surface-raised) 58%, transparent) !important;
- box-shadow: 0 8px 22px color-mix(in oklab, var(--dream-canvas) 18%, transparent),
+ background: var(--dream-immersive-control) !important;
+ box-shadow: 0 8px 22px var(--dream-immersive-shadow),
inset 0 0 0 1px var(--dream-immersive-line) !important;
backdrop-filter: blur(8px) saturate(1.04) !important;
}
@@ -574,7 +582,7 @@ html.codex-dream-skin.dream-art-wide .composer-surface-chrome {
color: var(--dream-text) !important;
background: var(--dream-immersive-composer) !important;
border: 0 !important;
- box-shadow: 0 10px 30px color-mix(in oklab, var(--dream-canvas) 20%, transparent),
+ box-shadow: 0 10px 30px var(--dream-immersive-shadow),
inset 0 0 0 1px var(--dream-immersive-line) !important;
backdrop-filter: none !important;
}
@@ -586,16 +594,16 @@ html.codex-dream-skin.dream-art-wide .composer-surface-chrome::before {
/* Current Codex builds render the home project picker in a hashed utility bar.
Merge it with the composer so the bottom control reads as one surface. */
html.codex-dream-skin .dream-home-utility {
- border: 1px solid var(--dream-line) !important;
+ border: 1px solid var(--dream-immersive-line) !important;
border-bottom: 0 !important;
border-radius: 18px 18px 0 0 !important;
background: var(--dream-immersive-composer) !important;
box-shadow: none !important;
}
-html.codex-dream-skin .dream-home:has(.dream-home-utility) .composer-surface-chrome {
+html.codex-dream-skin.dream-home-utility-present .dream-home .composer-surface-chrome {
border-radius: 0 0 18px 18px !important;
- box-shadow: 0 10px 30px color-mix(in oklab, var(--dream-canvas) 20%, transparent),
+ box-shadow: 0 10px 30px var(--dream-immersive-shadow),
inset 1px 0 var(--dream-immersive-line),
inset -1px 0 var(--dream-immersive-line),
inset 0 -1px var(--dream-immersive-line) !important;
@@ -605,14 +613,14 @@ html.codex-dream-skin .dream-home:has(.dream-home-utility) .composer-surface-chr
Clear only full-window or sticky route chrome, leaving functional cards and
their hover states intact. */
html.codex-dream-skin.dream-art-wide:is(.dream-task-ambient, .dream-task-banner)
- main.main-surface:not(.dream-home-shell) div.sticky:has(input[type="text"]),
+ main.main-surface:not(.dream-home-shell) .dream-task-search-sticky,
html.codex-dream-skin.dream-art-wide:is(.dream-task-ambient, .dream-task-banner)
- main.main-surface:not(.dream-home-shell) div.sticky:has(input[type="text"])::after {
+ main.main-surface:not(.dream-home-shell) .dream-task-search-sticky::after {
background: transparent !important;
}
html.codex-dream-skin.dream-art-wide:is(.dream-task-ambient, .dream-task-banner)
- main.main-surface:not(.dream-home-shell) div.no-drag:has(> input[type="text"]) {
+ main.main-surface:not(.dream-home-shell) .dream-task-search-input {
background: var(--dream-immersive-composer) !important;
border: 0 !important;
box-shadow: inset 0 0 0 1px var(--dream-immersive-line) !important;
diff --git a/windows/assets/renderer-inject.js b/windows/assets/renderer-inject.js
index bd15d17..6223102 100644
--- a/windows/assets/renderer-inject.js
+++ b/windows/assets/renderer-inject.js
@@ -20,6 +20,9 @@
"dream-task-ambient",
"dream-task-banner",
"dream-task-off",
+ "dream-route-home",
+ "dream-route-task",
+ "dream-home-utility-present",
];
const ROOT_PROPERTIES = [
"--dream-art",
@@ -33,12 +36,27 @@
"--dream-wallpaper-cover",
];
const HOME_UTILITY_CLASS = "dream-home-utility";
+ const TASK_SEARCH_STICKY_CLASS = "dream-task-search-sticky";
+ const TASK_SEARCH_INPUT_CLASS = "dream-task-search-input";
const installToken = {};
let samplingNativeShell = false;
let observer = null;
+ let appearanceObserver = null;
window.__CODEX_DREAM_SKIN_DISABLED__ = false;
const clamp = (value, min = 0, max = 1) => Math.min(max, Math.max(min, Number(value)));
+ const setStylePropertyIfChanged = (style, property, value) => {
+ if (!style) return false;
+ const current = style.getPropertyValue?.(property)?.trim?.() || "";
+ if (current === value) return false;
+ style.setProperty(property, value);
+ return true;
+ };
+ const setClassEnabled = (classList, className, enabled) => {
+ if (!classList || classList.contains(className) === enabled) return false;
+ classList.toggle(className, enabled);
+ return true;
+ };
const luminance = (red, green, blue) => {
const linear = [red, green, blue].map((value) => {
const channel = value / 255;
@@ -83,6 +101,22 @@
const mediaSize = Number.isSafeInteger(Number(media.size)) && Number(media.size) > 0
? Number(media.size)
: null;
+ const streamUrl = (() => {
+ if (typeof media.streamUrl !== "string" || !media.streamUrl) return null;
+ try {
+ const parsed = new URL(media.streamUrl);
+ const loopback = ["127.0.0.1", "localhost", "[::1]", "::1"].includes(parsed.hostname);
+ const safePath = /^\/[A-Za-z0-9_-]{16,128}\/stream\.mp4$/.test(parsed.pathname);
+ if (parsed.protocol !== "http:" || !loopback || !parsed.port || parsed.username ||
+ parsed.password || parsed.search || parsed.hash || !safePath) return null;
+ return parsed.href;
+ } catch {
+ return null;
+ }
+ })();
+ const streamCodec = streamUrl && media.codec === "avc1.42c01f"
+ ? media.codec
+ : streamUrl ? "avc1.42c01f" : null;
const playbackRate = Number.isFinite(Number(media.playbackRate))
? clamp(Number(media.playbackRate), .25, 2)
: 1;
@@ -101,6 +135,8 @@
mediaType,
mediaMime,
mediaSize,
+ streamUrl,
+ streamCodec,
playbackRate,
wallpaperReveal,
};
@@ -108,8 +144,10 @@
const previous = window[STATE_KEY];
if (previous?.observer) previous.observer.disconnect();
+ if (previous?.appearanceObserver) previous.appearanceObserver.disconnect();
if (previous?.timer) clearInterval(previous.timer);
if (previous?.scheduler?.timeout) clearTimeout(previous.scheduler.timeout);
+ previous?.stopStream?.();
if (previous?.artUrl) URL.revokeObjectURL(previous.artUrl);
if (previous?.mediaUrl) URL.revokeObjectURL(previous.mediaUrl);
if (previous?.visibilityHandler) {
@@ -129,6 +167,7 @@
const config = normalizeConfig(rawConfig);
let mediaUrl = null;
let mediaTransfer = null;
+ let streamController = null;
let profile = {
...defaultProfile,
aspect: config.initialAspect ?? (config.mediaType === "video" ? 16 / 9 : defaultProfile.aspect),
@@ -320,6 +359,8 @@
document.querySelectorAll(".dream-task").forEach((node) => node.classList.remove("dream-task"));
document.querySelectorAll(".dream-home-shell").forEach((node) => node.classList.remove("dream-home-shell"));
document.querySelectorAll(`.${HOME_UTILITY_CLASS}`).forEach((node) => node.classList.remove(HOME_UTILITY_CLASS));
+ document.querySelectorAll(`.${TASK_SEARCH_STICKY_CLASS}`).forEach((node) => node.classList.remove(TASK_SEARCH_STICKY_CLASS));
+ document.querySelectorAll(`.${TASK_SEARCH_INPUT_CLASS}`).forEach((node) => node.classList.remove(TASK_SEARCH_INPUT_CLASS));
document.getElementById(STYLE_ID)?.remove();
document.getElementById(CHROME_ID)?.remove();
document.getElementById(MEDIA_ID)?.remove();
@@ -337,29 +378,29 @@
: config.taskMode;
const accent = config.accent || `rgb(${profile.accent.join(" ")})`;
const accentInk = luminance(...profile.accent) > .42 ? "rgb(26 24 28)" : "rgb(250 248 251)";
- root.classList.toggle("dream-theme-light", appearance === "light");
- root.classList.toggle("dream-theme-dark", appearance === "dark");
- root.classList.toggle("dream-art-video", config.mediaType === "video");
- root.classList.toggle("dream-art-wide", profile.aspect >= 1.75);
- root.classList.toggle("dream-art-standard", profile.aspect < 1.75);
+ setClassEnabled(root.classList, "dream-theme-light", appearance === "light");
+ setClassEnabled(root.classList, "dream-theme-dark", appearance === "dark");
+ setClassEnabled(root.classList, "dream-art-video", config.mediaType === "video");
+ setClassEnabled(root.classList, "dream-art-wide", profile.aspect >= 1.75);
+ setClassEnabled(root.classList, "dream-art-standard", profile.aspect < 1.75);
for (const value of ["left", "center", "right"]) {
- root.classList.toggle(`dream-focus-${value}`, focus === value);
+ setClassEnabled(root.classList, `dream-focus-${value}`, focus === value);
}
for (const value of ["left", "center", "right", "none"]) {
- root.classList.toggle(`dream-safe-${value}`, safeArea === value);
+ setClassEnabled(root.classList, `dream-safe-${value}`, safeArea === value);
}
for (const value of ["ambient", "banner", "off"]) {
- root.classList.toggle(`dream-task-${value}`, taskMode === value);
+ setClassEnabled(root.classList, `dream-task-${value}`, taskMode === value);
}
- root.style.setProperty("--dream-art", artUrl ? `url("${artUrl}")` : "none");
- root.style.setProperty("--dream-art-position", `${Math.round(focusX * 100)}% ${Math.round(focusY * 100)}%`);
- root.style.setProperty("--dream-focus-x", String(focusX));
- root.style.setProperty("--dream-focus-y", String(focusY));
- root.style.setProperty("--dream-accent", accent);
- root.style.setProperty("--dream-accent-ink", accentInk);
- root.style.setProperty("--dream-image-luma", profile.luma.toFixed(3));
- root.style.setProperty("--dream-wallpaper-reveal", config.wallpaperReveal.toFixed(2));
- root.style.setProperty("--dream-wallpaper-cover", `${Math.round((1 - config.wallpaperReveal) * 100)}%`);
+ setStylePropertyIfChanged(root.style, "--dream-art", artUrl ? `url("${artUrl}")` : "none");
+ setStylePropertyIfChanged(root.style, "--dream-art-position", `${Math.round(focusX * 100)}% ${Math.round(focusY * 100)}%`);
+ setStylePropertyIfChanged(root.style, "--dream-focus-x", String(focusX));
+ setStylePropertyIfChanged(root.style, "--dream-focus-y", String(focusY));
+ setStylePropertyIfChanged(root.style, "--dream-accent", accent);
+ setStylePropertyIfChanged(root.style, "--dream-accent-ink", accentInk);
+ setStylePropertyIfChanged(root.style, "--dream-image-luma", profile.luma.toFixed(3));
+ setStylePropertyIfChanged(root.style, "--dream-wallpaper-reveal", config.wallpaperReveal.toFixed(2));
+ setStylePropertyIfChanged(root.style, "--dream-wallpaper-cover", `${Math.round((1 - config.wallpaperReveal) * 100)}%`);
};
const setWallpaperReveal = (value) => {
@@ -367,8 +408,10 @@
if (!Number.isFinite(numeric) || numeric < 0 || numeric > 1) return null;
config.wallpaperReveal = numeric;
const root = document.documentElement;
- root?.style.setProperty("--dream-wallpaper-reveal", numeric.toFixed(2));
- root?.style.setProperty("--dream-wallpaper-cover", `${Math.round((1 - numeric) * 100)}%`);
+ setStylePropertyIfChanged(root?.style, "--dream-wallpaper-reveal", numeric.toFixed(2));
+ setStylePropertyIfChanged(root?.style, "--dream-wallpaper-cover", `${Math.round((1 - numeric) * 100)}%`);
+ syncMediaElement();
+ syncStreamState();
return numeric;
};
@@ -393,26 +436,161 @@
media.preload = "auto";
document.body.appendChild(media);
}
- media.defaultMuted = true;
- media.muted = true;
- media.loop = true;
- media.playbackRate = config.playbackRate;
+ if (!media.defaultMuted) media.defaultMuted = true;
+ if (!media.muted) media.muted = true;
+ const shouldLoop = !config.streamUrl;
+ if (media.loop !== shouldLoop) media.loop = shouldLoop;
+ if (media.playbackRate !== config.playbackRate) media.playbackRate = config.playbackRate;
if (mediaUrl && media.src !== mediaUrl) {
media.src = mediaUrl;
media.load?.();
}
- if (!mediaUrl || document.hidden) {
- media.pause?.();
- } else {
+ if (!mediaUrl || document.hidden || config.wallpaperReveal <= 0) {
+ if (!media.paused) media.pause?.();
+ } else if (media.paused) {
const playback = media.play?.();
playback?.catch?.(() => {});
}
return media;
};
+ const waitForSourceBuffer = (sourceBuffer) => {
+ if (!sourceBuffer?.updating) return Promise.resolve();
+ return new Promise((resolve, reject) => {
+ const done = () => {
+ sourceBuffer.removeEventListener?.("error", failed);
+ resolve();
+ };
+ const failed = () => {
+ sourceBuffer.removeEventListener?.("updateend", done);
+ reject(new Error("Media stream append failed"));
+ };
+ sourceBuffer.addEventListener?.("updateend", done, { once: true });
+ sourceBuffer.addEventListener?.("error", failed, { once: true });
+ });
+ };
+
+ const stopStream = () => {
+ const controller = streamController;
+ streamController = null;
+ if (controller) {
+ controller.abort.abort();
+ controller.reader?.cancel?.().catch?.(() => {});
+ }
+ const media = document.getElementById(MEDIA_ID);
+ media?.pause?.();
+ media?.removeAttribute?.("src");
+ media?.load?.();
+ if (mediaUrl) URL.revokeObjectURL(mediaUrl);
+ mediaUrl = null;
+ const state = window[STATE_KEY];
+ if (state?.installToken === installToken) state.mediaUrl = null;
+ return Boolean(controller);
+ };
+
+ const startStream = async () => {
+ if (config.mediaType !== "video" || !config.streamUrl || typeof MediaSource !== "function" ||
+ typeof fetch !== "function" || typeof AbortController !== "function") return false;
+ const mime = `video/mp4; codecs="${config.streamCodec}"`;
+ if (typeof MediaSource.isTypeSupported === "function" && !MediaSource.isTypeSupported(mime)) {
+ return false;
+ }
+ stopStream();
+ const controller = {
+ abort: new AbortController(),
+ reader: null,
+ mediaSource: new MediaSource(),
+ stats: {
+ status: "connecting",
+ chunks: 0,
+ bytes: 0,
+ startedAt: Date.now(),
+ lastAppendAt: null,
+ error: null,
+ },
+ };
+ streamController = controller;
+ if (mediaUrl) URL.revokeObjectURL(mediaUrl);
+ mediaUrl = URL.createObjectURL(controller.mediaSource);
+ const state = window[STATE_KEY];
+ if (state?.installToken === installToken) {
+ state.mediaUrl = mediaUrl;
+ state.streamStats = controller.stats;
+ }
+ syncMediaElement();
+ try {
+ if (controller.mediaSource.readyState !== "open") {
+ await new Promise((resolve, reject) => {
+ controller.mediaSource.addEventListener("sourceopen", resolve, { once: true });
+ controller.mediaSource.addEventListener("error", reject, { once: true });
+ });
+ }
+ if (streamController !== controller) return false;
+ const sourceBuffer = controller.mediaSource.addSourceBuffer(mime);
+ controller.stats.status = "streaming";
+ const response = await fetch(config.streamUrl, {
+ cache: "no-store",
+ credentials: "omit",
+ signal: controller.abort.signal,
+ });
+ if (!response.ok || !response.body) throw new Error(`Stream request failed: ${response.status}`);
+ controller.reader = response.body.getReader();
+ for (;;) {
+ const { value, done } = await controller.reader.read();
+ if (done || streamController !== controller) break;
+ if (!(value instanceof Uint8Array) || value.byteLength === 0) continue;
+ await waitForSourceBuffer(sourceBuffer);
+ sourceBuffer.appendBuffer(value);
+ await waitForSourceBuffer(sourceBuffer);
+ controller.stats.chunks += 1;
+ controller.stats.bytes += value.byteLength;
+ controller.stats.lastAppendAt = Date.now();
+ const media = document.getElementById(MEDIA_ID);
+ const buffered = sourceBuffer.buffered;
+ if (media && buffered?.length && media.currentTime > 20) {
+ const removeBefore = media.currentTime - 10;
+ if (buffered.start(0) < removeBefore - 5) {
+ sourceBuffer.remove(0, removeBefore);
+ await waitForSourceBuffer(sourceBuffer);
+ }
+ }
+ }
+ if (controller.abort.signal.aborted || streamController !== controller) {
+ controller.stats.status = "aborted";
+ return false;
+ }
+ if (streamController === controller && controller.mediaSource.readyState === "open") {
+ controller.mediaSource.endOfStream();
+ }
+ controller.stats.status = "ended";
+ return true;
+ } catch (error) {
+ controller.stats.status = controller.abort.signal.aborted ? "aborted" : "error";
+ controller.stats.error = controller.abort.signal.aborted
+ ? null
+ : String(error?.message || error || "Unknown local stream error").slice(0, 500);
+ if (!controller.abort.signal.aborted) {
+ console.error(`[dream-skin] local stream failed: ${controller.stats.error}`);
+ }
+ return false;
+ } finally {
+ if (streamController === controller && controller.abort.signal.aborted) {
+ streamController = null;
+ }
+ }
+ };
+
+ const syncStreamState = () => {
+ if (!config.streamUrl) return false;
+ if (document.hidden || config.wallpaperReveal <= 0) return stopStream();
+ if (!streamController) void startStream();
+ return true;
+ };
+
const beginMedia = ({ mime, size } = {}) => {
if (config.mediaType !== "video" || mime !== config.mediaMime ||
!Number.isSafeInteger(size) || size !== config.mediaSize) return false;
+ stopStream();
mediaTransfer = { chunks: [], received: 0, expected: size, mime };
if (mediaUrl) URL.revokeObjectURL(mediaUrl);
mediaUrl = null;
@@ -466,7 +644,7 @@
return;
}
- root.classList.add("codex-dream-skin");
+ setClassEnabled(root.classList, "codex-dream-skin", true);
applyProfile(root);
let style = document.getElementById(STYLE_ID);
@@ -480,19 +658,39 @@
style.dataset.dreamVersion = "3";
}
- const home = document.querySelector('[role="main"]:has([data-testid="home-icon"])');
+ const home = document.querySelector('[data-testid="home-icon"]')?.closest?.('[role="main"]') || null;
const routeMains = [...document.querySelectorAll('[role="main"]')];
for (const candidate of routeMains) {
- candidate.classList.toggle("dream-home", candidate === home);
- candidate.classList.toggle("dream-task", candidate !== home);
+ setClassEnabled(candidate.classList, "dream-home", candidate === home);
+ setClassEnabled(candidate.classList, "dream-task", candidate !== home);
}
const utilityBars = new Set(home ? home.querySelectorAll('[class*="_homeUtilityBar_"]') : []);
for (const candidate of document.querySelectorAll(`.${HOME_UTILITY_CLASS}`)) {
- if (!utilityBars.has(candidate)) candidate.classList.remove(HOME_UTILITY_CLASS);
+ if (!utilityBars.has(candidate)) setClassEnabled(candidate.classList, HOME_UTILITY_CLASS, false);
}
- for (const candidate of utilityBars) candidate.classList.add(HOME_UTILITY_CLASS);
- shellMain.classList.toggle("dream-home-shell", Boolean(home));
- shellMain.classList.toggle("dream-task", !home && routeMains.length === 0);
+ for (const candidate of utilityBars) setClassEnabled(candidate.classList, HOME_UTILITY_CLASS, true);
+ setClassEnabled(root.classList, "dream-route-home", Boolean(home));
+ setClassEnabled(root.classList, "dream-route-task", !home);
+ setClassEnabled(root.classList, "dream-home-utility-present", utilityBars.size > 0);
+ setClassEnabled(shellMain.classList, "dream-home-shell", Boolean(home));
+ setClassEnabled(shellMain.classList, "dream-task", !home && routeMains.length === 0);
+
+ const stickySearches = new Set(
+ [...document.querySelectorAll("main.main-surface:not(.dream-home-shell) div.sticky")]
+ .filter((candidate) => candidate.querySelector('input[type="text"]')),
+ );
+ const searchInputs = new Set(
+ [...document.querySelectorAll("main.main-surface:not(.dream-home-shell) div.no-drag")]
+ .filter((candidate) => candidate.querySelector(':scope > input[type="text"]')),
+ );
+ for (const candidate of document.querySelectorAll(`.${TASK_SEARCH_STICKY_CLASS}`)) {
+ setClassEnabled(candidate.classList, TASK_SEARCH_STICKY_CLASS, stickySearches.has(candidate));
+ }
+ for (const candidate of stickySearches) setClassEnabled(candidate.classList, TASK_SEARCH_STICKY_CLASS, true);
+ for (const candidate of document.querySelectorAll(`.${TASK_SEARCH_INPUT_CLASS}`)) {
+ setClassEnabled(candidate.classList, TASK_SEARCH_INPUT_CLASS, searchInputs.has(candidate));
+ }
+ for (const candidate of searchInputs) setClassEnabled(candidate.classList, TASK_SEARCH_INPUT_CLASS, true);
let chrome = document.getElementById(CHROME_ID);
if (!chrome || chrome.parentElement !== document.body) {
@@ -502,7 +700,7 @@
chrome.setAttribute("aria-hidden", "true");
document.body.appendChild(chrome);
}
- chrome.classList.toggle("dream-home-shell", Boolean(home));
+ setClassEnabled(chrome.classList, "dream-home-shell", Boolean(home));
syncMediaElement();
};
@@ -512,8 +710,10 @@
window.__CODEX_DREAM_SKIN_DISABLED__ = true;
clearSkinDom();
state?.observer?.disconnect();
+ state?.appearanceObserver?.disconnect();
if (state?.timer) clearInterval(state.timer);
if (state?.scheduler?.timeout) clearTimeout(state.scheduler.timeout);
+ state?.stopStream?.();
if (state?.artUrl) URL.revokeObjectURL(state.artUrl);
if (state?.mediaUrl) URL.revokeObjectURL(state.mediaUrl);
if (state?.visibilityHandler) {
@@ -531,24 +731,51 @@
ensure();
}, 180);
};
- observer = new MutationObserver(() => {
+ const relevantSurfaceSelector = [
+ "main.main-surface",
+ '[role="main"]',
+ '[data-testid="home-icon"]',
+ '[class*="_homeUtilityBar_"]',
+ 'input[type="text"]',
+ ].join(",");
+ const containsRelevantSurface = (node) => Boolean(
+ node?.matches?.(relevantSurfaceSelector) || node?.querySelector?.(relevantSurfaceSelector)
+ );
+ const mutationAffectsSkin = (mutation) => {
+ return [...(mutation.addedNodes || []), ...(mutation.removedNodes || [])]
+ .some(containsRelevantSurface);
+ };
+ observer = new MutationObserver((mutations) => {
if (samplingNativeShell) return;
+ if (!mutations.some(mutationAffectsSkin)) return;
scheduleEnsure();
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
+ });
+ appearanceObserver = new MutationObserver(() => {
+ if (samplingNativeShell) return;
+ scheduleEnsure();
+ });
+ appearanceObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "data-theme", "data-appearance", "data-color-mode"],
});
- const timer = setInterval(ensure, 5000);
- const visibilityHandler = () => syncMediaElement();
+ const timer = setInterval(ensure, 15000);
+ const visibilityHandler = () => {
+ syncMediaElement();
+ syncStreamState();
+ };
document.addEventListener?.("visibilitychange", visibilityHandler);
window[STATE_KEY] = {
- ensure, cleanup, observer, timer, scheduler, artUrl, mediaUrl, beginMedia, appendMedia, commitMedia,
- setWallpaperReveal, visibilityHandler, profile, config, installToken, version: "1.2.0",
+ ensure, cleanup, observer, appearanceObserver, timer, scheduler, artUrl, mediaUrl,
+ beginMedia, appendMedia, commitMedia,
+ startStream, stopStream, setWallpaperReveal, visibilityHandler, profile, config,
+ streamStats: null, installToken, version: "1.2.0",
};
ensure();
+ if (config.streamUrl && !document.hidden && config.wallpaperReveal > 0) void startStream();
analyzeArt().then((result) => {
const state = window[STATE_KEY];
if (state?.installToken !== installToken || window.__CODEX_DREAM_SKIN_DISABLED__) return;
diff --git a/windows/scripts/injector.mjs b/windows/scripts/injector.mjs
index fbb7416..76696d8 100644
--- a/windows/scripts/injector.mjs
+++ b/windows/scripts/injector.mjs
@@ -14,6 +14,7 @@ const VIDEO_CHUNK_BYTES = 512 * 1024;
const STRONG_THEME_AUDIT_MS = 30000;
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
const BROWSER_ID_PATTERN = /^[A-Za-z0-9._-]{1,200}$/;
+const WALLPAPER_ENGINE_CONTENT_SUFFIX = path.join("steamapps", "workshop", "content", "431960");
class CdpIdentityMismatchError extends Error {}
@@ -306,6 +307,119 @@ function normalizedText(value, name, fallback, maxLength = 120) {
return value;
}
+function normalizedLoopbackStreamUrl(value) {
+ if (value === null || value === undefined || value === "") return null;
+ const text = normalizedText(value, "media.streamUrl", null, 400);
+ let parsed;
+ try {
+ parsed = new URL(text);
+ } catch {
+ throw new Error("media.streamUrl must be an absolute URL");
+ }
+ const safePath = /^\/[A-Za-z0-9_-]{16,128}\/stream\.mp4$/.test(parsed.pathname);
+ if (parsed.protocol !== "http:" || !LOOPBACK_HOSTS.has(parsed.hostname) || !parsed.port ||
+ parsed.username || parsed.password || parsed.search || parsed.hash || !safePath) {
+ throw new Error("media.streamUrl must be a tokenized loopback HTTP MP4 endpoint");
+ }
+ return parsed.href;
+}
+
+function isPathInside(rootPath, candidatePath) {
+ const relative = path.relative(rootPath, candidatePath);
+ return Boolean(relative) && !relative.startsWith(`..${path.sep}`) && relative !== ".." &&
+ !path.isAbsolute(relative);
+}
+
+function normalizeRelativeMediaPath(value, name) {
+ const relativePath = normalizedText(value, name, null, 240);
+ if (!relativePath || path.isAbsolute(relativePath)) {
+ throw new Error(`${name} must be a relative path`);
+ }
+ const normalized = path.normalize(relativePath);
+ if (!isPathInside(".", normalized)) {
+ throw new Error(`${name} must remain inside the Wallpaper Engine item directory`);
+ }
+ return normalized;
+}
+
+function normalizeWallpaperEngineReference(rawMedia) {
+ if (rawMedia.source === null || rawMedia.source === undefined || rawMedia.source === "") {
+ return null;
+ }
+ if (rawMedia.source !== "wallpaper-engine-local") {
+ throw new Error("media.source has an unsupported value");
+ }
+ const workshopId = normalizedText(rawMedia.workshopId, "media.workshopId", null, 20);
+ if (!workshopId || !/^\d{1,20}$/.test(workshopId)) {
+ throw new Error("media.workshopId must be a numeric Workshop item ID");
+ }
+ const workshopRoot = normalizedText(rawMedia.workshopRoot, "media.workshopRoot", null, 1024);
+ if (!workshopRoot || !path.isAbsolute(workshopRoot)) {
+ throw new Error("media.workshopRoot must be an absolute Steam Workshop content path");
+ }
+ const normalizedRoot = path.normalize(workshopRoot).replace(/[\\/]+$/, "");
+ if (!normalizedRoot.toLowerCase().endsWith(WALLPAPER_ENGINE_CONTENT_SUFFIX.toLowerCase())) {
+ throw new Error("media.workshopRoot is not the Wallpaper Engine Workshop content directory");
+ }
+ return {
+ workshopId,
+ workshopRoot: normalizedRoot,
+ relativePath: normalizeRelativeMediaPath(rawMedia.relativePath, "media.relativePath"),
+ };
+}
+
+async function resolveWallpaperEngineMedia(reference) {
+ const realWorkshopRoot = await fs.realpath(reference.workshopRoot);
+ const workshopDirectory = path.resolve(reference.workshopRoot, reference.workshopId);
+ if (!isPathInside(reference.workshopRoot, workshopDirectory)) {
+ throw new Error("Wallpaper Engine Workshop item directory escaped its content root");
+ }
+ const realWorkshopDirectory = await fs.realpath(workshopDirectory);
+ if (!isPathInside(realWorkshopRoot, realWorkshopDirectory)) {
+ throw new Error("Wallpaper Engine Workshop item directory escaped through a link or junction");
+ }
+ const mediaPath = path.resolve(workshopDirectory, reference.relativePath);
+ if (!isPathInside(workshopDirectory, mediaPath)) {
+ throw new Error("Wallpaper Engine media escaped its item directory");
+ }
+ const realMediaPath = await fs.realpath(mediaPath);
+ if (!isPathInside(realWorkshopDirectory, realMediaPath)) {
+ throw new Error("Wallpaper Engine media escaped through a link or junction");
+ }
+ return realMediaPath;
+}
+
+async function resolvePerformanceProxy(rawMedia, themeDir) {
+ if (rawMedia.proxy === null || rawMedia.proxy === undefined || rawMedia.proxy === "") return null;
+ const relativePath = normalizeRelativeMediaPath(rawMedia.proxy, "media.proxy");
+ const parts = relativePath.split(/[\\/]+/);
+ if (parts.length !== 2 || parts[0].toLowerCase() !== "media-cache") {
+ throw new Error("media.proxy must be a file directly inside media-cache");
+ }
+ const directoryName = path.basename(themeDir).toLowerCase();
+ const parent = path.dirname(themeDir);
+ const stateRoot = directoryName === "active-theme" ? parent
+ : path.basename(parent).toLowerCase() === "themes" ? path.dirname(parent)
+ : null;
+ if (!stateRoot) throw new Error("media.proxy requires a managed theme directory");
+ const cacheRoot = path.resolve(stateRoot, "media-cache");
+ const proxyPath = path.resolve(stateRoot, relativePath);
+ if (!isPathInside(cacheRoot, proxyPath)) {
+ throw new Error("media.proxy escaped media-cache");
+ }
+ const [realCacheRoot, realProxyPath] = await Promise.all([
+ fs.realpath(cacheRoot),
+ fs.realpath(proxyPath),
+ ]);
+ if (!isPathInside(realCacheRoot, realProxyPath)) {
+ throw new Error("media.proxy escaped media-cache through a link or junction");
+ }
+ if (!new Set([".mp4", ".webm"]).has(path.extname(realProxyPath).toLowerCase())) {
+ throw new Error("media.proxy must be an MP4 or WebM video");
+ }
+ return realProxyPath;
+}
+
async function readVideoSignature(videoPath, extension) {
const handle = await fs.open(videoPath, "r");
try {
@@ -333,37 +447,73 @@ async function loadTheme(themeDir) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error("Theme root must be an object");
}
+ if (raw.schemaVersion !== 1) {
+ throw new Error("Theme schemaVersion must equal 1");
+ }
+ const rawMedia = raw.media && typeof raw.media === "object" && !Array.isArray(raw.media)
+ ? raw.media : {};
+ const streamUrl = normalizedLoopbackStreamUrl(rawMedia.streamUrl);
+ const sceneStream = rawMedia.type === "scene";
+ if (sceneStream !== Boolean(streamUrl)) {
+ throw new Error("media.type scene and media.streamUrl must be used together");
+ }
+ if (sceneStream && rawMedia.codec !== "avc1.42c01f") {
+ throw new Error("Scene streams require media.codec avc1.42c01f");
+ }
+ const wallpaperEngineReference = normalizeWallpaperEngineReference(rawMedia);
const mediaFile = normalizedText(raw.image, "image", null, 240);
if (!mediaFile || path.isAbsolute(mediaFile)) throw new Error("Theme media must be a relative path");
- const mediaPath = path.resolve(realThemeDir, mediaFile);
- const relativeMedia = path.relative(realThemeDir, mediaPath);
- if (!relativeMedia || relativeMedia.startsWith("..") || path.isAbsolute(relativeMedia)) {
- throw new Error("Theme media must remain inside the selected theme directory");
- }
- const extension = path.extname(mediaPath).toLowerCase();
const imageExtensions = new Set([".png", ".jpg", ".jpeg", ".webp"]);
const videoExtensions = new Set([".mp4", ".webm"]);
- if (!imageExtensions.has(extension) && !videoExtensions.has(extension)) {
- throw new Error(`Unsupported theme media format: ${extension || "missing"}`);
+ const expectedRelativePath = wallpaperEngineReference?.relativePath;
+ if (expectedRelativePath && path.normalize(mediaFile) !== expectedRelativePath) {
+ throw new Error("Theme image must match media.relativePath for Wallpaper Engine media");
}
- const realMediaPath = await fs.realpath(mediaPath);
- const realRelativeMedia = path.relative(realThemeDir, realMediaPath);
- if (!realRelativeMedia || realRelativeMedia.startsWith("..") || path.isAbsolute(realRelativeMedia)) {
+ const sourceMediaPath = wallpaperEngineReference
+ ? await resolveWallpaperEngineMedia(wallpaperEngineReference)
+ : path.resolve(realThemeDir, mediaFile);
+ if (!wallpaperEngineReference && !isPathInside(realThemeDir, sourceMediaPath)) {
+ throw new Error("Theme media must remain inside the selected theme directory");
+ }
+ const sourceExtension = path.extname(sourceMediaPath).toLowerCase();
+ if (!imageExtensions.has(sourceExtension) && !videoExtensions.has(sourceExtension)) {
+ throw new Error(`Unsupported theme media format: ${sourceExtension || "missing"}`);
+ }
+ if (wallpaperEngineReference && sceneStream) {
+ throw new Error("Scene stream previews must be copied into the managed theme directory");
+ }
+ if (wallpaperEngineReference && !videoExtensions.has(sourceExtension)) {
+ throw new Error("Wallpaper Engine direct references only support MP4 or WebM video media");
+ }
+ const realMediaPath = wallpaperEngineReference
+ ? await resolvePerformanceProxy(rawMedia, realThemeDir) || sourceMediaPath
+ : await fs.realpath(sourceMediaPath);
+ const extension = path.extname(realMediaPath).toLowerCase();
+ if (!wallpaperEngineReference && !isPathInside(realThemeDir, realMediaPath)) {
throw new Error("Theme media 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 : {};
- const rawMedia = raw.media && typeof raw.media === "object" && !Array.isArray(raw.media)
- ? raw.media : {};
- const inferredMediaType = videoExtensions.has(extension) ? "video" : "image";
+ ? { ...raw.palette } : {};
+ const legacyColors = raw.colors && typeof raw.colors === "object" && !Array.isArray(raw.colors)
+ ? raw.colors : {};
+ if (palette.accent === undefined && legacyColors.accent !== undefined) {
+ // macOS Theme v1 packs name this field `colors`; Windows renders the same value as `palette`.
+ palette.accent = legacyColors.accent;
+ } else if (typeof palette.accent === "string" && typeof legacyColors.accent === "string"
+ && palette.accent.trim() !== legacyColors.accent.trim()) {
+ throw new Error("Theme palette.accent conflicts with colors.accent");
+ }
+ const sourceMediaType = videoExtensions.has(extension) ? "video" : "image";
+ const inferredMediaType = sceneStream ? "stream" : sourceMediaType;
const requestedMediaType = normalizedChoice(
rawMedia.type,
"media.type",
- new Set(["image", "video"]),
- inferredMediaType,
+ new Set(["image", "video", "scene"]),
+ sourceMediaType,
);
- if (requestedMediaType !== inferredMediaType) {
+ if ((!sceneStream && requestedMediaType !== sourceMediaType) ||
+ (sceneStream && sourceMediaType !== "image")) {
throw new Error(`Theme media type ${requestedMediaType} does not match ${extension}`);
}
const playbackRate = rawMedia.playbackRate === null || rawMedia.playbackRate === undefined
@@ -389,11 +539,18 @@ async function loadTheme(themeDir) {
},
palette: {},
media: {
- type: inferredMediaType,
+ type: sceneStream ? "video" : inferredMediaType,
playbackRate,
opacity,
},
};
+ if (sceneStream) {
+ theme.media.streamUrl = streamUrl;
+ theme.media.codec = "avc1.42c01f";
+ }
+ if (wallpaperEngineReference) {
+ theme.media.source = "wallpaper-engine-local";
+ }
if (typeof palette.accent === "string" && palette.accent.trim()) {
const accent = palette.accent.trim();
if (!/^(?:#[\da-f]{3,8}|(?:rgb|hsl|oklch|oklab)\([^;{}]{1,96}\))$/i.test(accent)) {
@@ -407,7 +564,8 @@ async function loadTheme(themeDir) {
const fingerprintHash = createHash("sha256").update(themeText, "utf8").update("\0");
let mediaBytes = null;
let mediaMime = null;
- if (inferredMediaType === "image") {
+ let artMime = null;
+ if (sourceMediaType === "image") {
if (mediaStat.size > MAX_ART_BYTES) {
throw new Error(`Theme image exceeds the ${MAX_ART_BYTES / 1024 / 1024} MB limit`);
}
@@ -420,8 +578,9 @@ async function loadTheme(themeDir) {
throw new Error("Theme image metadata is invalid or exceeds the 16384px / 50MP safety limit");
}
theme.artMetadata = artMetadata;
- mediaMime = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg"
+ artMime = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg"
: extension === ".webp" ? "image/webp" : "image/png";
+ mediaMime = sceneStream ? "video/mp4" : artMime;
fingerprintHash.update(mediaBytes);
} else {
if (mediaStat.size > MAX_VIDEO_BYTES) {
@@ -443,9 +602,11 @@ async function loadTheme(themeDir) {
imagePath: realMediaPath,
mediaPath: realMediaPath,
mediaBytes,
+ artMime,
mediaMime,
mediaSize: mediaStat.size,
mediaType: inferredMediaType,
+ mediaStreamUrl: streamUrl,
fingerprint,
sourceStamp: `${themeStat.size}:${themeStat.mtimeMs}:${mediaStat.size}:${mediaStat.mtimeMs}`,
};
@@ -457,8 +618,8 @@ async function loadPayload(themeDir = path.join(root, "assets"), candidateTheme
fs.readFile(path.join(root, "assets", "dream-skin.css"), "utf8"),
fs.readFile(path.join(root, "assets", "renderer-inject.js"), "utf8"),
]);
- const artDataUrl = loadedTheme.mediaType === "image"
- ? `data:${loadedTheme.mediaMime};base64,${loadedTheme.mediaBytes.toString("base64")}`
+ const artDataUrl = ["image", "stream"].includes(loadedTheme.mediaType)
+ ? `data:${loadedTheme.artMime ?? loadedTheme.mediaMime};base64,${loadedTheme.mediaBytes.toString("base64")}`
: "";
const payload = template
.replace("__DREAM_CSS_JSON__", JSON.stringify(css))
@@ -628,12 +789,29 @@ export async function setOpacityOnSession(session, value) {
return opacity;
}
+export async function setStreamCspBypass(session, enabled) {
+ const requested = enabled === true;
+ if (session.dreamSkinStreamCspBypass === requested) return requested;
+ await session.send("Page.setBypassCSP", { enabled: requested });
+ session.dreamSkinStreamCspBypass = requested;
+ return requested;
+}
+
async function applyToSession(session, loadedPayload) {
- const result = await session.evaluate(loadedPayload.payload);
- if (loadedPayload.mediaType === "video") {
- await transferVideoToSession(session, loadedPayload);
+ const streamEnabled = Boolean(loadedPayload.mediaStreamUrl);
+ await setStreamCspBypass(session, streamEnabled);
+ try {
+ const result = await session.evaluate(loadedPayload.payload);
+ if (loadedPayload.mediaType === "video" && !streamEnabled) {
+ await transferVideoToSession(session, loadedPayload);
+ }
+ return result;
+ } catch (error) {
+ if (streamEnabled) {
+ await setStreamCspBypass(session, false).catch(() => {});
+ }
+ throw error;
}
- return result;
}
export function earlyPayloadFor(payload, revision) {
@@ -684,31 +862,35 @@ async function removeEarlyPayload(session, identifier) {
}
async function removeFromSession(session) {
- return session.evaluate(`(() => {
- window.__CODEX_DREAM_SKIN_DISABLED__ = true;
- const state = window.__CODEX_DREAM_SKIN_STATE__;
- if (state?.cleanup) return state.cleanup();
- document.documentElement?.classList.remove(
- 'codex-dream-skin', 'dream-theme-light', 'dream-theme-dark', 'dream-art-video',
- 'dream-art-wide', 'dream-art-standard', 'dream-focus-left',
- 'dream-focus-center', 'dream-focus-right', 'dream-safe-left',
- 'dream-safe-center', 'dream-safe-right', 'dream-safe-none',
- 'dream-task-ambient', 'dream-task-banner', 'dream-task-off'
- );
- for (const property of [
- '--dream-art', '--dream-art-position', '--dream-focus-x', '--dream-focus-y',
- '--dream-accent', '--dream-accent-ink', '--dream-image-luma',
- '--dream-wallpaper-reveal', '--dream-wallpaper-cover'
- ]) document.documentElement?.style.removeProperty(property);
- document.querySelectorAll('.dream-home').forEach((node) => node.classList.remove('dream-home'));
- document.querySelectorAll('.dream-task').forEach((node) => node.classList.remove('dream-task'));
- document.querySelectorAll('.dream-home-shell').forEach((node) => node.classList.remove('dream-home-shell'));
- document.getElementById('codex-dream-skin-style')?.remove();
- document.getElementById('codex-dream-skin-chrome')?.remove();
- document.getElementById('codex-dream-skin-media')?.remove();
- delete window.__CODEX_DREAM_SKIN_STATE__;
- return true;
- })()`);
+ try {
+ return await session.evaluate(`(() => {
+ window.__CODEX_DREAM_SKIN_DISABLED__ = true;
+ const state = window.__CODEX_DREAM_SKIN_STATE__;
+ if (state?.cleanup) return state.cleanup();
+ document.documentElement?.classList.remove(
+ 'codex-dream-skin', 'dream-theme-light', 'dream-theme-dark', 'dream-art-video',
+ 'dream-art-wide', 'dream-art-standard', 'dream-focus-left',
+ 'dream-focus-center', 'dream-focus-right', 'dream-safe-left',
+ 'dream-safe-center', 'dream-safe-right', 'dream-safe-none',
+ 'dream-task-ambient', 'dream-task-banner', 'dream-task-off'
+ );
+ for (const property of [
+ '--dream-art', '--dream-art-position', '--dream-focus-x', '--dream-focus-y',
+ '--dream-accent', '--dream-accent-ink', '--dream-image-luma',
+ '--dream-wallpaper-reveal', '--dream-wallpaper-cover'
+ ]) document.documentElement?.style.removeProperty(property);
+ document.querySelectorAll('.dream-home').forEach((node) => node.classList.remove('dream-home'));
+ document.querySelectorAll('.dream-task').forEach((node) => node.classList.remove('dream-task'));
+ document.querySelectorAll('.dream-home-shell').forEach((node) => node.classList.remove('dream-home-shell'));
+ document.getElementById('codex-dream-skin-style')?.remove();
+ document.getElementById('codex-dream-skin-chrome')?.remove();
+ document.getElementById('codex-dream-skin-media')?.remove();
+ delete window.__CODEX_DREAM_SKIN_STATE__;
+ return true;
+ })()`);
+ } finally {
+ await setStreamCspBypass(session, false).catch(() => {});
+ }
}
async function verifyRemovedSession(session) {
@@ -743,6 +925,8 @@ async function verifySession(session) {
chromePresent: Boolean(document.getElementById('codex-dream-skin-chrome')),
chromePointerEvents: getComputedStyle(document.getElementById('codex-dream-skin-chrome') || document.body).pointerEvents,
mediaType: window.__CODEX_DREAM_SKIN_STATE__?.config?.mediaType ?? 'image',
+ mediaStream: Boolean(window.__CODEX_DREAM_SKIN_STATE__?.config?.streamUrl),
+ streamStats: window.__CODEX_DREAM_SKIN_STATE__?.streamStats ?? null,
wallpaperReveal: window.__CODEX_DREAM_SKIN_STATE__?.config?.wallpaperReveal ?? null,
mediaPresent: Boolean(document.getElementById('codex-dream-skin-media')),
mediaReady: Boolean(window.__CODEX_DREAM_SKIN_STATE__?.mediaUrl),
@@ -768,7 +952,9 @@ async function verifySession(session) {
result.chromePointerEvents === 'none' && Boolean(result.composer) && Boolean(result.sidebar) &&
Number.isFinite(result.wallpaperReveal) && result.wallpaperReveal >= 0 && result.wallpaperReveal <= 1 &&
(result.mediaType !== 'video' ||
- (result.mediaPresent && result.mediaReady && result.mediaElementOpacity === '1')) &&
+ (result.mediaPresent && result.mediaReady && result.mediaElementOpacity === '1' &&
+ (!result.mediaStream ||
+ (result.streamStats?.chunks > 0 && result.streamStats?.bytes > 0)))) &&
(!result.homePresent || (Boolean(result.hero) &&
(!result.suggestionsPresent || (result.cards.length >= 2 && result.cards.length <= 4))));
return result;
@@ -1163,6 +1349,7 @@ if (path.resolve(process.argv[1] || "") === path.resolve(scriptPath)) {
themeId: loaded.theme.id,
appearance: loaded.theme.appearance,
art: loaded.theme.art,
+ palette: loaded.theme.palette,
artMetadata: loaded.theme.artMetadata ?? null,
media: loaded.theme.media,
}));
diff --git a/windows/scripts/manager-command.ps1 b/windows/scripts/manager-command.ps1
index 8b0acf4..554c0d1 100644
--- a/windows/scripts/manager-command.ps1
+++ b/windows/scripts/manager-command.ps1
@@ -1,20 +1,50 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
- [ValidateSet('SetWallpaper', 'SetReveal', 'Pause', 'Resume', 'Status')]
+ [ValidateSet('SetWallpaper', 'SetReveal', 'Pause', 'Resume', 'Status', 'ListThemes', 'SaveTheme', 'UseTheme', 'ListWallpaperEngine', 'UseWallpaperEngine', 'UseSceneStream')]
[string]$Action,
[string]$Path,
+ [string]$Name,
+ [string]$ThemeId,
+ [string]$SteamLibraryPath,
+ [string]$StreamUrl,
[ValidateRange(0, 100)]
[int]$Percent = 100,
[string]$StateRoot = (Join-Path $env:LOCALAPPDATA 'CodexDreamSkin')
)
$ErrorActionPreference = 'Stop'
+$utf8Output = New-Object System.Text.UTF8Encoding($false)
+[Console]::OutputEncoding = $utf8Output
+$OutputEncoding = $utf8Output
. (Join-Path $PSScriptRoot 'common-windows.ps1')
. (Join-Path $PSScriptRoot 'theme-windows.ps1')
$paths = Get-DreamSkinThemePaths -StateRoot $StateRoot
+function ConvertTo-DreamSkinManagerThemeResult {
+ param(
+ [Parameter(Mandatory = $true)][string]$Action,
+ [Parameter(Mandatory = $true)][object]$LoadedTheme
+ )
+ [pscustomobject]@{
+ action = $Action
+ themeId = "$($LoadedTheme.Theme.id)"
+ name = "$($LoadedTheme.Theme.name)"
+ mediaPath = $LoadedTheme.MediaPath
+ mediaType = $LoadedTheme.MediaType
+ source = if ($LoadedTheme.Theme.media -and $LoadedTheme.Theme.media.source) {
+ "$($LoadedTheme.Theme.media.source)"
+ } else { 'managed' }
+ reveal = Get-DreamSkinThemeMediaOpacity -Theme $LoadedTheme.Theme
+ }
+}
+
+function Initialize-DreamSkinManagerThemeStore {
+ Initialize-DreamSkinThemeStore -SkillRoot (Split-Path -Parent $PSScriptRoot) `
+ -StateRoot $StateRoot | Out-Null
+}
+
switch ($Action) {
'SetWallpaper' {
if (-not $Path) { throw 'SetWallpaper requires -Path.' }
@@ -46,6 +76,86 @@ switch ($Action) {
reveal = Get-DreamSkinThemeMediaOpacity -Theme $result.Theme
} | ConvertTo-Json -Depth 5
}
+ 'ListThemes' {
+ Initialize-DreamSkinManagerThemeStore
+ $themes = @(
+ Get-DreamSkinSavedThemes -StateRoot $StateRoot -SkipImageMetadata |
+ ForEach-Object {
+ [pscustomobject]@{
+ id = $_.Id
+ name = $_.Name
+ mediaType = $_.MediaType
+ }
+ }
+ )
+ [pscustomobject]@{
+ action = $Action
+ themes = $themes
+ } | ConvertTo-Json -Depth 5
+ }
+ 'SaveTheme' {
+ if (-not $Name) { throw 'SaveTheme requires -Name.' }
+ Initialize-DreamSkinManagerThemeStore
+ ConvertTo-DreamSkinManagerThemeResult -Action $Action `
+ -LoadedTheme (Save-DreamSkinCurrentTheme -Name $Name -StateRoot $StateRoot) |
+ ConvertTo-Json -Depth 5
+ }
+ 'UseTheme' {
+ if (-not $ThemeId) { throw 'UseTheme requires -ThemeId.' }
+ Initialize-DreamSkinManagerThemeStore
+ $matches = @(
+ Get-DreamSkinSavedThemes -StateRoot $StateRoot -SkipImageMetadata |
+ Where-Object { $_.Id -ceq $ThemeId }
+ )
+ if ($matches.Count -ne 1) { throw "Saved theme was not found: $ThemeId" }
+ ConvertTo-DreamSkinManagerThemeResult -Action $Action `
+ -LoadedTheme (Use-DreamSkinSavedTheme -ThemeDirectory $matches[0].Path -StateRoot $StateRoot) |
+ ConvertTo-Json -Depth 5
+ }
+ 'ListWallpaperEngine' {
+ $items = @(
+ Get-DreamSkinWallpaperEngineProjects -SteamLibraryPath $SteamLibraryPath |
+ ForEach-Object {
+ [pscustomobject]@{
+ workshopId = $_.WorkshopId
+ name = $_.Name
+ workshopRoot = $_.WorkshopRoot
+ projectDirectory = $_.ProjectDirectory
+ relativePath = $_.RelativePath
+ mediaPath = $_.MediaPath
+ mediaType = $_.MediaType
+ length = $_.Length
+ }
+ }
+ )
+ [pscustomobject]@{
+ action = $Action
+ items = $items
+ } | ConvertTo-Json -Depth 5
+ }
+ 'UseWallpaperEngine' {
+ if (-not $Path) { throw 'UseWallpaperEngine requires -Path.' }
+ Initialize-DreamSkinManagerThemeStore
+ $applyArguments = if (Test-Path -LiteralPath $Path -PathType Leaf) {
+ @{ MediaPath = $Path }
+ } else {
+ @{ ProjectDirectory = $Path }
+ }
+ ConvertTo-DreamSkinManagerThemeResult -Action $Action `
+ -LoadedTheme (Set-DreamSkinActiveWallpaperEngineTheme @applyArguments -Theme $null `
+ -Name $Name -StateRoot $StateRoot) |
+ ConvertTo-Json -Depth 5
+ }
+ 'UseSceneStream' {
+ if (-not $Path -or -not $StreamUrl) {
+ throw 'UseSceneStream requires -Path and -StreamUrl.'
+ }
+ Initialize-DreamSkinManagerThemeStore
+ ConvertTo-DreamSkinManagerThemeResult -Action $Action `
+ -LoadedTheme (Set-DreamSkinActiveSceneStreamTheme -ScenePath $Path `
+ -StreamUrl $StreamUrl -Name $Name -StateRoot $StateRoot) |
+ ConvertTo-Json -Depth 5
+ }
'Pause' {
Set-DreamSkinPaused -Paused $true -StateRoot $StateRoot | Out-Null
[pscustomobject]@{ action = $Action; paused = $true } | ConvertTo-Json
diff --git a/windows/scripts/theme-windows.ps1 b/windows/scripts/theme-windows.ps1
index 03624a6..b210c1e 100644
--- a/windows/scripts/theme-windows.ps1
+++ b/windows/scripts/theme-windows.ps1
@@ -192,6 +192,7 @@ function Get-DreamSkinThemePaths {
Active = Join-Path $fullRoot 'active-theme'
Saved = Join-Path $fullRoot 'themes'
Images = Join-Path $fullRoot 'images'
+ MediaCache = Join-Path $fullRoot 'media-cache'
PauseFile = Join-Path $fullRoot 'paused'
State = Join-Path $fullRoot 'state.json'
}
@@ -228,6 +229,331 @@ function Test-DreamSkinThemePathWithin {
}
}
+function Test-DreamSkinThemeSchemaV1 {
+ param([AllowNull()][object]$Theme)
+ if ($null -eq $Theme) { return $false }
+ $schemaProperty = $Theme.PSObject.Properties['schemaVersion']
+ if ($null -eq $schemaProperty -or $schemaProperty.Value -isnot [System.ValueType] -or
+ $schemaProperty.Value -is [bool]) {
+ return $false
+ }
+ try {
+ return [double]$schemaProperty.Value -eq 1
+ } catch {
+ return $false
+ }
+}
+
+function Get-DreamSkinThemeStateRoot {
+ param([Parameter(Mandatory = $true)][string]$ThemeDirectory)
+ $directory = [System.IO.Path]::GetFullPath($ThemeDirectory).TrimEnd('\')
+ if ([System.IO.Path]::GetFileName($directory) -ceq 'active-theme') {
+ return [System.IO.Path]::GetDirectoryName($directory)
+ }
+ $parent = [System.IO.Path]::GetDirectoryName($directory)
+ if ($parent -and [System.IO.Path]::GetFileName($parent) -ceq 'themes') {
+ return [System.IO.Path]::GetDirectoryName($parent)
+ }
+ return $null
+}
+
+function Resolve-DreamSkinPerformanceProxy {
+ param(
+ [Parameter(Mandatory = $true)][string]$ThemeDirectory,
+ [Parameter(Mandatory = $true)][object]$Theme
+ )
+ if ($null -eq $Theme.media) { return $null }
+ $property = $Theme.media.PSObject.Properties['proxy']
+ if ($null -eq $property -or -not "$($property.Value)") { return $null }
+ $relative = "$($property.Value)"
+ if ([System.IO.Path]::IsPathRooted($relative)) {
+ throw 'Theme performance proxy must be a relative state path.'
+ }
+ $normalized = $relative.Replace('/', '\')
+ if (-not $normalized.StartsWith('media-cache\', [System.StringComparison]::OrdinalIgnoreCase) -or
+ $normalized.Contains('..')) {
+ throw 'Theme performance proxy must remain inside media-cache.'
+ }
+ $stateRoot = Get-DreamSkinThemeStateRoot -ThemeDirectory $ThemeDirectory
+ if (-not $stateRoot) { throw 'Theme performance proxy requires a managed theme directory.' }
+ $cacheRoot = Join-Path $stateRoot 'media-cache'
+ $proxyPath = [System.IO.Path]::GetFullPath((Join-Path $stateRoot $normalized))
+ if (-not (Test-DreamSkinThemePathWithin -Path $proxyPath -Root $cacheRoot)) {
+ throw 'Theme performance proxy is unavailable or escaped media-cache.'
+ }
+ if ((Assert-DreamSkinMediaFile -Path $proxyPath) -cne 'video') {
+ throw 'Theme performance proxy must be an MP4 or WebM video.'
+ }
+ return $proxyPath
+}
+
+function Test-DreamSkinWallpaperEngineContentRoot {
+ param([Parameter(Mandatory = $true)][string]$Path)
+ try {
+ $fullPath = [System.IO.Path]::GetFullPath($Path).TrimEnd('\')
+ $suffix = [System.IO.Path]::Combine('steamapps', 'workshop', 'content', '431960')
+ return $fullPath.EndsWith($suffix, [System.StringComparison]::OrdinalIgnoreCase)
+ } catch {
+ return $false
+ }
+}
+
+function Test-DreamSkinLoopbackStreamUrl {
+ param([AllowNull()][string]$Url)
+ if (-not $Url -or $Url.Length -gt 400) { return $false }
+ try {
+ $uri = [System.Uri]::new($Url, [System.UriKind]::Absolute)
+ $loopback = $uri.Host -in @('127.0.0.1', 'localhost', '::1')
+ return $uri.Scheme -ceq 'http' -and $loopback -and -not $uri.IsDefaultPort -and
+ -not $uri.UserInfo -and -not $uri.Query -and -not $uri.Fragment -and
+ $uri.AbsolutePath -cmatch '^/[A-Za-z0-9_-]{16,128}/stream\.mp4$'
+ } catch {
+ return $false
+ }
+}
+
+function Get-DreamSkinWallpaperEngineReference {
+ param([AllowNull()][object]$Theme)
+ if ($null -eq $Theme -or $null -eq $Theme.media) { return $null }
+ $sourceProperty = $Theme.media.PSObject.Properties['source']
+ if ($null -eq $sourceProperty -or "$($sourceProperty.Value)" -cne 'wallpaper-engine-local') {
+ return $null
+ }
+ $workshopIdProperty = $Theme.media.PSObject.Properties['workshopId']
+ $workshopRootProperty = $Theme.media.PSObject.Properties['workshopRoot']
+ $relativePathProperty = $Theme.media.PSObject.Properties['relativePath']
+ if ($null -eq $workshopIdProperty -or $null -eq $workshopRootProperty -or
+ $null -eq $relativePathProperty) {
+ throw 'Wallpaper Engine media reference is incomplete.'
+ }
+ $workshopId = "$($workshopIdProperty.Value)"
+ $workshopRoot = "$($workshopRootProperty.Value)"
+ $relativePath = "$($relativePathProperty.Value)"
+ if ($workshopId -notmatch '^\d{1,20}$' -or
+ -not [System.IO.Path]::IsPathRooted($workshopRoot) -or
+ -not (Test-DreamSkinWallpaperEngineContentRoot -Path $workshopRoot) -or
+ -not $relativePath -or [System.IO.Path]::IsPathRooted($relativePath)) {
+ throw 'Wallpaper Engine media reference is invalid.'
+ }
+ $fullWorkshopRoot = [System.IO.Path]::GetFullPath($workshopRoot)
+ $workshopDirectory = [System.IO.Path]::GetFullPath((Join-Path $fullWorkshopRoot $workshopId))
+ if (-not (Test-DreamSkinThemePathWithin -Path $workshopDirectory -Root $fullWorkshopRoot)) {
+ throw 'Wallpaper Engine Workshop item is unavailable or escaped its content directory.'
+ }
+ $mediaPath = [System.IO.Path]::GetFullPath((Join-Path $workshopDirectory $relativePath))
+ if (-not (Test-DreamSkinThemePathWithin -Path $mediaPath -Root $workshopDirectory)) {
+ throw 'Wallpaper Engine media is unavailable or escaped its item directory.'
+ }
+ $mediaType = Assert-DreamSkinMediaFile -Path $mediaPath
+ if ($mediaType -cne 'video') {
+ throw 'Wallpaper Engine direct references only support MP4 or WebM video media.'
+ }
+ return [pscustomobject]@{
+ WorkshopRoot = $fullWorkshopRoot
+ WorkshopDirectory = $workshopDirectory
+ WorkshopId = $workshopId
+ RelativePath = $relativePath
+ MediaPath = $mediaPath
+ MediaType = $mediaType
+ }
+}
+
+function Read-DreamSkinWallpaperEngineProject {
+ param([Parameter(Mandatory = $true)][string]$ProjectDirectory)
+ $directory = [System.IO.Path]::GetFullPath($ProjectDirectory)
+ $workshopId = Split-Path -Leaf $directory
+ $workshopRoot = Split-Path -Parent $directory
+ if ($workshopId -notmatch '^\d{1,20}$' -or
+ -not (Test-DreamSkinWallpaperEngineContentRoot -Path $workshopRoot) -or
+ -not (Test-DreamSkinThemePathWithin -Path $directory -Root $workshopRoot)) {
+ throw 'Wallpaper Engine project must be inside steamapps\\workshop\\content\\431960\\.'
+ }
+ $projectPath = Join-Path $directory 'project.json'
+ if (-not (Test-DreamSkinThemePathWithin -Path $projectPath -Root $directory)) {
+ throw 'Wallpaper Engine project metadata is missing or unsafe.'
+ }
+ try {
+ $project = (Read-DreamSkinUtf8File -Path $projectPath) | ConvertFrom-Json -ErrorAction Stop
+ } catch {
+ throw "Wallpaper Engine project metadata is invalid JSON: $projectPath"
+ }
+ $projectType = "$($project.type)".Trim().ToLowerInvariant()
+ if ($null -eq $project -or $projectType -notin @('video', 'scene')) {
+ throw 'Wallpaper Engine project must be a video or scene wallpaper.'
+ }
+ if ($projectType -ceq 'scene') {
+ $mediaPath = [System.IO.Path]::GetFullPath((Join-Path $directory 'scene.pkg'))
+ if (-not (Test-DreamSkinThemePathWithin -Path $mediaPath -Root $directory) -or
+ -not (Test-Path -LiteralPath $mediaPath -PathType Leaf)) {
+ throw 'Wallpaper Engine scene.pkg is missing or escaped its project directory.'
+ }
+ Assert-DreamSkinNoReparseComponents -Path $mediaPath
+ $mediaType = 'scene'
+ } else {
+ if (-not $project.file -or [System.IO.Path]::IsPathRooted("$($project.file)")) {
+ throw 'Wallpaper Engine video project must declare a relative video file.'
+ }
+ $mediaPath = [System.IO.Path]::GetFullPath((Join-Path $directory "$($project.file)"))
+ if (-not (Test-DreamSkinThemePathWithin -Path $mediaPath -Root $directory)) {
+ throw 'Wallpaper Engine video file is missing or escaped its project directory.'
+ }
+ $mediaType = Assert-DreamSkinMediaFile -Path $mediaPath
+ if ($mediaType -cne 'video') {
+ throw 'Wallpaper Engine project file must be MP4 or WebM.'
+ }
+ }
+ return [pscustomobject]@{
+ WorkshopRoot = [System.IO.Path]::GetFullPath($workshopRoot)
+ WorkshopDirectory = $directory
+ WorkshopId = $workshopId
+ RelativePath = $mediaPath.Substring($directory.TrimEnd('\').Length).TrimStart('\')
+ MediaPath = $mediaPath
+ MediaType = $mediaType
+ Name = if ($project.title) { "$($project.title)" } else { $workshopId }
+ }
+}
+
+function Get-DreamSkinWallpaperEngineReferenceFromMediaPath {
+ param([Parameter(Mandatory = $true)][string]$MediaPath)
+ $fullMediaPath = [System.IO.Path]::GetFullPath($MediaPath)
+ $mediaType = Assert-DreamSkinMediaFile -Path $fullMediaPath
+ if ($mediaType -cne 'video') {
+ throw 'Wallpaper Engine direct references only support MP4 or WebM video media.'
+ }
+ $current = Split-Path -Parent $fullMediaPath
+ while ($current) {
+ $workshopId = Split-Path -Leaf $current
+ $workshopRoot = Split-Path -Parent $current
+ if ($workshopId -match '^\d{1,20}$' -and
+ (Test-DreamSkinWallpaperEngineContentRoot -Path $workshopRoot) -and
+ (Test-DreamSkinThemePathWithin -Path $current -Root $workshopRoot) -and
+ (Test-DreamSkinThemePathWithin -Path $fullMediaPath -Root $current)) {
+ return [pscustomobject]@{
+ WorkshopRoot = [System.IO.Path]::GetFullPath($workshopRoot)
+ WorkshopDirectory = [System.IO.Path]::GetFullPath($current)
+ WorkshopId = $workshopId
+ RelativePath = $fullMediaPath.Substring($current.TrimEnd('\').Length).TrimStart('\')
+ MediaPath = $fullMediaPath
+ MediaType = $mediaType
+ }
+ }
+ $parent = Split-Path -Parent $current
+ if (-not $parent -or $parent -eq $current) { break }
+ $current = $parent
+ }
+ throw 'Wallpaper Engine media must be inside steamapps\workshop\content\431960\.'
+}
+
+function Test-DreamSkinWallpaperEngineDisplayName {
+ param([AllowNull()][string]$Value)
+ if (-not $Value) { return $false }
+ $trimmed = $Value.Trim()
+ return $trimmed.Length -gt 0 -and $trimmed.Length -le 120 -and
+ $trimmed -notmatch '[\u0000-\u001f\ufffd]' -and $trimmed -notmatch '锟'
+}
+
+function Get-DreamSkinWallpaperEngineDisplayName {
+ param(
+ [Parameter(Mandatory = $true)][object]$Reference,
+ [AllowNull()][object]$Project
+ )
+ $fileName = [System.IO.Path]::GetFileNameWithoutExtension($Reference.MediaPath)
+ $title = if ($Project -and $Project.title) { "$($Project.title)" } else { $null }
+ if (-not (Test-DreamSkinWallpaperEngineDisplayName -Value $title)) { $title = $null }
+ if (-not (Test-DreamSkinWallpaperEngineDisplayName -Value $fileName)) {
+ $fileName = "Wallpaper Engine $($Reference.WorkshopId)"
+ }
+ if ($title -and $Project -and $Project.file -and
+ "$($Project.file)" -ceq $Reference.RelativePath) {
+ return $title.Trim()
+ }
+ if ($title) { return ($title.Trim() + ' · ' + $fileName.Trim()) }
+ return $fileName.Trim()
+}
+
+function Get-DreamSkinSteamLibraryPaths {
+ param([string]$SteamLibraryPath)
+ $paths = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
+ $addPath = {
+ param([AllowNull()][string]$Candidate)
+ if (-not $Candidate -or -not [System.IO.Path]::IsPathRooted($Candidate)) { return }
+ try {
+ $fullPath = [System.IO.Path]::GetFullPath($Candidate)
+ if (Test-Path -LiteralPath (Join-Path $fullPath 'steamapps') -PathType Container) {
+ $null = $paths.Add($fullPath)
+ }
+ } catch {}
+ }
+ if ($SteamLibraryPath) {
+ & $addPath $SteamLibraryPath
+ } else {
+ foreach ($candidate in @(
+ 'C:\Program Files (x86)\Steam',
+ 'C:\Program Files\Steam'
+ )) {
+ & $addPath $candidate
+ }
+ try {
+ & $addPath ((Get-ItemProperty -LiteralPath 'HKCU:\Software\Valve\Steam' -ErrorAction Stop).SteamPath)
+ } catch {}
+ }
+ foreach ($libraryPath in @($paths)) {
+ $libraryFolders = Join-Path $libraryPath 'steamapps\libraryfolders.vdf'
+ if (-not (Test-Path -LiteralPath $libraryFolders -PathType Leaf)) { continue }
+ try {
+ $vdf = [System.IO.File]::ReadAllText($libraryFolders)
+ foreach ($match in [regex]::Matches($vdf, '(?im)"path"\s+"((?:\\\\|[^"])*)"')) {
+ $decoded = $match.Groups[1].Value -replace '\\\\', '\'
+ & $addPath $decoded
+ }
+ } catch {}
+ }
+ return @($paths | Sort-Object)
+}
+
+function Get-DreamSkinWallpaperEngineProjects {
+ param([string]$SteamLibraryPath)
+ $projects = @()
+ foreach ($libraryPath in Get-DreamSkinSteamLibraryPaths -SteamLibraryPath $SteamLibraryPath) {
+ $workshopRoot = Join-Path $libraryPath 'steamapps\workshop\content\431960'
+ if (-not (Test-DreamSkinWallpaperEngineContentRoot -Path $workshopRoot) -or
+ -not (Test-Path -LiteralPath $workshopRoot -PathType Container)) {
+ continue
+ }
+ foreach ($directory in Get-ChildItem -LiteralPath $workshopRoot -Directory -Force -ErrorAction SilentlyContinue) {
+ if ($directory.Name -notmatch '^\d{1,20}$' -or
+ ($directory.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
+ continue
+ }
+ try {
+ # A Workshop item is one Wallpaper Engine wallpaper. Do not enumerate
+ # every embedded asset: Web/Scene projects commonly contain dozens of
+ # loop videos, which would appear as duplicate entries and cannot retain
+ # their original effects when directly referenced by Codex.
+ $reference = Read-DreamSkinWallpaperEngineProject -ProjectDirectory $directory.FullName
+ $name = "$($reference.Name)"
+ if (-not (Test-DreamSkinWallpaperEngineDisplayName -Value $name)) {
+ $name = [System.IO.Path]::GetFileNameWithoutExtension($reference.MediaPath)
+ }
+ if (-not (Test-DreamSkinWallpaperEngineDisplayName -Value $name)) {
+ $name = "Wallpaper Engine $($reference.WorkshopId)"
+ }
+ $projects += [pscustomobject]@{
+ WorkshopId = $reference.WorkshopId
+ Name = $name.Trim()
+ WorkshopRoot = $reference.WorkshopRoot
+ ProjectDirectory = $reference.WorkshopDirectory
+ RelativePath = $reference.RelativePath
+ MediaPath = $reference.MediaPath
+ MediaType = $reference.MediaType
+ Length = ([System.IO.FileInfo]::new($reference.MediaPath)).Length
+ }
+ } catch {}
+ }
+ }
+ return @($projects | Sort-Object Name, WorkshopId, MediaPath)
+}
+
function Read-DreamSkinTheme {
param(
[Parameter(Mandatory = $true)][string]$ThemeDirectory,
@@ -245,25 +571,52 @@ function Read-DreamSkinTheme {
} catch {
throw "Theme metadata is invalid JSON: $themePath"
}
- if ($null -eq $theme -or $theme -is [string] -or $theme -is [array] -or -not $theme.image) {
- throw "Theme metadata must be an object with a relative media path: $themePath"
+ if ($null -eq $theme -or $theme -is [string] -or $theme -is [array] -or
+ -not (Test-DreamSkinThemeSchemaV1 -Theme $theme) -or -not $theme.image) {
+ throw "Theme metadata must be a schemaVersion 1 object with a relative media path: $themePath"
}
$media = "$($theme.image)"
if ([System.IO.Path]::IsPathRooted($media)) { throw 'Theme media path must be relative.' }
- $mediaPath = [System.IO.Path]::GetFullPath((Join-Path $directory $media))
- if (-not (Test-DreamSkinThemePathWithin -Path $mediaPath -Root $directory) -or
- -not (Test-Path -LiteralPath $mediaPath -PathType Leaf)) {
- throw 'Theme media must remain inside its theme directory and exist.'
+ $reference = Get-DreamSkinWallpaperEngineReference -Theme $theme
+ $imagePath = $null
+ if ($null -ne $reference) {
+ $declaredMediaPath = [System.IO.Path]::GetFullPath((Join-Path $reference.WorkshopDirectory $media))
+ if ($declaredMediaPath -ine $reference.MediaPath) {
+ throw 'Theme image must match its Wallpaper Engine relative media path.'
+ }
+ $imagePath = $reference.MediaPath
+ $mediaPath = Resolve-DreamSkinPerformanceProxy -ThemeDirectory $directory -Theme $theme
+ if (-not $mediaPath) { $mediaPath = $imagePath }
+ $mediaType = $reference.MediaType
+ } else {
+ $mediaPath = [System.IO.Path]::GetFullPath((Join-Path $directory $media))
+ if (-not (Test-DreamSkinThemePathWithin -Path $mediaPath -Root $directory) -or
+ -not (Test-Path -LiteralPath $mediaPath -PathType Leaf)) {
+ throw 'Theme media must remain inside its theme directory and exist.'
+ }
+ $mediaType = Assert-DreamSkinMediaFile -Path $mediaPath -SkipImageMetadata:$SkipImageMetadata
+ $imagePath = $mediaPath
}
- $mediaType = Assert-DreamSkinMediaFile -Path $mediaPath -SkipImageMetadata:$SkipImageMetadata
- if ($theme.media -and $theme.media.type -and "$($theme.media.type)" -cne $mediaType) {
+ $declaredMediaType = if ($theme.media -and $theme.media.type) {
+ "$($theme.media.type)"
+ } else {
+ $mediaType
+ }
+ if ($declaredMediaType -ceq 'scene') {
+ if ($mediaType -cne 'image' -or
+ -not (Test-DreamSkinLoopbackStreamUrl -Url "$($theme.media.streamUrl)") -or
+ "$($theme.media.codec)" -cne 'avc1.42c01f') {
+ throw 'Scene themes require an image preview and a valid loopback H.264 stream.'
+ }
+ $mediaType = 'scene'
+ } elseif ($declaredMediaType -cne $mediaType) {
throw "Theme media type does not match its file extension: $mediaPath"
}
$null = Get-DreamSkinThemeMediaOpacity -Theme $theme
return [pscustomobject]@{
Directory = $directory
ThemePath = $themePath
- ImagePath = $mediaPath
+ ImagePath = $imagePath
MediaPath = $mediaPath
MediaType = $mediaType
Theme = $theme
@@ -275,6 +628,9 @@ function Write-DreamSkinTheme {
[Parameter(Mandatory = $true)][string]$ThemeDirectory,
[Parameter(Mandatory = $true)][object]$Theme
)
+ if (-not (Test-DreamSkinThemeSchemaV1 -Theme $Theme)) {
+ throw 'Theme schemaVersion must equal 1.'
+ }
Assert-DreamSkinNoReparseComponents -Path $ThemeDirectory
New-Item -ItemType Directory -Force -Path $ThemeDirectory | Out-Null
Assert-DreamSkinNoReparseComponents -Path $ThemeDirectory
@@ -340,6 +696,272 @@ function New-DreamSkinThemeImageName {
[guid]::NewGuid().ToString('N').Substring(0, 8) + $Extension.ToLowerInvariant()
}
+function Get-DreamSkinVideoDimensions {
+ param([Parameter(Mandatory = $true)][string]$Path)
+ $shell = $null
+ try {
+ $shell = New-Object -ComObject Shell.Application
+ $folder = $shell.Namespace([System.IO.Path]::GetDirectoryName($Path))
+ $item = if ($null -ne $folder) { $folder.ParseName([System.IO.Path]::GetFileName($Path)) } else { $null }
+ if ($null -eq $item) { return $null }
+ $width = [int64]$item.ExtendedProperty('System.Video.FrameWidth')
+ $height = [int64]$item.ExtendedProperty('System.Video.FrameHeight')
+ $frameRate = [int64]$item.ExtendedProperty('System.Video.FrameRate')
+ if ($width -lt 1 -or $height -lt 1) { return $null }
+ return [pscustomobject]@{
+ Width = $width
+ Height = $height
+ FramesPerSecond = if ($frameRate -gt 0) { [double]$frameRate / 1000 } else { 30 }
+ }
+ } catch {
+ return $null
+ } finally {
+ if ($null -ne $shell) { [void][System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($shell) }
+ }
+}
+
+function Invoke-DreamSkinWinRtOperation {
+ param([Parameter(Mandatory = $true)][object]$Operation, [Parameter(Mandatory = $true)][type]$ResultType)
+ $method = [System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object {
+ $_.Name -ceq 'AsTask' -and $_.IsGenericMethod -and $_.GetParameters().Count -eq 1 -and
+ $_.GetParameters()[0].ParameterType.Name -ceq 'IAsyncOperation`1'
+ } | Select-Object -First 1
+ if ($null -eq $method) { throw 'Windows Runtime async operation bridge is unavailable.' }
+ $task = $method.MakeGenericMethod($ResultType).Invoke($null, @($Operation))
+ $task.GetAwaiter().GetResult()
+}
+
+function Invoke-DreamSkinWinRtProgressAction {
+ param([Parameter(Mandatory = $true)][object]$Operation)
+ $method = [System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object {
+ $_.Name -ceq 'AsTask' -and $_.IsGenericMethod -and $_.GetParameters().Count -eq 1 -and
+ $_.GetParameters()[0].ParameterType.Name -ceq 'IAsyncActionWithProgress`1'
+ } | Select-Object -First 1
+ if ($null -eq $method) { throw 'Windows Runtime progress bridge is unavailable.' }
+ $task = $method.MakeGenericMethod([double]).Invoke($null, @($Operation))
+ $task.GetAwaiter().GetResult()
+}
+
+function New-DreamSkinPerformanceProxy {
+ param(
+ [Parameter(Mandatory = $true)][object]$Reference,
+ [string]$StateRoot = (Join-Path $env:LOCALAPPDATA 'CodexDreamSkin')
+ )
+ $dimensions = Get-DreamSkinVideoDimensions -Path $Reference.MediaPath
+ if ($null -eq $dimensions -or ($dimensions.Width -le 1920 -and $dimensions.Height -le 1080)) {
+ return $null
+ }
+ $paths = Get-DreamSkinThemePaths -StateRoot $StateRoot
+ Ensure-DreamSkinManagedDirectory -Path $paths.Root -Root $paths.Root
+ Ensure-DreamSkinManagedDirectory -Path $paths.MediaCache -Root $paths.Root
+ $source = [System.IO.FileInfo]::new($Reference.MediaPath)
+ $cacheName = '{0}-{1}-{2}-1080p.mp4' -f $Reference.WorkshopId, $source.Length, $source.LastWriteTimeUtc.Ticks
+ $proxyPath = Join-Path $paths.MediaCache $cacheName
+ if (Test-Path -LiteralPath $proxyPath -PathType Leaf) {
+ try {
+ if ((Assert-DreamSkinMediaFile -Path $proxyPath) -ceq 'video') {
+ return [pscustomobject]@{ Path = $proxyPath; RelativePath = 'media-cache/' + $cacheName }
+ }
+ } catch {}
+ Remove-Item -LiteralPath $proxyPath -Force -ErrorAction SilentlyContinue
+ }
+
+ $partialPath = Join-Path $paths.MediaCache ('.' + $cacheName + '.' + [guid]::NewGuid().ToString('N') + '.partial.mp4')
+ [System.IO.File]::WriteAllBytes($partialPath, [byte[]]@())
+ try {
+ Add-Type -AssemblyName System.Runtime.WindowsRuntime
+ $null = [Windows.Storage.StorageFile, Windows.Storage, ContentType=WindowsRuntime]
+ $null = [Windows.Media.MediaProperties.MediaEncodingProfile, Windows.Media.MediaProperties, ContentType=WindowsRuntime]
+ $null = [Windows.Media.MediaProperties.VideoEncodingQuality, Windows.Media.MediaProperties, ContentType=WindowsRuntime]
+ $null = [Windows.Media.Transcoding.MediaTranscoder, Windows.Media.Transcoding, ContentType=WindowsRuntime]
+ $null = [Windows.Media.Transcoding.PrepareTranscodeResult, Windows.Media.Transcoding, ContentType=WindowsRuntime]
+ $inputFile = Invoke-DreamSkinWinRtOperation `
+ -Operation ([Windows.Storage.StorageFile]::GetFileFromPathAsync($Reference.MediaPath)) `
+ -ResultType ([Windows.Storage.StorageFile])
+ $outputFile = Invoke-DreamSkinWinRtOperation `
+ -Operation ([Windows.Storage.StorageFile]::GetFileFromPathAsync($partialPath)) `
+ -ResultType ([Windows.Storage.StorageFile])
+ $profile = [Windows.Media.MediaProperties.MediaEncodingProfile]::CreateMp4(
+ [Windows.Media.MediaProperties.VideoEncodingQuality]::HD1080p)
+ $transcoder = [Windows.Media.Transcoding.MediaTranscoder]::new()
+ $prepared = Invoke-DreamSkinWinRtOperation `
+ -Operation ($transcoder.PrepareFileTranscodeAsync($inputFile, $outputFile, $profile)) `
+ -ResultType ([Windows.Media.Transcoding.PrepareTranscodeResult])
+ if (-not $prepared.CanTranscode) { throw "Windows media transcoding failed: $($prepared.FailureReason)" }
+ Invoke-DreamSkinWinRtProgressAction -Operation ($prepared.TranscodeAsync())
+ if ((Assert-DreamSkinMediaFile -Path $partialPath) -cne 'video') {
+ throw 'Generated performance proxy is not a supported video.'
+ }
+ Move-Item -LiteralPath $partialPath -Destination $proxyPath -Force
+ return [pscustomobject]@{ Path = $proxyPath; RelativePath = 'media-cache/' + $cacheName }
+ } finally {
+ Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
+ }
+}
+
+function Set-DreamSkinActiveWallpaperEngineTheme {
+ param(
+ [string]$ProjectDirectory,
+ [string]$MediaPath,
+ [AllowNull()][object]$Theme,
+ [string]$Name,
+ [string]$StateRoot = (Join-Path $env:LOCALAPPDATA 'CodexDreamSkin')
+ )
+ if ($MediaPath) {
+ $reference = Get-DreamSkinWallpaperEngineReferenceFromMediaPath -MediaPath $MediaPath
+ $project = $null
+ $projectPath = Join-Path $reference.WorkshopDirectory 'project.json'
+ if (Test-DreamSkinThemePathWithin -Path $projectPath -Root $reference.WorkshopDirectory) {
+ try { $project = (Read-DreamSkinUtf8File -Path $projectPath) | ConvertFrom-Json -ErrorAction Stop } catch {}
+ }
+ if (-not $Name) { $Name = Get-DreamSkinWallpaperEngineDisplayName -Reference $reference -Project $project }
+ } elseif ($ProjectDirectory) {
+ $reference = Read-DreamSkinWallpaperEngineProject -ProjectDirectory $ProjectDirectory
+ } else {
+ throw 'Set-DreamSkinActiveWallpaperEngineTheme requires -MediaPath or -ProjectDirectory.'
+ }
+ $paths = Get-DreamSkinThemePaths -StateRoot $StateRoot
+ Ensure-DreamSkinManagedDirectory -Path $paths.Root -Root $paths.Root
+ Ensure-DreamSkinManagedDirectory -Path $paths.Active -Root $paths.Root
+ $oldImage = $null
+ try { $oldImage = (Read-DreamSkinTheme -ThemeDirectory $paths.Active).ImagePath } catch {}
+ if ($null -eq $Theme) {
+ $Theme = [pscustomobject]@{
+ schemaVersion = 1
+ id = 'wallpaper-engine-' + $reference.WorkshopId
+ name = $reference.Name
+ appearance = 'auto'
+ art = [pscustomobject]@{ focusX = $null; focusY = $null; safeArea = 'auto'; taskMode = 'auto' }
+ palette = [pscustomobject]@{}
+ media = [pscustomobject]@{ type = 'video'; playbackRate = 1; opacity = 1 }
+ }
+ }
+ if (-not (Test-DreamSkinThemeSchemaV1 -Theme $Theme)) {
+ throw 'Theme schemaVersion must equal 1.'
+ }
+ $performanceProxy = $null
+ try {
+ $existingProxy = Resolve-DreamSkinPerformanceProxy -ThemeDirectory $paths.Active -Theme $Theme
+ if ($existingProxy -and [System.IO.Path]::GetFileName($existingProxy).StartsWith(
+ "$($reference.WorkshopId)-", [System.StringComparison]::OrdinalIgnoreCase)) {
+ $performanceProxy = [pscustomobject]@{
+ Path = $existingProxy
+ RelativePath = "$($Theme.media.proxy)"
+ }
+ }
+ } catch {}
+ try {
+ if ($null -eq $performanceProxy) {
+ $performanceProxy = New-DreamSkinPerformanceProxy -Reference $reference -StateRoot $StateRoot
+ }
+ } catch {
+ Write-Warning "Wallpaper performance proxy was unavailable; using the original video: $($_.Exception.Message)"
+ }
+ $Theme | Add-Member -NotePropertyName image -NotePropertyValue $reference.RelativePath -Force
+ $media = [pscustomobject]@{
+ type = 'video'
+ playbackRate = if ($Theme.media -and $Theme.media.playbackRate) { [double]$Theme.media.playbackRate } else { 1 }
+ opacity = Get-DreamSkinThemeMediaOpacity -Theme $Theme
+ source = 'wallpaper-engine-local'
+ workshopId = $reference.WorkshopId
+ workshopRoot = $reference.WorkshopRoot
+ relativePath = $reference.RelativePath
+ }
+ if ($null -ne $performanceProxy) {
+ $media | Add-Member -NotePropertyName proxy -NotePropertyValue $performanceProxy.RelativePath
+ }
+ $Theme | Add-Member -NotePropertyName media -NotePropertyValue $media -Force
+ if ($Name) { $Theme | Add-Member -NotePropertyName name -NotePropertyValue $Name -Force }
+ if (-not $Theme.id) { $Theme | Add-Member -NotePropertyName id -NotePropertyValue ('wallpaper-engine-' + $reference.WorkshopId) -Force }
+ if (-not $Theme.appearance) { $Theme | Add-Member -NotePropertyName appearance -NotePropertyValue 'auto' -Force }
+ if (-not $Theme.art) {
+ $Theme | Add-Member -NotePropertyName art -NotePropertyValue `
+ ([pscustomobject]@{ focusX = $null; focusY = $null; safeArea = 'auto'; taskMode = 'auto' }) -Force
+ }
+ if (-not $Theme.palette) {
+ $Theme | Add-Member -NotePropertyName palette -NotePropertyValue ([pscustomobject]@{}) -Force
+ }
+ Write-DreamSkinTheme -ThemeDirectory $paths.Active -Theme $Theme
+ if ($oldImage -and (Test-DreamSkinThemePathWithin -Path $oldImage -Root $paths.Active)) {
+ Remove-Item -LiteralPath $oldImage -Force -ErrorAction SilentlyContinue
+ }
+ return Read-DreamSkinTheme -ThemeDirectory $paths.Active
+}
+
+function Set-DreamSkinActiveSceneStreamTheme {
+ param(
+ [Parameter(Mandatory = $true)][string]$ScenePath,
+ [Parameter(Mandatory = $true)][string]$StreamUrl,
+ [string]$Name,
+ [string]$StateRoot = (Join-Path $env:LOCALAPPDATA 'CodexDreamSkin')
+ )
+ if (-not (Test-DreamSkinLoopbackStreamUrl -Url $StreamUrl)) {
+ throw 'Scene stream URL must be a tokenized loopback HTTP endpoint.'
+ }
+ $fullScenePath = [System.IO.Path]::GetFullPath($ScenePath)
+ if ([System.IO.Path]::GetFileName($fullScenePath) -ine 'scene.pkg') {
+ throw 'Scene stream source must be scene.pkg.'
+ }
+ $reference = Read-DreamSkinWallpaperEngineProject -ProjectDirectory (Split-Path -Parent $fullScenePath)
+ if ($reference.MediaType -cne 'scene' -or $reference.MediaPath -ine $fullScenePath) {
+ throw 'Scene stream source does not match its Wallpaper Engine project.'
+ }
+ $paths = Get-DreamSkinThemePaths -StateRoot $StateRoot
+ Ensure-DreamSkinManagedDirectory -Path $paths.Active -Root $paths.Root
+ $opacity = 1
+ try {
+ $current = Read-DreamSkinTheme -ThemeDirectory $paths.Active -SkipImageMetadata
+ if ($current.MediaType -ceq 'scene') {
+ $opacity = Get-DreamSkinThemeMediaOpacity -Theme $current.Theme
+ }
+ } catch {}
+ $projectDirectory = $reference.WorkshopDirectory
+ $preview = Get-ChildItem -LiteralPath $projectDirectory -File -Force -ErrorAction SilentlyContinue |
+ Where-Object { $_.BaseName -ceq 'preview' -and $_.Extension.ToLowerInvariant() -in @('.jpg', '.jpeg', '.png', '.webp') } |
+ Select-Object -First 1
+ if ($null -eq $preview) {
+ $previewPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'assets\dream-reference.jpg'
+ } else {
+ $previewPath = $preview.FullName
+ }
+ Assert-DreamSkinImageFile -Path $previewPath
+ $previewName = 'scene-preview' + [System.IO.Path]::GetExtension($previewPath).ToLowerInvariant()
+ $activePreview = Join-Path $paths.Active $previewName
+ Copy-Item -LiteralPath $previewPath -Destination $activePreview -Force
+ Assert-DreamSkinImageFile -Path $activePreview
+ if (-not $Name) { $Name = $reference.Name }
+ $theme = [pscustomobject]@{
+ schemaVersion = 1
+ id = 'wallpaper-engine-scene-' + $reference.WorkshopId
+ name = $Name
+ image = $previewName
+ appearance = 'auto'
+ art = [pscustomobject]@{
+ focusX = $null
+ focusY = $null
+ safeArea = 'auto'
+ taskMode = 'auto'
+ }
+ palette = [pscustomobject]@{}
+ media = [pscustomobject]@{
+ type = 'scene'
+ streamUrl = $StreamUrl
+ codec = 'avc1.42c01f'
+ playbackRate = 1
+ opacity = $opacity
+ scenePath = $fullScenePath
+ workshopId = $reference.WorkshopId
+ }
+ }
+ Write-DreamSkinTheme -ThemeDirectory $paths.Active -Theme $theme
+ Get-ChildItem -LiteralPath $paths.Active -File -Force -ErrorAction SilentlyContinue |
+ Where-Object {
+ $_.Name -ne 'theme.json' -and $_.FullName -ine $activePreview
+ } |
+ Remove-Item -Force -ErrorAction SilentlyContinue
+ return Read-DreamSkinTheme -ThemeDirectory $paths.Active
+}
+
function Set-DreamSkinActiveTheme {
param(
[Parameter(Mandatory = $true)][string]$ImagePath,
@@ -358,6 +980,7 @@ function Set-DreamSkinActiveTheme {
try { $oldImage = (Read-DreamSkinTheme -ThemeDirectory $paths.Active).ImagePath } catch {}
if ($null -eq $Theme) {
$Theme = [pscustomobject]@{
+ schemaVersion = 1
id = 'custom'
name = '自定义主题'
appearance = 'auto'
@@ -366,6 +989,9 @@ function Set-DreamSkinActiveTheme {
media = [pscustomobject]@{ type = $mediaType; playbackRate = 1; opacity = 1 }
}
}
+ if (-not (Test-DreamSkinThemeSchemaV1 -Theme $Theme)) {
+ throw 'Theme schemaVersion must equal 1.'
+ }
$imageName = New-DreamSkinThemeImageName -Extension $extension
$target = Join-Path $paths.Active $imageName
$temporary = Join-Path $paths.Active ('.dream-tmp-' + [guid]::NewGuid().ToString('N') + $extension)
@@ -445,6 +1071,13 @@ function Save-DreamSkinCurrentTheme {
$id = (Get-Date).ToString('yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N').Substring(0, 8)
$destination = Join-Path $paths.Saved $id
Ensure-DreamSkinManagedDirectory -Path $destination -Root $paths.Root
+ $theme = $active.Theme | ConvertTo-Json -Depth 8 | ConvertFrom-Json
+ $theme.id = $id
+ $theme.name = $trimmed
+ if ($null -ne (Get-DreamSkinWallpaperEngineReference -Theme $theme)) {
+ Write-DreamSkinTheme -ThemeDirectory $destination -Theme $theme
+ return Read-DreamSkinTheme -ThemeDirectory $destination
+ }
$extension = [System.IO.Path]::GetExtension($active.ImagePath).ToLowerInvariant()
$imageName = 'art' + $extension
$destinationImage = Join-Path $destination $imageName
@@ -452,9 +1085,6 @@ function Save-DreamSkinCurrentTheme {
Copy-Item -LiteralPath $active.ImagePath -Destination $destinationImage -Force
Assert-DreamSkinNoReparseComponents -Path $destinationImage
$null = Assert-DreamSkinMediaFile -Path $destinationImage
- $theme = $active.Theme | ConvertTo-Json -Depth 8 | ConvertFrom-Json
- $theme.id = $id
- $theme.name = $trimmed
$theme.image = $imageName
Write-DreamSkinTheme -ThemeDirectory $destination -Theme $theme
return Read-DreamSkinTheme -ThemeDirectory $destination
@@ -477,6 +1107,7 @@ function Get-DreamSkinSavedThemes {
Id = "$($loaded.Theme.id)"
Name = if ($loaded.Theme.name) { "$($loaded.Theme.name)" } else { $directory.Name }
Path = $directory.FullName
+ MediaType = $loaded.MediaType
}
} catch {}
}
@@ -497,6 +1128,10 @@ function Use-DreamSkinSavedTheme {
}
$saved = Read-DreamSkinTheme -ThemeDirectory $directory
$theme = $saved.Theme | ConvertTo-Json -Depth 8 | ConvertFrom-Json
+ if ($null -ne (Get-DreamSkinWallpaperEngineReference -Theme $theme)) {
+ return Set-DreamSkinActiveWallpaperEngineTheme -ProjectDirectory `
+ (Split-Path -Parent $saved.ImagePath) -Theme $theme -StateRoot $StateRoot
+ }
return Set-DreamSkinActiveTheme -ImagePath $saved.ImagePath -Theme $theme -StateRoot $StateRoot
}
diff --git a/windows/tests/dynamic-media.test.mjs b/windows/tests/dynamic-media.test.mjs
index 811f970..4fb3b2f 100644
--- a/windows/tests/dynamic-media.test.mjs
+++ b/windows/tests/dynamic-media.test.mjs
@@ -7,11 +7,14 @@ import { fileURLToPath } from "node:url";
import {
isOpacityOnlyThemeChange,
setOpacityOnSession,
+ setStreamCspBypass,
transferVideoToSession,
} from "../scripts/injector.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
+const windowsRoot = path.resolve(here, "..");
const injectorPath = path.resolve(here, "../scripts/injector.mjs");
+const managerCommandPath = path.resolve(here, "../scripts/manager-command.ps1");
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "codex-dream-skin-video-"));
const themeDirectory = path.join(temporary, "theme");
const videoPath = path.join(themeDirectory, "loop.mp4");
@@ -32,6 +35,27 @@ const runInjector = (directory) => new Promise((resolve, reject) => {
child.once("close", (code) => resolve({ code, stdout, stderr }));
});
+const runManagerCommand = (argumentsList) => new Promise((resolve, reject) => {
+ const child = spawn("powershell.exe", [
+ "-NoProfile",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-File",
+ managerCommandPath,
+ ...argumentsList,
+ ], { stdio: ["ignore", "pipe", "pipe"] });
+ const stdout = [];
+ const stderr = [];
+ child.stdout.on("data", (chunk) => { stdout.push(chunk); });
+ child.stderr.on("data", (chunk) => { stderr.push(chunk); });
+ child.once("error", reject);
+ child.once("close", (code) => resolve({
+ code,
+ stdout: Buffer.concat(stdout),
+ stderr: Buffer.concat(stderr).toString("utf8"),
+ }));
+});
+
try {
await fs.mkdir(themeDirectory, { recursive: true });
const videoBytes = Buffer.alloc(900_000, 0x2a);
@@ -40,7 +64,7 @@ try {
videoBytes.write("isom", 8, "ascii");
await fs.writeFile(videoPath, videoBytes);
await fs.writeFile(path.join(themeDirectory, "theme.json"), JSON.stringify({
- schemaVersion: 2,
+ schemaVersion: 1,
id: "video-fixture",
name: "Video Fixture",
image: "loop.mp4",
@@ -59,6 +83,167 @@ try {
assert.ok(summary.payloadBytes < videoBytes.length,
"The CDP bootstrap payload must not embed the complete video.");
+ const streamThemeDirectory = path.join(temporary, "scene-stream-theme");
+ const streamUrl = "http://127.0.0.1:17866/1234567890abcdef1234567890abcdef/stream.mp4";
+ await fs.mkdir(streamThemeDirectory);
+ await fs.copyFile(
+ path.join(windowsRoot, "assets", "dream-reference.jpg"),
+ path.join(streamThemeDirectory, "preview.jpg"),
+ );
+ await fs.writeFile(path.join(streamThemeDirectory, "theme.json"), JSON.stringify({
+ schemaVersion: 1,
+ id: "scene-stream-fixture",
+ name: "Scene Stream Fixture",
+ image: "preview.jpg",
+ media: {
+ type: "scene",
+ streamUrl,
+ codec: "avc1.42c01f",
+ opacity: 0.7,
+ },
+ }));
+ const streamChecked = await runInjector(streamThemeDirectory);
+ assert.equal(streamChecked.code, 0, streamChecked.stderr);
+ const streamSummary = JSON.parse(streamChecked.stdout);
+ assert.equal(streamSummary.media.type, "video");
+ assert.equal(streamSummary.media.streamUrl, streamUrl);
+ assert.equal(streamSummary.media.codec, "avc1.42c01f");
+ assert.equal(streamSummary.media.mime, "video/mp4");
+
+ const remoteStreamDirectory = path.join(temporary, "remote-scene-stream-theme");
+ await fs.mkdir(remoteStreamDirectory);
+ await fs.copyFile(
+ path.join(windowsRoot, "assets", "dream-reference.jpg"),
+ path.join(remoteStreamDirectory, "preview.jpg"),
+ );
+ await fs.writeFile(path.join(remoteStreamDirectory, "theme.json"), JSON.stringify({
+ schemaVersion: 1,
+ id: "remote-scene-stream-fixture",
+ image: "preview.jpg",
+ media: {
+ type: "scene",
+ streamUrl: "http://192.168.1.5:17866/1234567890abcdef1234567890abcdef/stream.mp4",
+ codec: "avc1.42c01f",
+ },
+ }));
+ const rejectedRemoteStream = await runInjector(remoteStreamDirectory);
+ assert.notEqual(rejectedRemoteStream.code, 0);
+ assert.match(rejectedRemoteStream.stderr, /loopback/i);
+
+ const steamLibrary = path.join(temporary, "Steam Library");
+ const workshopRoot = path.join(
+ steamLibrary,
+ "steamapps",
+ "workshop",
+ "content",
+ "431960",
+ );
+ const workshopId = "1234567890";
+ const workshopDirectory = path.join(workshopRoot, workshopId);
+ const referencedThemeDirectory = path.join(temporary, "referenced-theme");
+ await fs.mkdir(workshopDirectory, { recursive: true });
+ await fs.mkdir(referencedThemeDirectory);
+ await fs.copyFile(videoPath, path.join(workshopDirectory, "wallpaper.mp4"));
+ await fs.writeFile(path.join(workshopDirectory, "project.json"), JSON.stringify({
+ type: "video",
+ file: "wallpaper.mp4",
+ title: "本地动态壁纸",
+ }));
+ const sceneWorkshopId = "1234567891";
+ const sceneWorkshopDirectory = path.join(workshopRoot, sceneWorkshopId);
+ await fs.mkdir(sceneWorkshopDirectory);
+ await fs.writeFile(path.join(sceneWorkshopDirectory, "project.json"), JSON.stringify({
+ type: "scene",
+ file: "scene.json",
+ title: "本地场景壁纸",
+ }));
+ await fs.writeFile(path.join(sceneWorkshopDirectory, "scene.pkg"), Buffer.alloc(1024, 0x5a));
+ await fs.copyFile(
+ path.join(windowsRoot, "assets", "dream-reference.jpg"),
+ path.join(sceneWorkshopDirectory, "preview.jpg"),
+ );
+ await fs.writeFile(path.join(referencedThemeDirectory, "theme.json"), JSON.stringify({
+ schemaVersion: 1,
+ id: "wallpaper-engine-fixture",
+ name: "Wallpaper Engine Fixture",
+ image: "wallpaper.mp4",
+ media: {
+ type: "video",
+ source: "wallpaper-engine-local",
+ workshopId,
+ workshopRoot,
+ relativePath: "wallpaper.mp4",
+ },
+ }));
+ const referenced = await runInjector(referencedThemeDirectory);
+ assert.equal(referenced.code, 0, referenced.stderr);
+ const referencedSummary = JSON.parse(referenced.stdout);
+ assert.equal(referencedSummary.media.type, "video");
+ assert.equal(referencedSummary.media.size, videoBytes.length);
+
+ if (process.platform === "win32") {
+ const managerListing = await runManagerCommand([
+ "-Action", "ListWallpaperEngine",
+ "-SteamLibraryPath", steamLibrary,
+ ]);
+ assert.equal(managerListing.code, 0, managerListing.stderr);
+ const managerPayload = JSON.parse(managerListing.stdout.toString("utf8"));
+ assert.equal(managerPayload.items.find((item) => item.workshopId === workshopId)?.name, "本地动态壁纸",
+ "Manager command JSON must stay UTF-8 when the Wallpaper Engine title contains Chinese.");
+ const listedScene = managerPayload.items.find((item) => item.workshopId === sceneWorkshopId);
+ assert.equal(listedScene?.name, "本地场景壁纸");
+ assert.equal(listedScene?.mediaType, "scene");
+ assert.equal(listedScene?.relativePath, "scene.pkg");
+
+ const sceneStateRoot = path.join(temporary, "scene-state");
+ const sceneApplied = await runManagerCommand([
+ "-Action", "UseSceneStream",
+ "-Path", path.join(sceneWorkshopDirectory, "scene.pkg"),
+ "-StreamUrl", streamUrl,
+ "-StateRoot", sceneStateRoot,
+ ]);
+ assert.equal(sceneApplied.code, 0, sceneApplied.stderr);
+ const sceneAppliedPayload = JSON.parse(sceneApplied.stdout.toString("utf8"));
+ assert.equal(sceneAppliedPayload.mediaType, "scene");
+ const checkedAppliedScene = await runInjector(path.join(sceneStateRoot, "active-theme"));
+ assert.equal(checkedAppliedScene.code, 0, checkedAppliedScene.stderr);
+ assert.equal(JSON.parse(checkedAppliedScene.stdout).media.streamUrl, streamUrl);
+ }
+
+ const escapedReferenceDirectory = path.join(temporary, "escaped-reference");
+ await fs.mkdir(escapedReferenceDirectory);
+ await fs.writeFile(path.join(escapedReferenceDirectory, "theme.json"), JSON.stringify({
+ schemaVersion: 1,
+ id: "escaped-wallpaper-engine-fixture",
+ image: "../wallpaper.mp4",
+ media: {
+ type: "video",
+ source: "wallpaper-engine-local",
+ workshopId,
+ workshopRoot,
+ relativePath: "../wallpaper.mp4",
+ },
+ }));
+ const escapedReference = await runInjector(escapedReferenceDirectory);
+ assert.notEqual(escapedReference.code, 0);
+ assert.match(escapedReference.stderr, /remain inside|relative path/i);
+
+ const macPaletteDirectory = path.join(temporary, "mac-palette");
+ await fs.mkdir(macPaletteDirectory);
+ await fs.copyFile(videoPath, path.join(macPaletteDirectory, "loop.mp4"));
+ await fs.writeFile(path.join(macPaletteDirectory, "theme.json"), JSON.stringify({
+ schemaVersion: 1,
+ id: "mac-palette-fixture",
+ image: "loop.mp4",
+ colors: { accent: "#c93d4c" },
+ media: { type: "video" },
+ }));
+ const macPaletteChecked = await runInjector(macPaletteDirectory);
+ assert.equal(macPaletteChecked.code, 0, macPaletteChecked.stderr);
+ const macPaletteSummary = JSON.parse(macPaletteChecked.stdout);
+ assert.deepEqual(macPaletteSummary.palette, { accent: "#c93d4c" },
+ "Windows must map the macOS colors.accent field to its palette contract.");
+
const expressions = [];
const session = {
async evaluate(expression) {
@@ -88,6 +273,21 @@ try {
assert.equal(await setOpacityOnSession(opacitySession, 0.65), 0.65);
assert.match(opacityExpressions[0], /setWallpaperReveal/);
+ const cspCommands = [];
+ const cspSession = {
+ async send(method, params) {
+ cspCommands.push({ method, params });
+ return {};
+ },
+ };
+ assert.equal(await setStreamCspBypass(cspSession, true), true);
+ assert.equal(await setStreamCspBypass(cspSession, true), true);
+ assert.equal(await setStreamCspBypass(cspSession, false), false);
+ assert.deepEqual(cspCommands, [
+ { method: "Page.setBypassCSP", params: { enabled: true } },
+ { method: "Page.setBypassCSP", params: { enabled: false } },
+ ], "Scene streaming must scope CSP bypass to the active loopback stream.");
+
const previousTheme = {
mediaPath: videoPath,
mediaType: "video",
@@ -105,6 +305,7 @@ try {
await fs.mkdir(invalidDirectory);
await fs.writeFile(path.join(invalidDirectory, "fake.mp4"), Buffer.from("not an mp4"));
await fs.writeFile(path.join(invalidDirectory, "theme.json"), JSON.stringify({
+ schemaVersion: 1,
id: "invalid-video",
image: "fake.mp4",
media: { type: "video" },
@@ -117,6 +318,7 @@ try {
await fs.mkdir(invalidOpacityDirectory);
await fs.copyFile(videoPath, path.join(invalidOpacityDirectory, "loop.mp4"));
await fs.writeFile(path.join(invalidOpacityDirectory, "theme.json"), JSON.stringify({
+ schemaVersion: 1,
id: "invalid-opacity",
image: "loop.mp4",
media: { type: "video", opacity: 1.1 },
@@ -124,6 +326,19 @@ try {
const rejectedOpacity = await runInjector(invalidOpacityDirectory);
assert.notEqual(rejectedOpacity.code, 0);
assert.match(rejectedOpacity.stderr, /opacity/i);
+
+ const unsupportedSchemaDirectory = path.join(temporary, "unsupported-schema");
+ await fs.mkdir(unsupportedSchemaDirectory);
+ await fs.copyFile(videoPath, path.join(unsupportedSchemaDirectory, "loop.mp4"));
+ await fs.writeFile(path.join(unsupportedSchemaDirectory, "theme.json"), JSON.stringify({
+ schemaVersion: 2,
+ id: "unsupported-schema",
+ image: "loop.mp4",
+ media: { type: "video" },
+ }));
+ const rejectedSchema = await runInjector(unsupportedSchemaDirectory);
+ assert.notEqual(rejectedSchema.code, 0);
+ assert.match(rejectedSchema.stderr, /schema/i);
} finally {
await fs.rm(temporary, { recursive: true, force: true });
}
diff --git a/windows/tests/renderer-inject.test.mjs b/windows/tests/renderer-inject.test.mjs
index da9a6ad..1c15079 100644
--- a/windows/tests/renderer-inject.test.mjs
+++ b/windows/tests/renderer-inject.test.mjs
@@ -46,8 +46,13 @@ assert.match(
);
assert.match(
css,
- /\.dream-art-video main\.main-surface\s*>\s*header\.app-header-tint\s*\{[^}]*background:\s*color-mix\([^}]*backdrop-filter:\s*blur\(/,
- "Dynamic wallpaper mode must protect the secondary Codex toolbar from changing video frames.",
+ /\.dream-art-video main\.main-surface\s*>\s*header\.app-header-tint\s*\{[^}]*background:\s*var\(--dream-immersive-composer\) !important;[^}]*backdrop-filter:\s*none !important;/,
+ "Dynamic wallpaper mode must keep the secondary Codex toolbar responsive to the shared veil without live blur.",
+);
+assert.match(
+ css,
+ /\.dream-art-video \[class~="group\/application-menu-top-bar"\]\s*\{[^}]*background:\s*var\(--dream-immersive-composer\) !important;[^}]*backdrop-filter:\s*none !important;/,
+ "Dynamic wallpaper mode must avoid live blur on the native application menu.",
);
assert.match(
css,
@@ -64,6 +69,16 @@ assert.match(
/--dream-immersive-edge:\s*color-mix\([^;]*var\(--dream-wallpaper-cover\)[^;]*transparent\)/,
"The full-window foreground veil must disappear as wallpaper reveal reaches 100%.",
);
+assert.match(
+ css,
+ /--dream-immersive-composer:\s*color-mix\([^;]*var\(--dream-wallpaper-cover\)[^;]*transparent\)/,
+ "Composer and toolbar surfaces must be fully transparent at 100% wallpaper reveal.",
+);
+assert.match(
+ css,
+ /main\.main-surface\.dream-home-shell,[\s\S]*?background-position:\s*0 var\(--height-token-toolbar, 46px\) !important;/,
+ "Home and task route veils must start below the fixed secondary toolbar to avoid a double tint.",
+);
function createFixture({
shellPresent,
@@ -76,6 +91,7 @@ function createFixture({
computedColorScheme = "",
osAppearance = "light",
analysisFixture = null,
+ streamFixture = null,
}) {
const nodes = new Map();
const rootClasses = new Set(staleSkin ? ["codex-dream-skin"] : []);
@@ -84,6 +100,16 @@ function createFixture({
const observers = [];
const documentListeners = new Map();
let objectUrlCount = 0;
+ let styleWriteCount = 0;
+ let intervalDelay = null;
+ let mediaPlayCount = 0;
+ let mediaPauseCount = 0;
+ let streamAppendCount = 0;
+ let streamFetchCount = 0;
+ let streamFetchUrl = null;
+ let streamAborted = false;
+ let streamEnded = false;
+ let pendingReadResolve = null;
let hasShell = shellPresent;
let documentHidden = false;
let root;
@@ -97,22 +123,17 @@ function createFixture({
};
const makeClassList = (classes = new Set(), onMutation = () => {}) => ({
add(...values) {
- let changed = false;
- for (const value of values) {
- if (!classes.has(value)) { classes.add(value); changed = true; }
- }
- if (changed) onMutation();
+ for (const value of values) classes.add(value);
+ if (values.length > 0) onMutation();
},
remove(...values) {
- let changed = false;
- for (const value of values) changed = classes.delete(value) || changed;
- if (changed) onMutation();
+ for (const value of values) classes.delete(value);
+ if (values.length > 0) onMutation();
},
toggle(value, enabled) {
- const changed = enabled ? !classes.has(value) : classes.has(value);
if (enabled) classes.add(value);
else classes.delete(value);
- if (changed) onMutation();
+ onMutation();
},
contains(value) { return classes.has(value); },
});
@@ -122,7 +143,8 @@ function createFixture({
classList: makeClassList(rootClasses, queueRootClassMutation),
getAttribute() { return null; },
style: {
- setProperty(key, value) { rootStyles.set(key, value); },
+ getPropertyValue(key) { return rootStyles.get(key) ?? ""; },
+ setProperty(key, value) { styleWriteCount += 1; rootStyles.set(key, value); },
removeProperty(key) { rootStyles.delete(key); },
},
appendChild(node) {
@@ -148,6 +170,11 @@ function createFixture({
const routeClasses = new Set();
const utilityClasses = new Set();
const utilityNode = { classList: makeClassList(utilityClasses) };
+ const homeIcon = {
+ closest(selector) {
+ return selector === '[role="main"]' && hasShell && homePresent ? routeMain : null;
+ },
+ };
const routeMain = {
classList: makeClassList(routeClasses),
querySelectorAll(selector) {
@@ -184,8 +211,8 @@ function createFixture({
setAttribute() {},
removeAttribute(name) { if (name === "src") this.src = ""; },
load() {},
- pause() { this.paused = true; },
- play() { this.paused = false; return Promise.resolve(); },
+ pause() { mediaPauseCount += 1; this.paused = true; },
+ play() { mediaPlayCount += 1; this.paused = false; return Promise.resolve(); },
remove() { nodes.delete(this.id); },
};
};
@@ -212,9 +239,7 @@ function createFixture({
querySelector(selector) {
if (selector === "main.main-surface") return hasShell ? shellMain : null;
if (selector === "aside.app-shell-left-panel") return hasShell && sidebarPresent ? {} : null;
- if (selector === '[role="main"]:has([data-testid="home-icon"])') {
- return hasShell && homePresent ? routeMain : null;
- }
+ if (selector === '[data-testid="home-icon"]') return hasShell && homePresent ? homeIcon : null;
return null;
},
querySelectorAll(selector) {
@@ -234,6 +259,55 @@ function createFixture({
return [];
},
};
+ class FixtureUrl extends globalThis.URL {
+ static createObjectURL() { objectUrlCount += 1; return `blob:fixture-${objectUrlCount}`; }
+ static revokeObjectURL(value) { revokedUrls.push(value); }
+ }
+ class FixtureSourceBuffer {
+ updating = false;
+ buffered = { length: 0, start() { return 0; }, end() { return 0; } };
+ listeners = new Map();
+ addEventListener(type, listener) { this.listeners.set(type, listener); }
+ removeEventListener(type, listener) {
+ if (this.listeners.get(type) === listener) this.listeners.delete(type);
+ }
+ appendBuffer() {
+ this.updating = true;
+ streamAppendCount += 1;
+ queueMicrotask(() => {
+ this.updating = false;
+ this.listeners.get("updateend")?.();
+ });
+ }
+ remove() {
+ this.updating = true;
+ queueMicrotask(() => {
+ this.updating = false;
+ this.listeners.get("updateend")?.();
+ });
+ }
+ }
+ class FixtureMediaSource {
+ static isTypeSupported(mime) {
+ return mime === 'video/mp4; codecs="avc1.42c01f"';
+ }
+ readyState = "closed";
+ listeners = new Map();
+ addEventListener(type, listener) {
+ this.listeners.set(type, listener);
+ if (type === "sourceopen") {
+ queueMicrotask(() => {
+ this.readyState = "open";
+ listener();
+ });
+ }
+ }
+ addSourceBuffer() { return new FixtureSourceBuffer(); }
+ endOfStream() { streamEnded = true; this.readyState = "ended"; }
+ }
+ class FixtureAbortController extends globalThis.AbortController {
+ abort() { streamAborted = true; super.abort(); }
+ }
const context = {
window: {
matchMedia() { return { matches: osAppearance === "dark" }; },
@@ -261,19 +335,48 @@ function createFixture({
return records;
}
},
- URL: {
- createObjectURL() { objectUrlCount += 1; return `blob:fixture-${objectUrlCount}`; },
- revokeObjectURL(value) { revokedUrls.push(value); },
- },
+ URL: FixtureUrl,
Blob,
Uint8Array,
atob,
- setInterval: () => 1,
+ setInterval: (_, delay) => { intervalDelay = delay; return 1; },
clearInterval: () => {},
setTimeout: () => 2,
clearTimeout: () => {},
getComputedStyle() { return { colorScheme: computedColorScheme }; },
};
+ if (streamFixture) {
+ const chunks = streamFixture.chunks.map((chunk) => new Uint8Array(chunk));
+ context.MediaSource = FixtureMediaSource;
+ context.AbortController = FixtureAbortController;
+ context.fetch = async (url) => {
+ streamFetchCount += 1;
+ streamFetchUrl = url;
+ if (streamFixture.error) throw new Error(streamFixture.error);
+ let index = 0;
+ return {
+ ok: true,
+ status: 200,
+ body: {
+ getReader() {
+ return {
+ async read() {
+ if (index >= chunks.length) {
+ if (!streamFixture.pending) return { done: true, value: undefined };
+ return new Promise((resolve) => { pendingReadResolve = resolve; });
+ }
+ return { done: false, value: chunks[index++] };
+ },
+ async cancel() {
+ pendingReadResolve?.({ done: true, value: undefined });
+ pendingReadResolve = null;
+ },
+ };
+ },
+ },
+ };
+ };
+ }
if (analysisFixture) {
context.Image = class {
naturalWidth = analysisFixture.naturalWidth;
@@ -288,6 +391,15 @@ function createFixture({
observers,
rootClasses,
rootStyles,
+ get styleWriteCount() { return styleWriteCount; },
+ get intervalDelay() { return intervalDelay; },
+ get mediaPlayCount() { return mediaPlayCount; },
+ get mediaPauseCount() { return mediaPauseCount; },
+ get streamAppendCount() { return streamAppendCount; },
+ get streamFetchCount() { return streamFetchCount; },
+ get streamFetchUrl() { return streamFetchUrl; },
+ get streamAborted() { return streamAborted; },
+ get streamEnded() { return streamEnded; },
revokedUrls,
routeClasses,
shellMainClasses,
@@ -311,6 +423,19 @@ assert.equal(main.rootClasses.has("dream-theme-dark"), true);
assert.equal(main.rootClasses.has("dream-art-standard"), true);
assert.equal(main.rootClasses.has("dream-task-ambient"), true);
assert.equal(main.routeClasses.has("dream-task"), true);
+assert.equal(main.intervalDelay, 15000,
+ "The periodic recovery pass must stay infrequent enough to avoid regular UI scans.");
+assert.equal(main.observers.length, 2);
+assert.equal(main.observers[0].options.attributes, undefined,
+ "The subtree observer must not subscribe to high-frequency class mutations.");
+assert.equal(main.observers[0].options.childList, true);
+assert.equal(main.observers[1].target, main.context.document.documentElement);
+assert.equal(main.observers[1].options.attributes, true,
+ "Appearance changes should be observed only on the document root.");
+const initialStyleWriteCount = main.styleWriteCount;
+main.context.window.__CODEX_DREAM_SKIN_STATE__.ensure();
+assert.equal(main.styleWriteCount, initialStyleWriteCount,
+ "An unchanged ensure pass must not invalidate root styles.");
assert.equal(main.context.window.__CODEX_DREAM_SKIN_STATE__.cleanup(), true);
assert.equal(main.rootClasses.has("codex-dream-skin"), false);
assert.equal(main.rootClasses.has("dream-theme-dark"), false);
@@ -469,9 +594,18 @@ assert.equal(videoTheme.nodes.has("codex-dream-skin-media"), true);
assert.equal(videoTheme.nodes.get("codex-dream-skin-media").muted, true);
assert.equal(videoTheme.nodes.get("codex-dream-skin-media").loop, true);
assert.equal(videoTheme.nodes.get("codex-dream-skin-media").paused, false);
+const initialMediaPlayCount = videoTheme.mediaPlayCount;
+videoState.ensure();
+assert.equal(videoTheme.mediaPlayCount, initialMediaPlayCount,
+ "An unchanged ensure pass must not call play() again for an already playing video.");
+assert.equal(videoState.setWallpaperReveal(0), 0);
+assert.equal(videoTheme.rootStyles.get("--dream-wallpaper-cover"), "100%");
+assert.equal(videoTheme.nodes.get("codex-dream-skin-media").paused, true,
+ "A fully covered dynamic wallpaper must stop decoding invisible frames.");
assert.equal(videoState.setWallpaperReveal(0.25), 0.25);
assert.equal(videoTheme.rootStyles.get("--dream-wallpaper-reveal"), "0.25");
assert.equal(videoTheme.rootStyles.get("--dream-wallpaper-cover"), "75%");
+assert.equal(videoTheme.nodes.get("codex-dream-skin-media").paused, false);
assert.equal(videoState.setWallpaperReveal(2), null);
videoTheme.setDocumentHidden(true);
assert.equal(videoTheme.nodes.get("codex-dream-skin-media").paused, true);
@@ -482,4 +616,91 @@ assert.equal(videoState.cleanup(), true);
assert.equal(videoTheme.nodes.has("codex-dream-skin-media"), false);
assert.deepEqual(videoTheme.revokedUrls, ["blob:fixture-1"]);
+const streamUrl = "http://127.0.0.1:17866/1234567890abcdef1234567890abcdef/stream.mp4";
+const sceneStream = createFixture({
+ shellPresent: true,
+ streamFixture: { chunks: [[0, 0, 0, 24], [102, 116, 121, 112]] },
+});
+vm.runInNewContext(buildPayload({
+ media: {
+ type: "video",
+ mime: "video/mp4",
+ streamUrl,
+ codec: "avc1.42c01f",
+ opacity: 1,
+ },
+ artMetadata: { ratio: 16 / 9 },
+}, ""), sceneStream.context);
+for (let index = 0; index < 8; index += 1) await new Promise((resolve) => setImmediate(resolve));
+const sceneStreamState = sceneStream.context.window.__CODEX_DREAM_SKIN_STATE__;
+assert.equal(sceneStreamState.config.streamUrl, streamUrl);
+assert.equal(sceneStream.streamFetchUrl, streamUrl);
+assert.equal(sceneStream.streamAppendCount, 2);
+assert.equal(sceneStream.streamEnded, true);
+assert.equal(sceneStreamState.streamStats.status, "ended");
+assert.equal(sceneStreamState.streamStats.chunks, 2);
+assert.equal(sceneStreamState.streamStats.bytes, 8);
+assert.equal(sceneStreamState.streamStats.error, null);
+assert.equal(sceneStream.nodes.get("codex-dream-skin-media").loop, false,
+ "A continuous scene stream must not loop the MediaSource URL.");
+assert.equal(sceneStreamState.cleanup(), true);
+assert.equal(sceneStream.streamAborted, true);
+
+const hiddenSceneStream = createFixture({
+ shellPresent: true,
+ streamFixture: { chunks: [[0, 0, 0, 24]], pending: true },
+});
+vm.runInNewContext(buildPayload({
+ media: {
+ type: "video",
+ mime: "video/mp4",
+ streamUrl,
+ codec: "avc1.42c01f",
+ opacity: 1,
+ },
+}, ""), hiddenSceneStream.context);
+for (let index = 0; index < 4; index += 1) await new Promise((resolve) => setImmediate(resolve));
+const hiddenSceneState = hiddenSceneStream.context.window.__CODEX_DREAM_SKIN_STATE__;
+assert.equal(hiddenSceneStream.streamFetchCount, 1);
+hiddenSceneStream.setDocumentHidden(true);
+for (let index = 0; index < 2; index += 1) await new Promise((resolve) => setImmediate(resolve));
+assert.equal(hiddenSceneStream.streamAborted, true,
+ "A hidden Codex document must abort the local scene stream instead of appending in the background.");
+assert.equal(hiddenSceneState.streamStats.status, "aborted");
+hiddenSceneStream.setDocumentHidden(false);
+for (let index = 0; index < 4; index += 1) await new Promise((resolve) => setImmediate(resolve));
+assert.equal(hiddenSceneStream.streamFetchCount, 2,
+ "The local scene stream must reconnect when the Codex document becomes visible again.");
+assert.equal(hiddenSceneState.cleanup(), true);
+
+const failedStream = createFixture({
+ shellPresent: true,
+ streamFixture: { chunks: [], error: "Refused to connect by Content Security Policy" },
+});
+vm.runInNewContext(buildPayload({
+ media: {
+ type: "video",
+ mime: "video/mp4",
+ streamUrl,
+ codec: "avc1.42c01f",
+ },
+}, ""), failedStream.context);
+for (let index = 0; index < 4; index += 1) await new Promise((resolve) => setImmediate(resolve));
+const failedStreamStats = failedStream.context.window.__CODEX_DREAM_SKIN_STATE__.streamStats;
+assert.equal(failedStreamStats.status, "error");
+assert.equal(failedStreamStats.error, "Refused to connect by Content Security Policy");
+
+const remoteStream = createFixture({ shellPresent: true, streamFixture: { chunks: [] } });
+vm.runInNewContext(buildPayload({
+ media: {
+ type: "video",
+ mime: "video/mp4",
+ streamUrl: "http://192.168.1.5:17866/1234567890abcdef1234567890abcdef/stream.mp4",
+ },
+}, ""), remoteStream.context);
+await new Promise((resolve) => setImmediate(resolve));
+assert.equal(remoteStream.context.window.__CODEX_DREAM_SKIN_STATE__.config.streamUrl, null);
+assert.equal(remoteStream.streamFetchUrl, null,
+ "Renderer defense-in-depth must reject non-loopback stream URLs.");
+
console.log("PASS: renderer applies adaptive theme metadata and preserves transparent auxiliary windows.");
diff --git a/windows/tests/run-tests.ps1 b/windows/tests/run-tests.ps1
index cf239e2..8f71f07 100644
--- a/windows/tests/run-tests.ps1
+++ b/windows/tests/run-tests.ps1
@@ -639,6 +639,24 @@ try {
-not (Test-DreamSkinThemePathWithin -Path $updatedTheme.ImagePath -Root $themePaths.Active)) {
throw 'Imported image did not reset to the generic adaptive contract inside the managed directory.'
}
+ $activeMediaBeforeSchemaReject = @(
+ Get-ChildItem -LiteralPath $themePaths.Active -File | Select-Object -ExpandProperty Name | Sort-Object
+ )
+ $unsupportedInputThemeRejected = $false
+ try {
+ $null = Set-DreamSkinActiveTheme -ImagePath (Join-Path $Root 'assets\dream-reference.jpg') `
+ -Theme ([pscustomobject]@{ schemaVersion = 2; id = 'unsupported'; image = 'ignored.jpg' }) `
+ -StateRoot $themeStateRoot
+ } catch {
+ $unsupportedInputThemeRejected = $true
+ }
+ $activeMediaAfterSchemaReject = @(
+ Get-ChildItem -LiteralPath $themePaths.Active -File | Select-Object -ExpandProperty Name | Sort-Object
+ )
+ if (-not $unsupportedInputThemeRejected -or
+ (@($activeMediaBeforeSchemaReject) -join '|') -cne (@($activeMediaAfterSchemaReject) -join '|')) {
+ throw 'A non-v1 caller theme changed active media before being rejected.'
+ }
$opacityTheme = Set-DreamSkinActiveThemeMediaOpacity -Opacity 0.42 -StateRoot $themeStateRoot
if ([math]::Abs((Get-DreamSkinThemeMediaOpacity -Theme $opacityTheme.Theme) - 0.42) -gt 0.000001) {
throw 'Active theme opacity was not persisted.'
@@ -697,6 +715,173 @@ try {
if ($restoredVideoTheme.Theme.media.type -cne 'video') {
throw 'Saved dynamic theme did not preserve its media contract.'
}
+
+ $managerCommandPath = Join-Path $Root 'scripts\manager-command.ps1'
+ function Invoke-DreamSkinManagerCommandForTest {
+ param([Parameter(Mandatory = $true)][string[]]$Arguments)
+ $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $managerCommandPath @Arguments 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ throw "Manager command failed: $($output -join [Environment]::NewLine)"
+ }
+ return (($output -join [Environment]::NewLine) | ConvertFrom-Json -ErrorAction Stop)
+ }
+ $newManagerStateRoot = Join-Path $temporaryRoot 'new-manager-state'
+ $managerSeededThemes = Invoke-DreamSkinManagerCommandForTest -Arguments @(
+ '-Action', 'ListThemes', '-StateRoot', $newManagerStateRoot
+ )
+ if (@($managerSeededThemes.themes).Count -ne 1 -or
+ $managerSeededThemes.themes[0].id -cne 'preset-romantic-rose') {
+ throw 'Manager command did not initialize a new theme store before listing saved themes.'
+ }
+ $managerSavedThemes = Invoke-DreamSkinManagerCommandForTest -Arguments @(
+ '-Action', 'ListThemes', '-StateRoot', $themeStateRoot
+ )
+ if ($managerSavedThemes.action -cne 'ListThemes' -or
+ @($managerSavedThemes.themes).Count -ne 3 -or
+ @($managerSavedThemes.themes | Where-Object { $_.mediaType -ceq 'video' }).Count -lt 1) {
+ throw 'Manager command did not list saved static and dynamic themes.'
+ }
+ $managerSavedTheme = Invoke-DreamSkinManagerCommandForTest -Arguments @(
+ '-Action', 'SaveTheme', '-Name', '管理器保存的动态主题', '-StateRoot', $themeStateRoot
+ )
+ if ($managerSavedTheme.action -cne 'SaveTheme' -or
+ $managerSavedTheme.name -cne '管理器保存的动态主题' -or
+ $managerSavedTheme.mediaType -cne 'video' -or -not $managerSavedTheme.themeId) {
+ throw 'Manager command did not save the active dynamic theme.'
+ }
+ $managerAppliedTheme = Invoke-DreamSkinManagerCommandForTest -Arguments @(
+ '-Action', 'UseTheme', '-ThemeId', "$($managerSavedTheme.themeId)", '-StateRoot', $themeStateRoot
+ )
+ if ($managerAppliedTheme.action -cne 'UseTheme' -or
+ $managerAppliedTheme.themeId -cne $managerSavedTheme.themeId -or
+ $managerAppliedTheme.mediaType -cne 'video' -or
+ (Read-DreamSkinTheme -ThemeDirectory $themePaths.Active).Theme.id -cne $managerSavedTheme.themeId) {
+ throw 'Manager command did not apply the selected saved dynamic theme.'
+ }
+
+ $steamLibrary = Join-Path $temporaryRoot 'Steam Library'
+ $wallpaperEngineContent = Join-Path $steamLibrary 'steamapps\workshop\content\431960'
+ $wallpaperEngineItem = Join-Path $wallpaperEngineContent '1234567890'
+ New-Item -ItemType Directory -Path $wallpaperEngineItem -Force | Out-Null
+ $wallpaperEngineVideo = Join-Path $wallpaperEngineItem 'wallpaper.mp4'
+ Copy-Item -LiteralPath $videoFixture -Destination $wallpaperEngineVideo
+ Write-DreamSkinUtf8FileAtomically -Path (Join-Path $wallpaperEngineItem 'project.json') -Content @'
+{"type":"video","file":"wallpaper.mp4","title":"本地动态壁纸"}
+'@
+ $referencedTheme = Set-DreamSkinActiveWallpaperEngineTheme -ProjectDirectory $wallpaperEngineItem `
+ -Name 'Wallpaper Engine 本地引用' -StateRoot $themeStateRoot
+ if ($referencedTheme.MediaPath -cne [System.IO.Path]::GetFullPath($wallpaperEngineVideo) -or
+ $referencedTheme.Theme.media.source -cne 'wallpaper-engine-local' -or
+ $referencedTheme.Theme.media.workshopId -cne '1234567890' -or
+ $referencedTheme.Theme.media.relativePath -cne 'wallpaper.mp4' -or
+ (Test-Path -LiteralPath (Join-Path $themePaths.Active 'wallpaper.mp4'))) {
+ throw 'Wallpaper Engine video was not retained as a direct local reference.'
+ }
+ New-Item -ItemType Directory -Path $themePaths.MediaCache -Force | Out-Null
+ $performanceProxy = Join-Path $themePaths.MediaCache '1234567890-test-1080p.mp4'
+ Copy-Item -LiteralPath $videoFixture -Destination $performanceProxy
+ $referencedTheme.Theme.media | Add-Member -NotePropertyName proxy `
+ -NotePropertyValue 'media-cache/1234567890-test-1080p.mp4' -Force
+ Write-DreamSkinTheme -ThemeDirectory $themePaths.Active -Theme $referencedTheme.Theme
+ $referencedTheme = Read-DreamSkinTheme -ThemeDirectory $themePaths.Active
+ if ($referencedTheme.ImagePath -cne [System.IO.Path]::GetFullPath($wallpaperEngineVideo) -or
+ $referencedTheme.MediaPath -cne [System.IO.Path]::GetFullPath($performanceProxy)) {
+ throw 'Wallpaper Engine performance proxy did not preserve its original existence reference.'
+ }
+ $savedReferencedTheme = Save-DreamSkinCurrentTheme -Name '保存的本地引用' -StateRoot $themeStateRoot
+ if ($savedReferencedTheme.Theme.media.source -cne 'wallpaper-engine-local' -or
+ (Test-Path -LiteralPath (Join-Path $savedReferencedTheme.Directory 'wallpaper.mp4'))) {
+ throw 'Saving a Wallpaper Engine reference copied its media instead of preserving the mapping.'
+ }
+ $restoredReferencedTheme = Use-DreamSkinSavedTheme -ThemeDirectory $savedReferencedTheme.Directory `
+ -StateRoot $themeStateRoot
+ if ($restoredReferencedTheme.ImagePath -cne [System.IO.Path]::GetFullPath($wallpaperEngineVideo) -or
+ $restoredReferencedTheme.MediaPath -cne [System.IO.Path]::GetFullPath($performanceProxy) -or
+ $restoredReferencedTheme.Theme.media.source -cne 'wallpaper-engine-local') {
+ throw 'Saved Wallpaper Engine reference did not restore its source and performance proxy.'
+ }
+ $unsafeWallpaperEngineItem = Join-Path $wallpaperEngineContent '9876543210'
+ New-Item -ItemType Directory -Path $unsafeWallpaperEngineItem -Force | Out-Null
+ Write-DreamSkinUtf8FileAtomically -Path (Join-Path $unsafeWallpaperEngineItem 'project.json') -Content @'
+{"type":"video","file":"..\\wallpaper.mp4"}
+'@
+ $unsafeWallpaperEngineRejected = $false
+ try {
+ $null = Set-DreamSkinActiveWallpaperEngineTheme -ProjectDirectory $unsafeWallpaperEngineItem `
+ -StateRoot $themeStateRoot
+ } catch {
+ $unsafeWallpaperEngineRejected = $true
+ }
+ if (-not $unsafeWallpaperEngineRejected) {
+ throw 'Wallpaper Engine mapping accepted a project media path that escaped its Workshop item directory.'
+ }
+ $sceneWallpaperEngineItem = Join-Path $wallpaperEngineContent '2468013579'
+ $sceneVideoDirectory = Join-Path $sceneWallpaperEngineItem 'assets'
+ New-Item -ItemType Directory -Path $sceneVideoDirectory -Force | Out-Null
+ $sceneVideo = Join-Path $sceneVideoDirectory 'loop.webm'
+ [System.IO.File]::WriteAllBytes($sceneVideo, [byte[]](0x1a, 0x45, 0xdf, 0xa3, 0x42, 0x86, 0x81, 0x01))
+ Write-DreamSkinUtf8FileAtomically -Path (Join-Path $sceneWallpaperEngineItem 'project.json') -Content @'
+{"type":"scene","title":"场景中的独立视频"}
+'@
+ $managerWallpaperEngineItems = Invoke-DreamSkinManagerCommandForTest -Arguments @(
+ '-Action', 'ListWallpaperEngine', '-SteamLibraryPath', $steamLibrary, '-StateRoot', $themeStateRoot
+ )
+ if ($managerWallpaperEngineItems.action -cne 'ListWallpaperEngine' -or
+ @($managerWallpaperEngineItems.items).Count -ne 1 -or
+ @($managerWallpaperEngineItems.items | Where-Object { $_.workshopId -ceq '1234567890' }).Count -ne 1 -or
+ @($managerWallpaperEngineItems.items | Where-Object {
+ $_.workshopId -ceq '2468013579'
+ }).Count -ne 0) {
+ throw 'Wallpaper Engine discovery listed an embedded Scene/Web video instead of one directly playable video project.'
+ }
+ $steamInstall = Join-Path $temporaryRoot 'Steam Install'
+ New-Item -ItemType Directory -Path (Join-Path $steamInstall 'steamapps') -Force | Out-Null
+ $escapedSteamLibrary = $steamLibrary.Replace('\', '\\')
+ Write-DreamSkinUtf8FileAtomically -Path (Join-Path $steamInstall 'steamapps\libraryfolders.vdf') -Content @"
+"libraryfolders"
+{
+ "1"
+ {
+ "path" "$escapedSteamLibrary"
+ }
+}
+"@
+ $managerLibraryFolderItems = Invoke-DreamSkinManagerCommandForTest -Arguments @(
+ '-Action', 'ListWallpaperEngine', '-SteamLibraryPath', $steamInstall, '-StateRoot', $themeStateRoot
+ )
+ if (@($managerLibraryFolderItems.items).Count -ne 1 -or
+ @($managerLibraryFolderItems.items | Where-Object { $_.workshopId -ceq '1234567890' }).Count -ne 1) {
+ throw 'Wallpaper Engine discovery did not resolve a secondary Steam library from libraryfolders.vdf.'
+ }
+ $managerWallpaperEngineTheme = Invoke-DreamSkinManagerCommandForTest -Arguments @(
+ '-Action', 'UseWallpaperEngine', '-Path', $sceneVideo, '-StateRoot', $themeStateRoot
+ )
+ if ($managerWallpaperEngineTheme.action -cne 'UseWallpaperEngine' -or
+ $managerWallpaperEngineTheme.mediaPath -cne [System.IO.Path]::GetFullPath($sceneVideo) -or
+ (Read-DreamSkinTheme -ThemeDirectory $themePaths.Active).Theme.media.source -cne 'wallpaper-engine-local') {
+ throw 'Manager command did not apply a Wallpaper Engine item as a direct local reference.'
+ }
+
+ $unsupportedSchemaTheme = Join-Path $temporaryRoot 'unsupported-schema-theme'
+ New-Item -ItemType Directory -Path $unsupportedSchemaTheme | Out-Null
+ Copy-Item -LiteralPath $videoFixture -Destination (Join-Path $unsupportedSchemaTheme 'loop.mp4')
+ Write-DreamSkinUtf8FileAtomically -Path (Join-Path $unsupportedSchemaTheme 'theme.json') `
+ -Content "{`"schemaVersion`":2,`"image`":`"loop.mp4`",`"media`":{`"type`":`"video`"}}`r`n"
+ $unsupportedSchemaRejected = $false
+ try { $null = Read-DreamSkinTheme -ThemeDirectory $unsupportedSchemaTheme } catch {
+ $unsupportedSchemaRejected = $true
+ }
+ if (-not $unsupportedSchemaRejected) { throw 'Theme schema versions other than 1 must be rejected.' }
+ $nonIntegralSchemaTheme = Join-Path $temporaryRoot 'non-integral-schema-theme'
+ New-Item -ItemType Directory -Path $nonIntegralSchemaTheme | Out-Null
+ Copy-Item -LiteralPath $videoFixture -Destination (Join-Path $nonIntegralSchemaTheme 'loop.mp4')
+ Write-DreamSkinUtf8FileAtomically -Path (Join-Path $nonIntegralSchemaTheme 'theme.json') `
+ -Content "{`"schemaVersion`":1.1,`"image`":`"loop.mp4`",`"media`":{`"type`":`"video`"}}`r`n"
+ $nonIntegralSchemaRejected = $false
+ try { $null = Read-DreamSkinTheme -ThemeDirectory $nonIntegralSchemaTheme } catch {
+ $nonIntegralSchemaRejected = $true
+ }
+ if (-not $nonIntegralSchemaRejected) { throw 'A non-integer theme schemaVersion was accepted as version 1.' }
$fakeVideo = Join-Path $temporaryRoot 'fake.mp4'
[System.IO.File]::WriteAllText($fakeVideo, 'not an mp4')
$fakeVideoRejected = $false
@@ -778,13 +963,13 @@ try {
'--dream-immersive-composer',
'background-position: var(--dream-art-position)',
'.dream-home-utility',
- ':has(.dream-home-utility) .composer-surface-chrome',
- ':is(.dream-task-ambient, .dream-task-banner):has(main.main-surface:not(.dream-home-shell))'
+ '.dream-home-utility-present .dream-home .composer-surface-chrome',
+ '.dream-route-task:is(.dream-task-ambient, .dream-task-banner)'
)) {
if (-not $css.Contains($requiredCss)) { throw "Windows immersive CSS is missing: $requiredCss" }
}
+ if ($css.Contains(':has(')) { throw 'Windows immersive CSS must not use global reverse selectors.' }
- $managerCommandPath = Join-Path $Root 'scripts\manager-command.ps1'
$managerBuildPath = Join-Path $Root 'app\build-manager.ps1'
$managerProjectPath = Join-Path $Root 'app\CodexDreamSkin.Manager\CodexDreamSkin.Manager.csproj'
foreach ($managerPath in @($managerCommandPath, $managerBuildPath, $managerProjectPath)) {
@@ -802,8 +987,20 @@ try {
throw "Graphical manager PowerShell entry point failed to parse: $managerScriptPath"
}
}
+ $managerBuildSource = Read-DreamSkinUtf8File -Path $managerBuildPath
+ $managerCleanIndex = $managerBuildSource.IndexOf(
+ '& $dotnet clean $project', [System.StringComparison]::Ordinal)
+ $managerRestoreIndex = $managerBuildSource.IndexOf(
+ '& $dotnet restore $project', [System.StringComparison]::Ordinal)
+ if ($managerCleanIndex -lt 0 -or $managerRestoreIndex -lt 0 -or
+ $managerCleanIndex -gt $managerRestoreIndex) {
+ throw 'Graphical manager publish can reuse stale embedded-resource outputs.'
+ }
$managerCommandSource = Read-DreamSkinUtf8File -Path $managerCommandPath
- foreach ($managerAction in @('SetWallpaper', 'SetReveal', 'Pause', 'Resume', 'Status')) {
+ foreach ($managerAction in @(
+ 'SetWallpaper', 'SetReveal', 'Pause', 'Resume', 'Status', 'ListThemes', 'SaveTheme', 'UseTheme',
+ 'ListWallpaperEngine', 'UseWallpaperEngine'
+ )) {
if (-not $managerCommandSource.Contains("'$managerAction'")) {
throw "Graphical manager command is missing: $managerAction"
}
@@ -828,7 +1025,8 @@ try {
'Multiselect = true',
'当前壁纸:{status.CurrentWallpaperLabel}',
'暂无壁纸 · 点击左侧「添加壁纸」导入',
- '100% · 原始壁纸画面',
+ '0% · 完全不透明',
+ '100% · 完全透明(原始壁纸)',
'开机启动管理器',
'退出管理器',
'支持 PNG、JPEG、WebP、MP4、WebM'
@@ -837,6 +1035,46 @@ try {
throw "Graphical manager UI contract is missing: $managerUiContract"
}
}
+ $savedThemesDialogPath = Join-Path $Root 'app\CodexDreamSkin.Manager\SavedThemesDialog.cs'
+ if (-not (Test-Path -LiteralPath $savedThemesDialogPath -PathType Leaf)) {
+ throw 'Graphical manager saved-theme dialog is missing.'
+ }
+ $savedThemesDialogSource = Read-DreamSkinUtf8File -Path $savedThemesDialogPath
+ foreach ($savedThemesUiContract in @(
+ '已保存主题',
+ '保存当前主题',
+ '应用主题',
+ 'ListSavedThemesAsync',
+ 'SaveCurrentThemeAsync',
+ 'ApplySavedThemeAsync',
+ 'AccessibleName'
+ )) {
+ if (-not $savedThemesDialogSource.Contains($savedThemesUiContract)) {
+ throw "Graphical manager saved-theme UI contract is missing: $savedThemesUiContract"
+ }
+ }
+ foreach ($mainFormThemeContract in @('_themesButton', 'ShowSavedThemes')) {
+ if (-not $managerFormSource.Contains($mainFormThemeContract)) {
+ throw "Graphical manager does not expose saved themes from its main window: $mainFormThemeContract"
+ }
+ }
+ foreach ($mainFormWallpaperEngineContract in @('_wallpaperEngineButton', 'ShowWallpaperEngine')) {
+ if (-not $managerFormSource.Contains($mainFormWallpaperEngineContract)) {
+ throw "Graphical manager does not expose locally downloaded Wallpaper Engine videos: $mainFormWallpaperEngineContract"
+ }
+ }
+ $wallpaperEngineDialogPath = Join-Path $Root 'app\CodexDreamSkin.Manager\WallpaperEngineDialog.cs'
+ if (-not (Test-Path -LiteralPath $wallpaperEngineDialogPath -PathType Leaf)) {
+ throw 'Graphical manager Wallpaper Engine dialog is missing.'
+ }
+ $wallpaperEngineDialogSource = Read-DreamSkinUtf8File -Path $wallpaperEngineDialogPath
+ foreach ($wallpaperEngineUiContract in @(
+ 'Wallpaper Engine', 'ListWallpaperEngineAsync', '导入到主页', 'SelectionMode.MultiExtended', 'ImportedItems', 'AccessibleName'
+ )) {
+ if (-not $wallpaperEngineDialogSource.Contains($wallpaperEngineUiContract)) {
+ throw "Graphical manager Wallpaper Engine UI contract is missing: $wallpaperEngineUiContract"
+ }
+ }
$managerModelSource = Read-DreamSkinUtf8File -Path (
Join-Path $Root 'app\CodexDreamSkin.Manager\Models.cs'
)
@@ -849,6 +1087,133 @@ try {
throw "Graphical manager status contract is missing: $managerStatusContract"
}
}
+ $managerServiceSource = Read-DreamSkinUtf8File -Path (
+ Join-Path $Root 'app\CodexDreamSkin.Manager\DreamSkinService.cs'
+ )
+ foreach ($managerThemeContract in @(
+ 'ListSavedThemesAsync',
+ 'SaveCurrentThemeAsync',
+ 'ApplySavedThemeAsync',
+ 'ListWallpaperEngineAsync',
+ 'ApplyWallpaperEngineAsync',
+ 'ListThemes', 'ListWallpaperEngine', 'UseWallpaperEngine',
+ 'SaveTheme',
+ 'UseTheme'
+ )) {
+ if (-not $managerServiceSource.Contains($managerThemeContract)) {
+ throw "Graphical manager saved-theme service contract is missing: $managerThemeContract"
+ }
+ }
+ foreach ($restartContract in @(
+ 'ConfirmCodexRestart',
+ 'arguments.Add("-RestartExisting")',
+ 'arguments.Add("-ForceRestart")'
+ )) {
+ if (-not $managerServiceSource.Contains($restartContract)) {
+ throw "Graphical manager restart confirmation contract is missing: $restartContract"
+ }
+ }
+ if ($managerServiceSource.Contains('"-PromptRestart"')) {
+ throw 'Graphical manager still delegates restart confirmation to a hidden PowerShell process.'
+ }
+ $managerRunnerSource = Read-DreamSkinUtf8File -Path (
+ Join-Path $Root 'app\CodexDreamSkin.Manager\PowerShellRunner.cs'
+ )
+ foreach ($managerRunnerContract in @(
+ 'bool captureOutput = true',
+ 'if (!captureOutput)',
+ 'new ProcessResult(process.ExitCode, string.Empty, string.Empty)'
+ )) {
+ if (-not $managerRunnerSource.Contains($managerRunnerContract)) {
+ throw "Graphical manager process runner contract is missing: $managerRunnerContract"
+ }
+ }
+ if ([regex]::Matches($managerServiceSource, 'captureOutput: false').Count -ne 1) {
+ throw 'Graphical manager startup still captures inheritable output pipes.'
+ }
+ $managerProgramSource = Read-DreamSkinUtf8File -Path (
+ Join-Path $Root 'app\CodexDreamSkin.Manager\Program.cs'
+ )
+ foreach ($managerRunnerSelfTestContract in @(
+ 'RunnerReturnsAfterParentExit',
+ 'child.pid',
+ '-RedirectStandardOutput $stdout -RedirectStandardError $stderr'
+ )) {
+ if (-not $managerProgramSource.Contains($managerRunnerSelfTestContract)) {
+ throw "Graphical manager output-pipe self-test is missing: $managerRunnerSelfTestContract"
+ }
+ }
+ $powerShellRunnerSource = Read-DreamSkinUtf8File -Path (
+ Join-Path $Root 'app\CodexDreamSkin.Manager\PowerShellRunner.cs'
+ )
+ if (-not $powerShellRunnerSource.Contains('process.Kill(entireProcessTree: true)')) {
+ throw 'Graphical manager cancellation can still orphan a PowerShell process tree.'
+ }
+ if (-not [regex]::IsMatch(
+ $managerFormSource,
+ 'private async Task ApplySelectedAsync\(\)[\s\S]*?await RunOperationAsync\([\s\S]*?\);\s*StopVideoPreview\(\);')) {
+ throw 'Graphical manager keeps decoding the selected video after applying it.'
+ }
+ $formClosingStart = $managerFormSource.IndexOf(
+ 'private void OnFormClosing(', [System.StringComparison]::Ordinal)
+ $formClosingEnd = $managerFormSource.IndexOf(
+ 'private void ClearLibraryCards(', [System.StringComparison]::Ordinal)
+ $formClosingSource = if ($formClosingStart -ge 0 -and $formClosingEnd -gt $formClosingStart) {
+ $managerFormSource.Substring($formClosingStart, $formClosingEnd - $formClosingStart)
+ } else {
+ ''
+ }
+ if (-not $formClosingSource.Contains('StopVideoPreview();') -or
+ $formClosingSource.IndexOf('StopVideoPreview();', [System.StringComparison]::Ordinal) -gt
+ $formClosingSource.IndexOf('Hide();', [System.StringComparison]::Ordinal)) {
+ throw 'Graphical manager hides to the tray before stopping its video preview.'
+ }
+ $sceneStreamHostSource = Read-DreamSkinUtf8File -Path (
+ Join-Path $Root 'app\CodexDreamSkin.Manager\SceneStreamHost.cs'
+ )
+ $errorDrainIndex = $sceneStreamHostSource.IndexOf('DrainErrorAsync(', [System.StringComparison]::Ordinal)
+ $readyLoopIndex = $sceneStreamHostSource.IndexOf('while (!timeout.IsCancellationRequested', [System.StringComparison]::Ordinal)
+ if ($errorDrainIndex -lt 0 -or $readyLoopIndex -lt 0 -or $errorDrainIndex -gt $readyLoopIndex) {
+ throw 'Scene stream host does not drain stderr before waiting for the ready line.'
+ }
+ foreach ($sceneLifecycleContract in @(
+ 'CleanupStaleStreamFiles',
+ 'DeleteStreamFileWithRetryAsync',
+ 'DisposeProcessImmediately'
+ )) {
+ if (-not $sceneStreamHostSource.Contains($sceneLifecycleContract)) {
+ throw "Scene stream lifecycle contract is missing: $sceneLifecycleContract"
+ }
+ }
+ if ($sceneStreamHostSource.Contains('StopAsync().GetAwaiter().GetResult()')) {
+ throw 'Scene stream disposal still blocks the WinForms UI thread on async shutdown.'
+ }
+ $managerReferenceCatalogPath = Join-Path $Root 'app\CodexDreamSkin.Manager\WallpaperEngineReferenceCatalog.cs'
+ if (-not (Test-Path -LiteralPath $managerReferenceCatalogPath -PathType Leaf)) {
+ throw 'Graphical manager Wallpaper Engine reference catalog is missing.'
+ }
+ $managerReferenceCatalogSource = Read-DreamSkinUtf8File -Path $managerReferenceCatalogPath
+ foreach ($referenceCatalogContract in @(
+ 'ResolveAndPrune', 'AddImports', 'steamapps', 'workshop', '431960'
+ )) {
+ if (-not $managerReferenceCatalogSource.Contains($referenceCatalogContract)) {
+ throw "Wallpaper Engine reference catalog is missing: $referenceCatalogContract"
+ }
+ }
+ foreach ($managerReferenceContract in @(
+ 'WallpaperEngineImports', 'WallpaperEngineReference', 'WallpaperSource', 'SourceLabel'
+ )) {
+ if (-not $managerModelSource.Contains($managerReferenceContract)) {
+ throw "Graphical manager Wallpaper Engine persistence model is missing: $managerReferenceContract"
+ }
+ }
+ foreach ($mainFormReferenceContract in @(
+ 'ShowWallpaperEngineAsync', 'AddImports', 'ResolveAndPrune', 'WallpaperSource.WallpaperEngine'
+ )) {
+ if (-not $managerFormSource.Contains($mainFormReferenceContract)) {
+ throw "Graphical manager does not persist or refresh Wallpaper Engine references: $mainFormReferenceContract"
+ }
+ }
$traySource = Read-DreamSkinUtf8File -Path (Join-Path $Root 'scripts\tray-dream-skin.ps1')
foreach ($requiredTrayAction in @('System.Windows.Forms.NotifyIcon', 'System.Windows.Forms.TrackBar', '壁纸透出', '暂停皮肤', '更换背景图或视频', '*.mp4;*.webm', '已保存主题', '完全恢复 Codex')) {
@@ -901,6 +1266,10 @@ try {
}
}
$injectorSource = Read-DreamSkinUtf8File -Path (Join-Path $Root 'scripts\injector.mjs')
+ if (-not $injectorSource.Contains('Page.setBypassCSP') -or
+ -not $injectorSource.Contains('setStreamCspBypass(session, false)')) {
+ throw 'Scene streaming does not scope and revoke its Codex CSP bypass.'
+ }
foreach ($requiredInjectorBehavior in @(
'MAX_ART_BYTES', 'createHash', 'readImageMetadata', '50MP safety limit', 'STRONG_THEME_AUDIT_MS',
'Page.addScriptToEvaluateOnNewDocument', 'Page.removeScriptToEvaluateOnNewDocument', 'earlyPayloadFor'