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'