diff --git a/.gitignore b/.gitignore index ab6f1e4..4181cda 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ README.preview.html docs/readme-preview.html release/ runtime/ +windows/app/**/bin/ +windows/app/**/obj/ +windows/dist/ diff --git a/windows/CHANGELOG.md b/windows/CHANGELOG.md index 98d5a70..301057a 100644 --- a/windows/CHANGELOG.md +++ b/windows/CHANGELOG.md @@ -4,6 +4,7 @@ ### 新增 +- 新增自包含的 Windows 图形化 EXE 管理器,提供壁纸库浏览与预览、一键应用、透出调节、启动/暂停/恢复、托盘控制和可选开机启动;发布文件内置 Dream Skin 引擎与 Node.js,最终用户无需手动运行脚本。 - Windows 系统托盘新增「壁纸透出」滑杆;100% 时视频保持原始画面,整页主题蒙层降为零,并随当前主题持久化;拖动滑杆不会重新传输整段视频。 - Windows 主题仓库与托盘支持本地 MP4/WebM 动态壁纸;视频静音循环、页面隐藏时暂停,并可像图片主题一样保存和切换。 - 视频采用上限 128 MB、单块 512 KiB 的 CDP 分块传输,在 renderer 内组装为 Blob URL,避免把完整视频塞进 early bootstrap payload;换主题、暂停和清理时会释放播放器与 Blob URL。 diff --git a/windows/README.en.md b/windows/README.en.md index 5fdddce..9d86198 100644 --- a/windows/README.en.md +++ b/windows/README.en.md @@ -6,6 +6,26 @@ Codex Dream Skin loads an external theme into the official Codex Windows desktop app through loopback CDP. The native sidebar, project picker, task content, and composer remain interactive. The tool does not modify WindowsApps, `app.asar`, or the app signature. +## Graphical EXE manager + +`windows/app/CodexDreamSkin.Manager` is a complete Windows desktop manager. Its release artifact is a +self-contained, single-file `CodexDreamSkinManager.exe` with the tested Dream Skin engine and Node.js +runtime embedded, so end users do not need Node.js, the .NET SDK, or manual PowerShell commands. + +The manager browses and previews PNG, JPEG, WebP, MP4, and WebM libraries; starts, reapplies, pauses, +restores, and switches Dream Skin; controls wallpaper reveal; exposes tray actions; and offers optional +per-user startup. + +Build it with: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\app\build-manager.ps1 +``` + +The build runs the Windows regression suite, embeds a trusted local Node.js 22+ runtime, publishes a +self-contained `win-x64` executable, and runs a post-publish self-test. Output is written to +`windows/dist`. + ## Requirements - The official `OpenAI.Codex` app installed from Microsoft Store and registered for the current user. diff --git a/windows/README.md b/windows/README.md index f114c8b..5ce22b4 100644 --- a/windows/README.md +++ b/windows/README.md @@ -6,6 +6,28 @@ Codex Dream Skin 通过本机回环 CDP 给官方 Codex Windows 桌面应用加载外部主题。它保留原生侧栏、项目选择、任务内容和输入框,不修改 WindowsApps、`app.asar` 或应用签名。 +## 图形化 EXE 管理器 + +`windows/app/CodexDreamSkin.Manager` 提供完整的 Windows 图形化管理器。发布版是自包含的 +单文件 `CodexDreamSkinManager.exe`,内置经过验证的 Dream Skin 引擎和 Node.js 运行时;最终 +用户不需要安装 Node.js、.NET SDK 或手动运行 PowerShell。 + +管理器支持: + +- 浏览、搜索并预览桌面壁纸库中的 PNG、JPEG、WebP、MP4 和 WebM。 +- 一键启动或重新应用 Dream Skin、切换壁纸、暂停/恢复以及还原官方外观。 +- 调整「壁纸透出」程度,100% 时视频保持原始画面且整页主题蒙层为零。 +- 系统托盘控制和可选的当前用户开机启动。 + +开发者可使用下列命令构建单文件版本: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\app\build-manager.ps1 +``` + +构建脚本会运行 Windows 回归测试、嵌入本机可信的 Node.js 22+ 运行时、发布自包含 +`win-x64` EXE,并执行发布后自检。输出位于 `windows/dist`。 + ## 运行要求 - 从 Microsoft Store 安装且已注册到当前用户的官方 `OpenAI.Codex` 应用。 diff --git a/windows/app/CodexDreamSkin.Manager/AutoStartManager.cs b/windows/app/CodexDreamSkin.Manager/AutoStartManager.cs new file mode 100644 index 0000000..ca6ab63 --- /dev/null +++ b/windows/app/CodexDreamSkin.Manager/AutoStartManager.cs @@ -0,0 +1,32 @@ +using Microsoft.Win32; + +namespace CodexDreamSkin.Manager; + +internal static class AutoStartManager +{ + private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run"; + private const string ValueName = "CodexDreamSkinManager"; + + public static bool IsEnabled() + { + using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, false); + return key?.GetValue(ValueName) is string value && + value.Contains(Environment.ProcessPath ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + + public static void SetEnabled(bool enabled) + { + using var key = Registry.CurrentUser.CreateSubKey(RunKeyPath, true) + ?? throw new InvalidOperationException("无法打开当前用户启动项。"); + if (enabled) + { + var executable = Environment.ProcessPath + ?? throw new InvalidOperationException("无法确定管理器 EXE 路径。"); + key.SetValue(ValueName, $"\"{executable}\" --minimized", RegistryValueKind.String); + } + else + { + key.DeleteValue(ValueName, false); + } + } +} diff --git a/windows/app/CodexDreamSkin.Manager/CodexDreamSkin.Manager.csproj b/windows/app/CodexDreamSkin.Manager/CodexDreamSkin.Manager.csproj new file mode 100644 index 0000000..0372e19 --- /dev/null +++ b/windows/app/CodexDreamSkin.Manager/CodexDreamSkin.Manager.csproj @@ -0,0 +1,50 @@ + + + WinExe + net8.0-windows + true + true + true + enable + enable + CodexDreamSkinManager + CodexDreamSkin.Manager + 1.0.0 + Codex Dream Skin Manager + One-click wallpaper manager for the official Codex Windows app. + CCDawn + Copyright © CCDawn + win-x64 + true + true + true + true + false + embedded + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/app/CodexDreamSkin.Manager/DreamSkinService.cs b/windows/app/CodexDreamSkin.Manager/DreamSkinService.cs new file mode 100644 index 0000000..38b48eb --- /dev/null +++ b/windows/app/CodexDreamSkin.Manager/DreamSkinService.cs @@ -0,0 +1,167 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace CodexDreamSkin.Manager; + +internal sealed class DreamSkinService +{ + private readonly RuntimeProvisioner _runtime; + private readonly PowerShellRunner _runner; + + public DreamSkinService(RuntimeProvisioner runtime) + { + _runtime = runtime; + _runner = new PowerShellRunner(runtime); + } + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + var result = await _runner.RunScriptAsync( + _runtime.StartScript, + new[] { "-Port", "9335", "-PromptRestart" }, + cancellationToken); + result.ThrowIfFailed("启动 Dream Skin"); + } + + public async Task ApplyWallpaperAsync(string path, CancellationToken cancellationToken = default) + { + if (!File.Exists(path) || !WallpaperCatalog.IsSupported(path)) + { + throw new InvalidOperationException("请选择有效的 PNG、JPEG、WebP、MP4 或 WebM 文件。"); + } + + var result = await RunManagerCommandAsync( + new[] { "-Action", "SetWallpaper", "-Path", path }, + cancellationToken); + result.ThrowIfFailed("切换壁纸"); + + var status = GetStatus(); + if (!status.WatcherRunning) + { + await StartAsync(cancellationToken); + } + } + + public async Task SetRevealAsync(int percent, CancellationToken cancellationToken = default) + { + if (percent is < 0 or > 100) + { + throw new ArgumentOutOfRangeException(nameof(percent)); + } + var result = await RunManagerCommandAsync( + new[] { "-Action", "SetReveal", "-Percent", percent.ToString() }, + cancellationToken); + result.ThrowIfFailed("调整壁纸透出程度"); + } + + public async Task SetPausedAsync(bool paused, CancellationToken cancellationToken = default) + { + var result = await RunManagerCommandAsync( + new[] { "-Action", paused ? "Pause" : "Resume" }, + cancellationToken); + result.ThrowIfFailed(paused ? "暂停皮肤" : "恢复皮肤"); + } + + public async Task RestoreAsync(CancellationToken cancellationToken = default) + { + var result = await _runner.RunScriptAsync( + _runtime.RestoreScript, + new[] { "-Port", "9335", "-RestoreBaseTheme", "-PromptRestart" }, + cancellationToken); + result.ThrowIfFailed("恢复 Codex 官方外观"); + } + + public DreamSkinStatus GetStatus() + { + var statePath = Path.Combine(_runtime.StateRoot, "state.json"); + var themePath = Path.Combine(_runtime.StateRoot, "active-theme", "theme.json"); + var paused = File.Exists(Path.Combine(_runtime.StateRoot, "paused")); + var watcherRunning = false; + string? activeTheme = null; + string? mediaPath = null; + WallpaperKind? mediaKind = null; + var reveal = 100; + + try + { + if (File.Exists(statePath)) + { + using var state = JsonDocument.Parse(File.ReadAllText(statePath)); + if (state.RootElement.TryGetProperty("injectorPid", out var pidElement) && + pidElement.TryGetInt32(out var pid)) + { + try + { + using var process = Process.GetProcessById(pid); + watcherRunning = !process.HasExited && + process.ProcessName.Equals("node", StringComparison.OrdinalIgnoreCase); + } + catch + { + watcherRunning = false; + } + } + } + + if (File.Exists(themePath)) + { + using var theme = JsonDocument.Parse(File.ReadAllText(themePath)); + if (theme.RootElement.TryGetProperty("name", out var nameElement)) + { + activeTheme = nameElement.GetString(); + } + if (theme.RootElement.TryGetProperty("image", out var imageElement)) + { + var image = imageElement.GetString(); + if (!string.IsNullOrWhiteSpace(image)) + { + mediaPath = Path.Combine(Path.GetDirectoryName(themePath)!, image); + mediaKind = WallpaperCatalog.GetKind(mediaPath); + } + } + if (theme.RootElement.TryGetProperty("media", out var mediaElement) && + mediaElement.TryGetProperty("opacity", out var opacityElement) && + opacityElement.TryGetDouble(out var opacity)) + { + reveal = Math.Clamp((int)Math.Round(opacity * 100), 0, 100); + } + } + } + catch + { + // Status rendering stays available even if an external process is replacing state atomically. + } + + var codexRunning = IsAnyProcessRunning("ChatGPT") || + IsAnyProcessRunning("Codex"); + return new DreamSkinStatus( + watcherRunning, + codexRunning, + paused, + activeTheme, + mediaPath, + mediaKind, + reveal); + } + + private Task RunManagerCommandAsync( + IEnumerable arguments, + CancellationToken cancellationToken) => + _runner.RunScriptAsync(_runtime.ManagerCommandScript, arguments, cancellationToken); + + private static bool IsAnyProcessRunning(string processName) + { + var processes = Process.GetProcessesByName(processName); + try + { + return processes.Any(process => !process.HasExited); + } + finally + { + foreach (var process in processes) + { + process.Dispose(); + } + } + } +} diff --git a/windows/app/CodexDreamSkin.Manager/GlobalUsings.cs b/windows/app/CodexDreamSkin.Manager/GlobalUsings.cs new file mode 100644 index 0000000..30f1415 --- /dev/null +++ b/windows/app/CodexDreamSkin.Manager/GlobalUsings.cs @@ -0,0 +1 @@ +global using System.IO; diff --git a/windows/app/CodexDreamSkin.Manager/MainForm.cs b/windows/app/CodexDreamSkin.Manager/MainForm.cs new file mode 100644 index 0000000..8ae07c8 --- /dev/null +++ b/windows/app/CodexDreamSkin.Manager/MainForm.cs @@ -0,0 +1,950 @@ +using System.Diagnostics; +using System.Drawing.Drawing2D; +using System.Runtime.InteropServices; +using System.Windows.Forms.Integration; +using WpfMediaElement = System.Windows.Controls.MediaElement; +using WpfMediaState = System.Windows.Controls.MediaState; +using WpfStretch = System.Windows.Media.Stretch; + +namespace CodexDreamSkin.Manager; + +internal sealed class MainForm : 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 static readonly Color Success = Color.FromArgb(103, 211, 155); + private static readonly Color Warning = Color.FromArgb(245, 190, 83); + + private readonly DreamSkinService _service; + private readonly SettingsStore _settingsStore; + private readonly WallpaperCatalog _catalog = new(); + private readonly AppSettings _settings; + private readonly CancellationTokenSource _lifetime = new(); + private readonly System.Windows.Forms.Timer _statusTimer = new() { Interval = 4000 }; + private readonly System.Windows.Forms.Timer _searchTimer = new() { Interval = 350 }; + private readonly NotifyIcon _trayIcon; + private readonly Icon _appIcon; + + private readonly FlowLayoutPanel _libraryFlow = new(); + private readonly TextBox _searchBox = new(); + private readonly Label _librarySummary = new(); + private readonly Label _statusLabel = new(); + private readonly Panel _statusDot = new(); + private readonly Button _startButton = new(); + private readonly Button _pauseButton = new(); + private readonly Button _applyButton = new(); + private readonly Button _restoreButton = new(); + private readonly TrackBar _revealSlider = new(); + private readonly Label _revealLabel = new(); + private readonly Label _selectionTitle = new(); + private readonly Label _selectionMeta = new(); + private readonly Label _messageLabel = new(); + private readonly Label _libraryPathLabel = new(); + private readonly CheckBox _autoStartCheckBox = new(); + private readonly PictureBox _previewImage = new(); + private readonly ElementHost _videoHost = new(); + private readonly WpfMediaElement _mediaElement = new(); + + private WallpaperItem? _selectedWallpaper; + private Control? _selectedCard; + private CancellationTokenSource? _libraryLoad; + private bool _busy; + private bool _allowExit; + private bool _initializing = true; + private bool _pauseRequested; + + public MainForm(DreamSkinService service, SettingsStore settingsStore, bool minimized) + { + _service = service; + _settingsStore = settingsStore; + _settings = settingsStore.Load(); + Directory.CreateDirectory(_settings.LibraryPath); + + Text = "Codex Dream Skin"; + StartPosition = FormStartPosition.CenterScreen; + MinimumSize = new Size(1060, 680); + Size = new Size(1280, 800); + BackColor = Canvas; + ForeColor = TextPrimary; + Font = new Font("Segoe UI Variable Text", 10f, FontStyle.Regular, GraphicsUnit.Point); + DoubleBuffered = true; + _appIcon = CreateAppIcon(); + Icon = _appIcon; + SetDarkTitleBar(Handle); + + BuildLayout(); + _trayIcon = BuildTrayIcon(); + _statusTimer.Tick += (_, _) => RefreshStatus(); + _searchTimer.Tick += async (_, _) => + { + _searchTimer.Stop(); + await ReloadLibraryAsync(); + }; + + Shown += async (_, _) => + { + _initializing = false; + _autoStartCheckBox.Checked = AutoStartManager.IsEnabled(); + RefreshStatus(); + await ReloadLibraryAsync(); + _statusTimer.Start(); + if (minimized) + { + Hide(); + } + }; + FormClosing += OnFormClosing; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _lifetime.Cancel(); + _libraryLoad?.Cancel(); + _statusTimer.Dispose(); + _searchTimer.Dispose(); + StopVideoPreview(); + _trayIcon.Dispose(); + _appIcon.Dispose(); + _lifetime.Dispose(); + _libraryLoad?.Dispose(); + } + base.Dispose(disposing); + } + + private void BuildLayout() + { + var header = new Panel + { + Dock = DockStyle.Top, + Height = 88, + BackColor = Surface, + Padding = new Padding(26, 14, 22, 12), + }; + var brand = CreateLabel("Codex Dream Skin", 20f, FontStyle.Bold, TextPrimary); + brand.AutoSize = true; + brand.Location = new Point(26, 13); + header.Controls.Add(brand); + var subtitle = CreateLabel("Codex 内部壁纸管理器", 9.5f, FontStyle.Regular, TextMuted); + subtitle.AutoSize = true; + subtitle.Location = new Point(28, 49); + header.Controls.Add(subtitle); + + _statusDot.Size = new Size(10, 10); + _statusDot.BackColor = Warning; + _statusDot.Anchor = AnchorStyles.Top | AnchorStyles.Right; + _statusDot.Location = new Point(header.Width - 322, 39); + header.Controls.Add(_statusDot); + _statusLabel.AutoSize = true; + _statusLabel.ForeColor = TextMuted; + _statusLabel.Anchor = AnchorStyles.Top | AnchorStyles.Right; + _statusLabel.Location = new Point(header.Width - 304, 34); + _statusLabel.Text = "正在读取状态"; + header.Controls.Add(_statusLabel); + header.Resize += (_, _) => + { + _statusDot.Left = header.ClientSize.Width - 322; + _statusLabel.Left = header.ClientSize.Width - 304; + _startButton.Left = header.ClientSize.Width - _startButton.Width - 22; + }; + + ConfigureButton(_startButton, "启动 / 重新应用", true); + _startButton.Size = new Size(150, 42); + _startButton.Anchor = AnchorStyles.Top | AnchorStyles.Right; + _startButton.Location = new Point(header.Width - 172, 23); + _startButton.AccessibleName = "启动或重新应用 Codex Dream Skin"; + _startButton.Click += async (_, _) => await RunOperationAsync( + "正在启动 Codex Dream Skin…", + () => _service.StartAsync(_lifetime.Token), + "Dream Skin 已启动。"); + header.Controls.Add(_startButton); + + var left = new Panel + { + Dock = DockStyle.Left, + Width = 224, + BackColor = Surface, + Padding = new Padding(18, 22, 18, 18), + }; + var libraryHeading = CreateLabel("壁纸库", 10f, FontStyle.Bold, TextMuted); + libraryHeading.Dock = DockStyle.Top; + libraryHeading.Height = 28; + left.Controls.Add(libraryHeading); + + _libraryPathLabel.Dock = DockStyle.Top; + _libraryPathLabel.Height = 58; + _libraryPathLabel.ForeColor = TextPrimary; + _libraryPathLabel.Text = CompactPath(_settings.LibraryPath); + _libraryPathLabel.AutoEllipsis = true; + _libraryPathLabel.AccessibleName = "当前壁纸库路径"; + left.Controls.Add(_libraryPathLabel); + left.Controls.SetChildIndex(_libraryPathLabel, 0); + + var chooseFolderButton = new Button(); + ConfigureButton(chooseFolderButton, "更换壁纸库", false); + chooseFolderButton.Dock = DockStyle.Top; + chooseFolderButton.Height = 38; + chooseFolderButton.Click += async (_, _) => await ChooseLibraryAsync(); + left.Controls.Add(chooseFolderButton); + left.Controls.SetChildIndex(chooseFolderButton, 0); + + var openFolderButton = new Button(); + ConfigureButton(openFolderButton, "打开文件夹", false); + openFolderButton.Dock = DockStyle.Top; + openFolderButton.Height = 38; + openFolderButton.Margin = new Padding(0, 8, 0, 0); + openFolderButton.Click += (_, _) => OpenLibraryFolder(); + left.Controls.Add(openFolderButton); + left.Controls.SetChildIndex(openFolderButton, 0); + + var actionsHeading = CreateLabel("控制", 10f, FontStyle.Bold, TextMuted); + actionsHeading.Dock = DockStyle.Top; + actionsHeading.Height = 44; + actionsHeading.Padding = new Padding(0, 18, 0, 0); + left.Controls.Add(actionsHeading); + left.Controls.SetChildIndex(actionsHeading, 0); + + ConfigureButton(_pauseButton, "暂停皮肤", false); + _pauseButton.Dock = DockStyle.Top; + _pauseButton.Height = 38; + _pauseButton.Click += async (_, _) => + { + var nextPaused = !_pauseRequested; + await RunOperationAsync( + nextPaused ? "正在暂停皮肤…" : "正在恢复皮肤…", + () => _service.SetPausedAsync(nextPaused, _lifetime.Token), + nextPaused ? "皮肤已暂停。" : "皮肤已恢复。"); + }; + left.Controls.Add(_pauseButton); + left.Controls.SetChildIndex(_pauseButton, 0); + + ConfigureButton(_restoreButton, "恢复官方外观", false, danger: true); + _restoreButton.Dock = DockStyle.Top; + _restoreButton.Height = 38; + _restoreButton.Click += async (_, _) => await RestoreAsync(); + left.Controls.Add(_restoreButton); + left.Controls.SetChildIndex(_restoreButton, 0); + + _autoStartCheckBox.Dock = DockStyle.Bottom; + _autoStartCheckBox.Height = 42; + _autoStartCheckBox.Text = "开机启动管理器"; + _autoStartCheckBox.ForeColor = TextMuted; + _autoStartCheckBox.FlatStyle = FlatStyle.Flat; + _autoStartCheckBox.CheckedChanged += (_, _) => + { + if (_initializing) + { + return; + } + try + { + AutoStartManager.SetEnabled(_autoStartCheckBox.Checked); + _settings.StartWithWindows = _autoStartCheckBox.Checked; + _settingsStore.Save(_settings); + ShowMessage(_autoStartCheckBox.Checked ? "已开启开机启动。" : "已关闭开机启动。", false); + } + catch (Exception exception) + { + ShowError(exception); + } + }; + left.Controls.Add(_autoStartCheckBox); + + var preview = BuildPreviewPanel(); + var library = BuildLibraryPanel(); + + Controls.Add(library); + Controls.Add(preview); + Controls.Add(left); + Controls.Add(header); + } + + private Control BuildLibraryPanel() + { + var panel = new Panel + { + Dock = DockStyle.Fill, + BackColor = Canvas, + Padding = new Padding(24, 20, 12, 16), + }; + var top = new Panel { Dock = DockStyle.Top, Height = 58, BackColor = Canvas }; + var title = CreateLabel("我的壁纸", 17f, FontStyle.Bold, TextPrimary); + title.AutoSize = true; + title.Location = new Point(0, 2); + top.Controls.Add(title); + _librarySummary.AutoSize = true; + _librarySummary.ForeColor = TextMuted; + _librarySummary.Location = new Point(2, 34); + _librarySummary.Text = "正在读取…"; + top.Controls.Add(_librarySummary); + + _searchBox.PlaceholderText = "搜索壁纸…"; + _searchBox.BorderStyle = BorderStyle.FixedSingle; + _searchBox.BackColor = SurfaceRaised; + _searchBox.ForeColor = TextPrimary; + _searchBox.Size = new Size(240, 34); + _searchBox.Anchor = AnchorStyles.Top | AnchorStyles.Right; + _searchBox.Location = new Point(top.Width - 240, 9); + _searchBox.AccessibleName = "搜索壁纸"; + _searchBox.TextChanged += (_, _) => + { + _searchTimer.Stop(); + _searchTimer.Start(); + }; + top.Controls.Add(_searchBox); + top.Resize += (_, _) => _searchBox.Left = top.ClientSize.Width - _searchBox.Width; + + _libraryFlow.Dock = DockStyle.Fill; + _libraryFlow.AutoScroll = true; + _libraryFlow.WrapContents = true; + _libraryFlow.FlowDirection = FlowDirection.LeftToRight; + _libraryFlow.BackColor = Canvas; + _libraryFlow.Padding = new Padding(0, 8, 4, 12); + _libraryFlow.AccessibleName = "壁纸列表"; + panel.Controls.Add(_libraryFlow); + panel.Controls.Add(top); + return panel; + } + + private Control BuildPreviewPanel() + { + var panel = new Panel + { + Dock = DockStyle.Right, + Width = 360, + BackColor = Surface, + Padding = new Padding(18, 20, 18, 16), + }; + var heading = CreateLabel("预览与应用", 15f, FontStyle.Bold, TextPrimary); + heading.Dock = DockStyle.Top; + heading.Height = 40; + panel.Controls.Add(heading); + + var previewSurface = new Panel + { + Dock = DockStyle.Top, + Height = 244, + BackColor = Color.Black, + Padding = new Padding(1), + }; + _previewImage.Dock = DockStyle.Fill; + _previewImage.BackColor = Color.Black; + _previewImage.SizeMode = PictureBoxSizeMode.Zoom; + _previewImage.AccessibleName = "壁纸静态预览"; + previewSurface.Controls.Add(_previewImage); + + _mediaElement.LoadedBehavior = WpfMediaState.Manual; + _mediaElement.UnloadedBehavior = WpfMediaState.Stop; + _mediaElement.Stretch = WpfStretch.Uniform; + _mediaElement.Volume = 0; + _mediaElement.MediaEnded += (_, _) => + { + _mediaElement.Position = TimeSpan.Zero; + _mediaElement.Play(); + }; + _mediaElement.MediaFailed += (_, args) => BeginInvoke(() => + ShowMessage($"视频预览不可用:{args.ErrorException?.Message ?? "当前系统解码器不支持"}", true)); + _videoHost.Child = _mediaElement; + _videoHost.Dock = DockStyle.Fill; + _videoHost.BackColor = Color.Black; + _videoHost.Visible = false; + _videoHost.AccessibleName = "动态壁纸预览"; + previewSurface.Controls.Add(_videoHost); + + panel.Controls.Add(previewSurface); + panel.Controls.SetChildIndex(previewSurface, 0); + + _selectionTitle.Dock = DockStyle.Top; + _selectionTitle.Height = 56; + _selectionTitle.Padding = new Padding(0, 14, 0, 0); + _selectionTitle.Font = new Font(Font.FontFamily, 12f, FontStyle.Bold); + _selectionTitle.ForeColor = TextPrimary; + _selectionTitle.Text = "请选择一张壁纸"; + _selectionTitle.AutoEllipsis = true; + panel.Controls.Add(_selectionTitle); + panel.Controls.SetChildIndex(_selectionTitle, 0); + + _selectionMeta.Dock = DockStyle.Top; + _selectionMeta.Height = 48; + _selectionMeta.ForeColor = TextMuted; + _selectionMeta.Text = "支持 PNG、JPEG、WebP、MP4、WebM"; + panel.Controls.Add(_selectionMeta); + panel.Controls.SetChildIndex(_selectionMeta, 0); + + ConfigureButton(_applyButton, "应用到 Codex", true); + _applyButton.Dock = DockStyle.Top; + _applyButton.Height = 44; + _applyButton.Enabled = false; + _applyButton.Click += async (_, _) => await ApplySelectedAsync(); + panel.Controls.Add(_applyButton); + panel.Controls.SetChildIndex(_applyButton, 0); + + var revealHeading = CreateLabel("壁纸透出", 10f, FontStyle.Bold, TextMuted); + revealHeading.Dock = DockStyle.Top; + revealHeading.Height = 44; + revealHeading.Padding = new Padding(0, 18, 0, 0); + panel.Controls.Add(revealHeading); + panel.Controls.SetChildIndex(revealHeading, 0); + + _revealLabel.Dock = DockStyle.Top; + _revealLabel.Height = 28; + _revealLabel.ForeColor = TextPrimary; + _revealLabel.Text = "100% · 原始壁纸画面"; + panel.Controls.Add(_revealLabel); + panel.Controls.SetChildIndex(_revealLabel, 0); + + _revealSlider.Dock = DockStyle.Top; + _revealSlider.Height = 42; + _revealSlider.Minimum = 0; + _revealSlider.Maximum = 100; + _revealSlider.Value = 100; + _revealSlider.TickFrequency = 10; + _revealSlider.SmallChange = 5; + _revealSlider.LargeChange = 10; + _revealSlider.AccessibleName = "壁纸透出程度"; + _revealSlider.Scroll += (_, _) => UpdateRevealLabel(); + _revealSlider.MouseUp += async (_, _) => await CommitRevealAsync(); + _revealSlider.KeyUp += async (_, _) => await CommitRevealAsync(); + panel.Controls.Add(_revealSlider); + panel.Controls.SetChildIndex(_revealSlider, 0); + + _messageLabel.Dock = DockStyle.Bottom; + _messageLabel.Height = 50; + _messageLabel.ForeColor = TextMuted; + _messageLabel.TextAlign = ContentAlignment.MiddleLeft; + _messageLabel.AutoEllipsis = true; + _messageLabel.Text = "选择壁纸后可直接应用。"; + panel.Controls.Add(_messageLabel); + return panel; + } + + private async Task ReloadLibraryAsync() + { + _libraryLoad?.Cancel(); + _libraryLoad?.Dispose(); + _libraryLoad = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token); + var token = _libraryLoad.Token; + _librarySummary.Text = "正在读取壁纸库…"; + try + { + var items = await _catalog.LoadAsync(_settings.LibraryPath, _searchBox.Text, token); + token.ThrowIfCancellationRequested(); + ClearLibraryCards(); + _librarySummary.Text = items.Count == 0 + ? "没有找到支持的壁纸" + : $"共 {items.Count} 个可直接使用的壁纸"; + + foreach (var item in items) + { + token.ThrowIfCancellationRequested(); + var card = CreateWallpaperCard(item); + _libraryFlow.Controls.Add(card); + } + + _ = LoadCardThumbnailsAsync(items, token); + } + catch (OperationCanceledException) + { + // A newer search or folder selection owns the next render. + } + catch (Exception exception) + { + _librarySummary.Text = "读取壁纸库失败"; + ShowError(exception); + } + } + + private async Task LoadCardThumbnailsAsync( + IReadOnlyList items, + CancellationToken token) + { + var cardMap = _libraryFlow.Controls + .OfType() + .Where(control => control.Tag is WallpaperItem) + .ToDictionary(control => ((WallpaperItem)control.Tag!).Path, StringComparer.OrdinalIgnoreCase); + + foreach (var item in items) + { + token.ThrowIfCancellationRequested(); + var thumbnail = await Task.Run(() => ShellThumbnail.Get(item.Path, 280, 170), token); + if (thumbnail is null) + { + continue; + } + if (token.IsCancellationRequested || !cardMap.TryGetValue(item.Path, out var card) || card.IsDisposed) + { + thumbnail.Dispose(); + continue; + } + BeginInvoke(() => + { + if (card.IsDisposed) + { + thumbnail.Dispose(); + return; + } + var image = card.Controls.OfType().FirstOrDefault(); + if (image is not null) + { + image.Image?.Dispose(); + image.Image = thumbnail; + } + }); + } + } + + private Panel CreateWallpaperCard(WallpaperItem item) + { + var card = new Panel + { + Width = 226, + Height = 190, + Margin = new Padding(0, 0, 14, 14), + BackColor = Surface, + Cursor = Cursors.Hand, + Tag = item, + AccessibleName = $"{item.Name},{item.TypeLabel}", + }; + var image = new PictureBox + { + Dock = DockStyle.Top, + Height = 126, + BackColor = Color.FromArgb(9, 10, 13), + SizeMode = PictureBoxSizeMode.Zoom, + Cursor = Cursors.Hand, + }; + card.Controls.Add(image); + var title = CreateLabel(item.Name, 9.5f, FontStyle.Bold, TextPrimary); + title.Location = new Point(10, 136); + title.Size = new Size(204, 22); + title.AutoEllipsis = true; + title.Cursor = Cursors.Hand; + card.Controls.Add(title); + var meta = CreateLabel($"{item.TypeLabel} · {item.SizeLabel}", 8.5f, FontStyle.Regular, TextMuted); + meta.Location = new Point(10, 162); + meta.Size = new Size(204, 20); + meta.Cursor = Cursors.Hand; + card.Controls.Add(meta); + + void SelectCard(object? _, EventArgs __) => SelectWallpaper(item, card); + card.Click += SelectCard; + image.Click += SelectCard; + title.Click += SelectCard; + meta.Click += SelectCard; + foreach (Control control in new Control[] { card, image, title, meta }) + { + control.MouseEnter += (_, _) => + { + if (card != _selectedCard) + { + card.BackColor = SurfaceHover; + } + }; + control.MouseLeave += (_, _) => + { + if (card != _selectedCard) + { + card.BackColor = Surface; + } + }; + } + return card; + } + + private void SelectWallpaper(WallpaperItem item, Control card) + { + if (_selectedCard is not null && !_selectedCard.IsDisposed) + { + _selectedCard.BackColor = Surface; + } + _selectedWallpaper = item; + _selectedCard = card; + card.BackColor = Color.FromArgb(67, 43, 52); + _selectionTitle.Text = item.Name; + _selectionMeta.Text = $"{item.TypeLabel} · {item.Extension} · {item.SizeLabel}"; + _applyButton.Enabled = !_busy; + ShowPreview(item); + } + + private void ShowPreview(WallpaperItem item) + { + StopVideoPreview(); + _previewImage.Image?.Dispose(); + _previewImage.Image = null; + if (item.Kind == WallpaperKind.Video) + { + _previewImage.Visible = false; + _videoHost.Visible = true; + _mediaElement.Source = new Uri(item.Path); + _mediaElement.Position = TimeSpan.Zero; + _mediaElement.Play(); + } + else + { + _videoHost.Visible = false; + _previewImage.Visible = true; + _previewImage.Image = ShellThumbnail.Get(item.Path, 720, 480); + } + } + + private void StopVideoPreview() + { + try + { + _mediaElement.Stop(); + _mediaElement.Source = null; + } + catch + { + // The WPF media host may already be tearing down with the form. + } + } + + private async Task ApplySelectedAsync() + { + if (_selectedWallpaper is null) + { + return; + } + await RunOperationAsync( + $"正在应用 {_selectedWallpaper.Name}…", + () => _service.ApplyWallpaperAsync(_selectedWallpaper.Path, _lifetime.Token), + $"已应用:{_selectedWallpaper.Name}"); + } + + private async Task CommitRevealAsync() + { + var value = _revealSlider.Value; + await RunOperationAsync( + $"正在设置壁纸透出 {value}%…", + () => _service.SetRevealAsync(value, _lifetime.Token), + value == 100 ? "壁纸已按原始画面显示。" : $"壁纸透出已设为 {value}%。", + refreshLibrary: false); + } + + private async Task RestoreAsync() + { + var answer = MessageBox.Show( + "这会停止 Dream Skin 并恢复 Codex 官方外观。Codex 可能需要重启一次。是否继续?", + "恢复官方外观", + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning, + MessageBoxDefaultButton.Button2); + if (answer != DialogResult.Yes) + { + return; + } + + await RunOperationAsync( + "正在恢复 Codex 官方外观…", + () => _service.RestoreAsync(_lifetime.Token), + "已恢复 Codex 官方外观。", + refreshLibrary: false); + } + + private async Task ChooseLibraryAsync() + { + using var dialog = new FolderBrowserDialog + { + Description = "选择 Codex 壁纸库文件夹", + SelectedPath = _settings.LibraryPath, + ShowNewFolderButton = true, + UseDescriptionForTitle = true, + }; + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + _settings.LibraryPath = Path.GetFullPath(dialog.SelectedPath); + _settingsStore.Save(_settings); + _libraryPathLabel.Text = CompactPath(_settings.LibraryPath); + await ReloadLibraryAsync(); + } + + private void OpenLibraryFolder() + { + Directory.CreateDirectory(_settings.LibraryPath); + Process.Start(new ProcessStartInfo + { + FileName = "explorer.exe", + UseShellExecute = true, + ArgumentList = { _settings.LibraryPath }, + }); + } + + private async Task RunOperationAsync( + string progress, + Func operation, + string success, + bool refreshLibrary = false) + { + if (_busy) + { + return; + } + SetBusy(true); + ShowMessage(progress, false); + try + { + await operation(); + ShowMessage(success, false); + RefreshStatus(); + if (refreshLibrary) + { + await ReloadLibraryAsync(); + } + } + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) + { + // App shutdown owns cancellation. + } + catch (Exception exception) + { + ShowError(exception); + } + finally + { + SetBusy(false); + } + } + + private void RefreshStatus() + { + var status = _service.GetStatus(); + _pauseRequested = status.Paused; + _statusLabel.Text = status.Summary; + _statusDot.BackColor = status.Paused ? Warning : status.WatcherRunning ? Success : Warning; + _pauseButton.Text = status.Paused ? "恢复皮肤" : "暂停皮肤"; + _pauseButton.Enabled = !_busy && (status.WatcherRunning || status.Paused); + if (!_revealSlider.Focused) + { + _revealSlider.Value = Math.Clamp(status.RevealPercent, 0, 100); + UpdateRevealLabel(); + } + _trayIcon.Text = $"Codex Dream Skin · {status.Summary}"; + } + + private void UpdateRevealLabel() + { + _revealLabel.Text = _revealSlider.Value == 100 + ? "100% · 原始壁纸画面" + : $"{_revealSlider.Value}% · 主题蒙层仍保留"; + } + + private void SetBusy(bool busy) + { + _busy = busy; + _startButton.Enabled = !busy; + _restoreButton.Enabled = !busy; + _applyButton.Enabled = !busy && _selectedWallpaper is not null; + _revealSlider.Enabled = !busy; + _searchBox.Enabled = !busy; + UseWaitCursor = busy; + } + + private void ShowMessage(string message, bool error) + { + _messageLabel.Text = message; + _messageLabel.ForeColor = error ? Color.FromArgb(246, 128, 128) : TextMuted; + } + + private void ShowError(Exception exception) + { + ShowMessage(exception.Message, true); + MessageBox.Show( + exception.Message, + "Codex Dream Skin", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + } + + private NotifyIcon BuildTrayIcon() + { + var menu = new ContextMenuStrip + { + BackColor = SurfaceRaised, + ForeColor = TextPrimary, + Renderer = new ToolStripProfessionalRenderer(new DarkColorTable()), + }; + menu.Items.Add("打开壁纸管理器", null, (_, _) => ShowManager()); + menu.Items.Add("启动 / 重新应用", null, async (_, _) => await RunOperationAsync( + "正在启动 Codex Dream Skin…", + () => _service.StartAsync(_lifetime.Token), + "Dream Skin 已启动。")); + menu.Items.Add("暂停 / 恢复", null, async (_, _) => + { + var status = _service.GetStatus(); + await RunOperationAsync( + status.Paused ? "正在恢复皮肤…" : "正在暂停皮肤…", + () => _service.SetPausedAsync(!status.Paused, _lifetime.Token), + status.Paused ? "皮肤已恢复。" : "皮肤已暂停。"); + }); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("退出管理器", null, (_, _) => + { + _allowExit = true; + Application.Exit(); + }); + var tray = new NotifyIcon + { + Icon = _appIcon, + Text = "Codex Dream Skin", + Visible = true, + ContextMenuStrip = menu, + }; + tray.DoubleClick += (_, _) => ShowManager(); + return tray; + } + + private void ShowManager() + { + Show(); + WindowState = FormWindowState.Normal; + Activate(); + BringToFront(); + } + + private void OnFormClosing(object? sender, FormClosingEventArgs args) + { + if (_allowExit || args.CloseReason == CloseReason.WindowsShutDown) + { + return; + } + args.Cancel = true; + Hide(); + _trayIcon.ShowBalloonTip( + 1800, + "Codex Dream Skin", + "管理器仍在任务栏托盘运行。", + ToolTipIcon.Info); + } + + private void ClearLibraryCards() + { + StopVideoPreview(); + _selectedWallpaper = null; + _selectedCard = null; + _applyButton.Enabled = false; + _selectionTitle.Text = "请选择一张壁纸"; + _selectionMeta.Text = "支持 PNG、JPEG、WebP、MP4、WebM"; + _previewImage.Image?.Dispose(); + _previewImage.Image = null; + foreach (Control control in _libraryFlow.Controls) + { + foreach (var image in control.Controls.OfType()) + { + image.Image?.Dispose(); + } + control.Dispose(); + } + _libraryFlow.Controls.Clear(); + } + + private static void ConfigureButton(Button button, string text, bool primary, bool danger = false) + { + button.Text = text; + button.FlatStyle = FlatStyle.Flat; + button.FlatAppearance.BorderSize = primary ? 0 : 1; + button.FlatAppearance.BorderColor = danger + ? Color.FromArgb(122, 65, 76) + : Color.FromArgb(70, 75, 88); + button.BackColor = primary + ? Accent + : danger + ? Color.FromArgb(64, 37, 43) + : SurfaceRaised; + button.ForeColor = primary ? Color.White : danger ? Color.FromArgb(255, 178, 190) : TextPrimary; + button.Cursor = Cursors.Hand; + button.TextAlign = ContentAlignment.MiddleCenter; + button.FlatAppearance.MouseOverBackColor = primary + ? AccentHover + : danger + ? Color.FromArgb(83, 44, 52) + : SurfaceHover; + } + + private static Label CreateLabel(string text, float size, FontStyle style, Color color) => new() + { + Text = text, + Font = new Font("Segoe UI Variable Text", size, style, GraphicsUnit.Point), + ForeColor = color, + BackColor = Color.Transparent, + }; + + private static string CompactPath(string path) + { + var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); + return path.StartsWith(desktop, StringComparison.OrdinalIgnoreCase) + ? "桌面" + path[desktop.Length..] + : path; + } + + private static Icon CreateAppIcon() + { + using var bitmap = new Bitmap(64, 64); + using var graphics = Graphics.FromImage(bitmap); + graphics.SmoothingMode = SmoothingMode.AntiAlias; + graphics.Clear(Color.FromArgb(20, 22, 28)); + using var brush = new LinearGradientBrush( + new Rectangle(8, 8, 48, 48), + Color.FromArgb(242, 112, 145), + Color.FromArgb(126, 91, 220), + 45f); + graphics.FillEllipse(brush, 8, 8, 48, 48); + using var cutout = new SolidBrush(Color.FromArgb(20, 22, 28)); + graphics.FillEllipse(cutout, 24, 9, 34, 34); + using var star = new SolidBrush(Color.White); + graphics.FillEllipse(star, 39, 38, 5, 5); + graphics.FillEllipse(star, 28, 45, 3, 3); + var handle = bitmap.GetHicon(); + try + { + using var source = Icon.FromHandle(handle); + return (Icon)source.Clone(); + } + finally + { + DestroyIcon(handle); + } + } + + private static void SetDarkTitleBar(IntPtr handle) + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763)) + { + return; + } + var enabled = 1; + DwmSetWindowAttribute(handle, 20, ref enabled, sizeof(int)); + } + + [DllImport("dwmapi.dll")] + private static extern int DwmSetWindowAttribute( + IntPtr window, + int attribute, + ref int value, + int valueSize); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DestroyIcon(IntPtr icon); + + private sealed class DarkColorTable : ProfessionalColorTable + { + public override Color ToolStripDropDownBackground => SurfaceRaised; + public override Color ImageMarginGradientBegin => SurfaceRaised; + public override Color ImageMarginGradientMiddle => SurfaceRaised; + public override Color ImageMarginGradientEnd => SurfaceRaised; + public override Color MenuItemSelected => SurfaceHover; + public override Color MenuItemBorder => SurfaceHover; + public override Color SeparatorDark => Color.FromArgb(68, 72, 84); + public override Color SeparatorLight => Color.FromArgb(68, 72, 84); + } +} diff --git a/windows/app/CodexDreamSkin.Manager/Models.cs b/windows/app/CodexDreamSkin.Manager/Models.cs new file mode 100644 index 0000000..15e6903 --- /dev/null +++ b/windows/app/CodexDreamSkin.Manager/Models.cs @@ -0,0 +1,111 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodexDreamSkin.Manager; + +internal enum WallpaperKind +{ + Image, + Video, +} + +internal sealed record WallpaperItem( + string Path, + string Name, + string Extension, + WallpaperKind Kind, + long Length, + DateTime LastWriteTime) +{ + public string TypeLabel => Kind == WallpaperKind.Video ? "动态壁纸" : "静态壁纸"; + public string SizeLabel => Length >= 1024L * 1024L + ? $"{Length / 1024d / 1024d:0.#} MB" + : $"{Math.Max(1, Length / 1024d):0} KB"; +} + +internal sealed record DreamSkinStatus( + bool WatcherRunning, + bool CodexRunning, + bool Paused, + string? ActiveTheme, + string? MediaPath, + WallpaperKind? MediaKind, + int RevealPercent) +{ + public string Summary => Paused + ? "皮肤已暂停" + : WatcherRunning + ? "Codex 壁纸正在运行" + : "等待启动"; +} + +internal sealed class AppSettings +{ + public string LibraryPath { get; set; } = SettingsStore.DefaultLibraryPath; + public bool StartWithWindows { get; set; } +} + +internal sealed class SettingsStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public static string DefaultLibraryPath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), + "Codex壁纸库"); + + public SettingsStore() + { + SettingsPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "CodexDreamSkin", + "manager-settings.json"); + } + + public string SettingsPath { get; } + + public AppSettings Load() + { + try + { + if (!File.Exists(SettingsPath)) + { + return new AppSettings(); + } + + var settings = JsonSerializer.Deserialize(File.ReadAllText(SettingsPath), JsonOptions) + ?? new AppSettings(); + if (string.IsNullOrWhiteSpace(settings.LibraryPath)) + { + settings.LibraryPath = DefaultLibraryPath; + } + return settings; + } + catch + { + return new AppSettings(); + } + } + + public void Save(AppSettings settings) + { + var directory = Path.GetDirectoryName(SettingsPath)!; + Directory.CreateDirectory(directory); + var temporary = SettingsPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + File.WriteAllText(temporary, JsonSerializer.Serialize(settings, JsonOptions)); + File.Move(temporary, SettingsPath, true); + } + finally + { + if (File.Exists(temporary)) + { + File.Delete(temporary); + } + } + } +} diff --git a/windows/app/CodexDreamSkin.Manager/PowerShellRunner.cs b/windows/app/CodexDreamSkin.Manager/PowerShellRunner.cs new file mode 100644 index 0000000..ccbe317 --- /dev/null +++ b/windows/app/CodexDreamSkin.Manager/PowerShellRunner.cs @@ -0,0 +1,79 @@ +using System.Diagnostics; + +namespace CodexDreamSkin.Manager; + +internal sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError) +{ + public void ThrowIfFailed(string operation) + { + if (ExitCode == 0) + { + return; + } + + var detail = string.IsNullOrWhiteSpace(StandardError) ? StandardOutput : StandardError; + throw new InvalidOperationException($"{operation}失败({ExitCode}):{detail.Trim()}"); + } +} + +internal sealed class PowerShellRunner +{ + private readonly RuntimeProvisioner _runtime; + private readonly string _powershellPath; + + public PowerShellRunner(RuntimeProvisioner runtime) + { + _runtime = runtime; + _powershellPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Windows), + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + if (!File.Exists(_powershellPath)) + { + throw new FileNotFoundException("找不到 Windows PowerShell 5.1。", _powershellPath); + } + } + + public async Task RunScriptAsync( + string script, + IEnumerable arguments, + CancellationToken cancellationToken = default) + { + var startInfo = new ProcessStartInfo + { + FileName = _powershellPath, + WorkingDirectory = _runtime.PayloadRoot, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = System.Text.Encoding.UTF8, + StandardErrorEncoding = System.Text.Encoding.UTF8, + }; + startInfo.ArgumentList.Add("-NoProfile"); + startInfo.ArgumentList.Add("-ExecutionPolicy"); + startInfo.ArgumentList.Add("Bypass"); + startInfo.ArgumentList.Add("-File"); + startInfo.ArgumentList.Add(script); + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + var nodeDirectory = Path.GetDirectoryName(_runtime.NodePath)!; + startInfo.Environment["PATH"] = nodeDirectory + Path.PathSeparator + + (startInfo.Environment.TryGetValue("PATH", out var path) ? path : Environment.GetEnvironmentVariable("PATH")); + + 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); + return new ProcessResult( + process.ExitCode, + await outputTask, + await errorTask); + } +} diff --git a/windows/app/CodexDreamSkin.Manager/Program.cs b/windows/app/CodexDreamSkin.Manager/Program.cs new file mode 100644 index 0000000..444a836 --- /dev/null +++ b/windows/app/CodexDreamSkin.Manager/Program.cs @@ -0,0 +1,91 @@ +using System.Reflection; + +namespace CodexDreamSkin.Manager; + +internal static class Program +{ + private const string MutexName = @"Local\CodexDreamSkin.Manager"; + + [STAThread] + private static int Main(string[] args) + { + using var mutex = new Mutex(true, MutexName, out var createdNew); + if (!createdNew && !args.Contains("--self-test", StringComparer.OrdinalIgnoreCase)) + { + MessageBox.Show( + "Codex Dream Skin Manager 已经在运行,请查看任务栏托盘。", + "Codex Dream Skin", + MessageBoxButtons.OK, + MessageBoxIcon.Information); + return 0; + } + + try + { + var runtime = new RuntimeProvisioner(); + runtime.EnsureExtracted(); + + if (args.Contains("--self-test", StringComparer.OrdinalIgnoreCase)) + { + return SelfTest.Run(runtime); + } + + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + + var settings = new SettingsStore(); + var service = new DreamSkinService(runtime); + var minimized = args.Contains("--minimized", StringComparer.OrdinalIgnoreCase); + Application.Run(new MainForm(service, settings, minimized)); + return 0; + } + catch (Exception exception) + { + var logPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "CodexDreamSkin", + "manager-error.log"); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(logPath)!); + File.AppendAllText(logPath, $"[{DateTimeOffset.Now:O}] {exception}\r\n"); + } + catch + { + // The original exception is more important than secondary logging. + } + + MessageBox.Show( + $"启动失败:{exception.Message}\r\n\r\n日志:{logPath}", + "Codex Dream Skin", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + return 1; + } + } +} + +internal static class SelfTest +{ + public static int Run(RuntimeProvisioner runtime) + { + try + { + runtime.ValidateExtractedPayload(); + if (!WallpaperCatalog.IsSupported("sample.mp4") || + !WallpaperCatalog.IsSupported("sample.webp") || + WallpaperCatalog.IsSupported("sample.exe")) + { + return 2; + } + + var version = Assembly.GetExecutingAssembly().GetName().Version; + return version is null ? 3 : 0; + } + catch + { + return 4; + } + } +} diff --git a/windows/app/CodexDreamSkin.Manager/RuntimeProvisioner.cs b/windows/app/CodexDreamSkin.Manager/RuntimeProvisioner.cs new file mode 100644 index 0000000..aad49fd --- /dev/null +++ b/windows/app/CodexDreamSkin.Manager/RuntimeProvisioner.cs @@ -0,0 +1,176 @@ +using System.IO; +using System.Reflection; +using System.Security.Cryptography; + +namespace CodexDreamSkin.Manager; + +internal sealed class RuntimeProvisioner +{ + private const string ResourcePrefix = "DreamSkin."; + private static readonly IReadOnlyDictionary ResourceMap = + new Dictionary(StringComparer.Ordinal) + { + ["Engine.assets.dream-reference.jpg"] = @"payload\assets\dream-reference.jpg", + ["Engine.assets.dream-skin.css"] = @"payload\assets\dream-skin.css", + ["Engine.assets.renderer-inject.js"] = @"payload\assets\renderer-inject.js", + ["Engine.assets.theme.json"] = @"payload\assets\theme.json", + ["Engine.scripts.common-windows.ps1"] = @"payload\scripts\common-windows.ps1", + ["Engine.scripts.config-utf8.ps1"] = @"payload\scripts\config-utf8.ps1", + ["Engine.scripts.image-metadata.mjs"] = @"payload\scripts\image-metadata.mjs", + ["Engine.scripts.injector.mjs"] = @"payload\scripts\injector.mjs", + ["Engine.scripts.install-dream-skin.ps1"] = @"payload\scripts\install-dream-skin.ps1", + ["Engine.scripts.manager-command.ps1"] = @"payload\scripts\manager-command.ps1", + ["Engine.scripts.restore-dream-skin.ps1"] = @"payload\scripts\restore-dream-skin.ps1", + ["Engine.scripts.start-dream-skin.ps1"] = @"payload\scripts\start-dream-skin.ps1", + ["Engine.scripts.theme-windows.ps1"] = @"payload\scripts\theme-windows.ps1", + ["Engine.scripts.tray-dream-skin.ps1"] = @"payload\scripts\tray-dream-skin.ps1", + ["Engine.scripts.verify-dream-skin.ps1"] = @"payload\scripts\verify-dream-skin.ps1", + ["Runtime.node.exe"] = @"node\node.exe", + ["Runtime.NODE-LICENSE.txt"] = @"node\NODE-LICENSE.txt", + }; + + private readonly Assembly _assembly = Assembly.GetExecutingAssembly(); + + public RuntimeProvisioner() + { + StateRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "CodexDreamSkin"); + var version = _assembly.GetName().Version?.ToString(3) ?? "1.0.0"; + RuntimeRoot = Path.Combine(StateRoot, "manager-runtime", version); + PayloadRoot = Path.Combine(RuntimeRoot, "payload"); + ScriptsRoot = Path.Combine(PayloadRoot, "scripts"); + NodePath = Path.Combine(RuntimeRoot, "node", "node.exe"); + } + + public string StateRoot { get; } + public string RuntimeRoot { get; } + public string PayloadRoot { get; } + public string ScriptsRoot { get; } + public string NodePath { get; } + public string StartScript => Path.Combine(ScriptsRoot, "start-dream-skin.ps1"); + public string RestoreScript => Path.Combine(ScriptsRoot, "restore-dream-skin.ps1"); + public string ManagerCommandScript => Path.Combine(ScriptsRoot, "manager-command.ps1"); + + public void EnsureExtracted() + { + EnsureSafeDirectory(StateRoot, StateRoot); + EnsureSafeDirectory(Path.Combine(StateRoot, "manager-runtime"), StateRoot); + EnsureSafeDirectory(RuntimeRoot, StateRoot); + + foreach (var entry in ResourceMap) + { + var destination = Path.Combine(RuntimeRoot, entry.Value); + ExtractAtomically(ResourcePrefix + entry.Key, destination); + } + + ValidateExtractedPayload(); + } + + public void ValidateExtractedPayload() + { + foreach (var relativePath in ResourceMap.Values) + { + var path = Path.Combine(RuntimeRoot, relativePath); + if (!File.Exists(path) || new FileInfo(path).Length == 0) + { + throw new InvalidOperationException($"嵌入运行时文件缺失:{relativePath}"); + } + } + } + + private void ExtractAtomically(string resourceName, string destination) + { + using var resource = _assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"EXE 缺少嵌入资源:{resourceName}"); + + var directory = Path.GetDirectoryName(destination) + ?? throw new InvalidOperationException("嵌入资源目标目录无效。"); + EnsureSafeDirectory(directory, StateRoot); + + if (File.Exists(destination) && StreamsMatch(resource, destination)) + { + return; + } + + resource.Position = 0; + var temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + using (var output = new FileStream( + temporary, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 1024 * 1024, + FileOptions.WriteThrough)) + { + resource.CopyTo(output); + output.Flush(true); + } + + File.Move(temporary, destination, true); + } + finally + { + if (File.Exists(temporary)) + { + File.Delete(temporary); + } + } + } + + private static bool StreamsMatch(Stream resource, string destination) + { + if (!resource.CanSeek) + { + return false; + } + + var info = new FileInfo(destination); + if (resource.Length != info.Length) + { + return false; + } + + using var resourceHash = SHA256.Create(); + var expected = resourceHash.ComputeHash(resource); + resource.Position = 0; + using var file = File.OpenRead(destination); + var actual = SHA256.HashData(file); + return CryptographicOperations.FixedTimeEquals(expected, actual); + } + + private static void EnsureSafeDirectory(string path, string root) + { + var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar); + var fullPath = Path.GetFullPath(path); + if (!fullPath.Equals(fullRoot, StringComparison.OrdinalIgnoreCase) && + !fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"运行时目录越过受管根目录:{fullPath}"); + } + + var current = fullPath; + while (!string.IsNullOrEmpty(current)) + { + if (Directory.Exists(current)) + { + var attributes = File.GetAttributes(current); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException($"运行时目录包含重解析点:{current}"); + } + } + + if (current.Equals(fullRoot, StringComparison.OrdinalIgnoreCase)) + { + break; + } + + current = Path.GetDirectoryName(current) ?? string.Empty; + } + + Directory.CreateDirectory(fullPath); + } +} diff --git a/windows/app/CodexDreamSkin.Manager/ShellThumbnail.cs b/windows/app/CodexDreamSkin.Manager/ShellThumbnail.cs new file mode 100644 index 0000000..8f10c63 --- /dev/null +++ b/windows/app/CodexDreamSkin.Manager/ShellThumbnail.cs @@ -0,0 +1,82 @@ +using System.Runtime.InteropServices; + +namespace CodexDreamSkin.Manager; + +internal static class ShellThumbnail +{ + [Flags] + private enum ThumbnailOptions + { + BiggerSizeOk = 0x1, + ThumbnailOnly = 0x8, + } + + [StructLayout(LayoutKind.Sequential)] + private struct NativeSize + { + public int Width; + public int Height; + } + + [ComImport] + [Guid("bcc18b79-ba16-442f-80c4-8a59c30c463b")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellItemImageFactory + { + void GetImage( + [In] NativeSize size, + [In] ThumbnailOptions flags, + out IntPtr bitmapHandle); + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)] + private static extern void SHCreateItemFromParsingName( + [MarshalAs(UnmanagedType.LPWStr)] string path, + IntPtr bindingContext, + [In] ref Guid interfaceId, + [MarshalAs(UnmanagedType.Interface)] out IShellItemImageFactory imageFactory); + + [DllImport("gdi32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DeleteObject(IntPtr handle); + + public static Image? Get(string path, int width, int height) + { + try + { + var id = typeof(IShellItemImageFactory).GUID; + SHCreateItemFromParsingName(path, IntPtr.Zero, ref id, out var factory); + factory.GetImage( + new NativeSize { Width = width, Height = height }, + ThumbnailOptions.ThumbnailOnly | ThumbnailOptions.BiggerSizeOk, + out var bitmapHandle); + try + { + using var source = Image.FromHbitmap(bitmapHandle); + return new Bitmap(source); + } + finally + { + DeleteObject(bitmapHandle); + Marshal.FinalReleaseComObject(factory); + } + } + catch + { + if (WallpaperCatalog.GetKind(path) == WallpaperKind.Video) + { + return null; + } + + try + { + using var source = Image.FromFile(path); + return new Bitmap(source); + } + catch + { + return null; + } + } + } +} diff --git a/windows/app/CodexDreamSkin.Manager/WallpaperCatalog.cs b/windows/app/CodexDreamSkin.Manager/WallpaperCatalog.cs new file mode 100644 index 0000000..bfe70b1 --- /dev/null +++ b/windows/app/CodexDreamSkin.Manager/WallpaperCatalog.cs @@ -0,0 +1,80 @@ +namespace CodexDreamSkin.Manager; + +internal sealed class WallpaperCatalog +{ + private static readonly HashSet ImageExtensions = + new(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".webp" }; + private static readonly HashSet VideoExtensions = + new(StringComparer.OrdinalIgnoreCase) { ".mp4", ".webm" }; + + public static bool IsSupported(string path) + { + var extension = Path.GetExtension(path); + return ImageExtensions.Contains(extension) || VideoExtensions.Contains(extension); + } + + public static WallpaperKind GetKind(string path) => + VideoExtensions.Contains(Path.GetExtension(path)) ? WallpaperKind.Video : WallpaperKind.Image; + + public Task> LoadAsync( + string libraryPath, + string? search, + CancellationToken cancellationToken = default) + { + return Task.Run>(() => + { + if (!Directory.Exists(libraryPath)) + { + return Array.Empty(); + } + + var options = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + ReturnSpecialDirectories = false, + AttributesToSkip = FileAttributes.ReparsePoint | FileAttributes.System, + }; + var normalizedSearch = search?.Trim(); + var items = new List(); + foreach (var path in Directory.EnumerateFiles(libraryPath, "*", options)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!IsSupported(path)) + { + continue; + } + + var name = Path.GetFileNameWithoutExtension(path); + if (!string.IsNullOrWhiteSpace(normalizedSearch) && + !name.Contains(normalizedSearch, StringComparison.CurrentCultureIgnoreCase) && + !path.Contains(normalizedSearch, StringComparison.CurrentCultureIgnoreCase)) + { + continue; + } + + try + { + var info = new FileInfo(path); + items.Add(new WallpaperItem( + info.FullName, + name, + info.Extension.ToUpperInvariant(), + GetKind(info.FullName), + info.Length, + info.LastWriteTime)); + } + catch (IOException) + { + // A single file changing during discovery must not hide the rest of the library. + } + } + + return items + .OrderByDescending(item => item.LastWriteTime) + .ThenBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase) + .Take(500) + .ToArray(); + }, cancellationToken); + } +} diff --git a/windows/app/build-manager.ps1 b/windows/app/build-manager.ps1 new file mode 100644 index 0000000..d3b5ac9 --- /dev/null +++ b/windows/app/build-manager.ps1 @@ -0,0 +1,125 @@ +[CmdletBinding()] +param( + [string]$OutputDirectory, + [string]$DesktopOutput, + [switch]$SkipWindowsTests +) + +$ErrorActionPreference = 'Stop' +$windowsRoot = Split-Path -Parent $PSScriptRoot +$OutputDirectory = if ($OutputDirectory) { + [System.IO.Path]::GetFullPath($OutputDirectory) +} else { + Join-Path $windowsRoot 'dist' +} +$project = Join-Path $PSScriptRoot 'CodexDreamSkin.Manager\CodexDreamSkin.Manager.csproj' + +function Get-ManagerDotNet { + $installed = Get-Command dotnet.exe -ErrorAction SilentlyContinue + if ($null -ne $installed) { + $sdks = & $installed.Source --list-sdks + if (@($sdks | Where-Object { $_ -match '^8\.' }).Count -gt 0) { + return $installed.Source + } + } + + $cacheRoot = Join-Path ( + [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::UserProfile) + ) '.cache\codex-runtimes' + $candidate = Get-ChildItem -LiteralPath $cacheRoot -Directory -Filter 'dotnet-sdk-8.*' ` + -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | + ForEach-Object { Join-Path $_.FullName 'dotnet.exe' } | + Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | + Select-Object -First 1 + if ($candidate) { return $candidate } + throw 'A .NET 8 SDK is required to build Codex Dream Skin Manager.' +} + +function Get-ManagerNode { + $node = Get-Command node.exe -ErrorAction SilentlyContinue + if ($null -eq $node) { throw 'Node.js 22 or newer is required for the bundled runtime.' } + $versionText = (& $node.Source --version).TrimStart('v') + $version = [version]$versionText + if ($version.Major -lt 22) { throw "Node.js 22 or newer is required; found $versionText." } + return [pscustomobject]@{ Path = $node.Source; Version = $versionText } +} + +$dotnet = Get-ManagerDotNet +$node = Get-ManagerNode +$cache = Join-Path $env:LOCALAPPDATA 'CodexDreamSkin\build-cache' +New-Item -ItemType Directory -Path $cache -Force | Out-Null +$nodeLicense = Join-Path $cache "node-v$($node.Version)-LICENSE.txt" +if (-not (Test-Path -LiteralPath $nodeLicense -PathType Leaf)) { + $licenseUrl = "https://raw.githubusercontent.com/nodejs/node/v$($node.Version)/LICENSE" + Invoke-WebRequest -Uri $licenseUrl -OutFile $nodeLicense -UseBasicParsing -TimeoutSec 30 +} +if ((Get-Item -LiteralPath $nodeLicense).Length -lt 1000) { + throw 'The downloaded Node.js license file is incomplete.' +} + +if (-not $SkipWindowsTests) { + & powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File (Join-Path $windowsRoot 'tests\run-tests.ps1') + if ($LASTEXITCODE -ne 0) { throw 'Windows regression tests failed.' } +} + +$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 restore $project ` + "-p:NodeExe=$($node.Path)" ` + "-p:NodeLicense=$nodeLicense" + if ($LASTEXITCODE -ne 0) { throw '.NET restore failed.' } + + & $dotnet publish $project ` + --configuration Release ` + --runtime win-x64 ` + --self-contained true ` + --no-restore ` + --output $staging ` + "-p:NodeExe=$($node.Path)" ` + "-p:NodeLicense=$nodeLicense" ` + '-p:PublishSingleFile=true' ` + '-p:IncludeNativeLibrariesForSelfExtract=true' ` + '-p:EnableCompressionInSingleFile=true' + if ($LASTEXITCODE -ne 0) { throw '.NET publish failed.' } + + $publishedExe = Join-Path $staging 'CodexDreamSkinManager.exe' + if (-not (Test-Path -LiteralPath $publishedExe -PathType Leaf)) { + throw 'Single-file publish did not produce CodexDreamSkinManager.exe.' + } + $selfTest = Start-Process -FilePath $publishedExe -ArgumentList '--self-test' -Wait -PassThru + if ($selfTest.ExitCode -ne 0) { + throw "Published manager self-test failed with exit code $($selfTest.ExitCode)." + } + + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + $outputExe = Join-Path ([System.IO.Path]::GetFullPath($OutputDirectory)) 'CodexDreamSkinManager.exe' + Copy-Item -LiteralPath $publishedExe -Destination $outputExe -Force + $hash = Get-FileHash -Algorithm SHA256 -LiteralPath $outputExe + [System.IO.File]::WriteAllText( + "$outputExe.sha256", + "$($hash.Hash.ToLowerInvariant()) CodexDreamSkinManager.exe`r`n", + [System.Text.UTF8Encoding]::new($false)) + + if ($DesktopOutput) { + $desktopPath = [System.IO.Path]::GetFullPath($DesktopOutput) + $desktopDirectory = Split-Path -Parent $desktopPath + New-Item -ItemType Directory -Path $desktopDirectory -Force | Out-Null + Copy-Item -LiteralPath $outputExe -Destination $desktopPath -Force + } + + [pscustomobject]@{ + output = $outputExe + desktopOutput = if ($DesktopOutput) { [System.IO.Path]::GetFullPath($DesktopOutput) } else { $null } + bytes = (Get-Item -LiteralPath $outputExe).Length + sha256 = $hash.Hash.ToLowerInvariant() + nodeVersion = $node.Version + dotnet = $dotnet + } | ConvertTo-Json -Depth 4 +} finally { + if (Test-Path -LiteralPath $staging) { + Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/windows/scripts/manager-command.ps1 b/windows/scripts/manager-command.ps1 new file mode 100644 index 0000000..8b0acf4 --- /dev/null +++ b/windows/scripts/manager-command.ps1 @@ -0,0 +1,77 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet('SetWallpaper', 'SetReveal', 'Pause', 'Resume', 'Status')] + [string]$Action, + [string]$Path, + [ValidateRange(0, 100)] + [int]$Percent = 100, + [string]$StateRoot = (Join-Path $env:LOCALAPPDATA 'CodexDreamSkin') +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common-windows.ps1') +. (Join-Path $PSScriptRoot 'theme-windows.ps1') + +$paths = Get-DreamSkinThemePaths -StateRoot $StateRoot + +switch ($Action) { + 'SetWallpaper' { + if (-not $Path) { throw 'SetWallpaper requires -Path.' } + $fullPath = [System.IO.Path]::GetFullPath($Path) + $theme = $null + if (Test-Path -LiteralPath $paths.Active -PathType Container) { + try { + $theme = (Read-DreamSkinTheme -ThemeDirectory $paths.Active -SkipImageMetadata).Theme + } catch { + $theme = $null + } + } + $result = Set-DreamSkinActiveTheme -ImagePath $fullPath -Theme $theme ` + -Name ([System.IO.Path]::GetFileNameWithoutExtension($fullPath)) -StateRoot $StateRoot + [pscustomobject]@{ + action = $Action + mediaPath = $result.MediaPath + mediaType = $result.MediaType + name = $result.Theme.name + reveal = Get-DreamSkinThemeMediaOpacity -Theme $result.Theme + } | ConvertTo-Json -Depth 5 + } + 'SetReveal' { + $result = Set-DreamSkinActiveThemeMediaOpacity -Opacity ([double]$Percent / 100) ` + -StateRoot $StateRoot + [pscustomobject]@{ + action = $Action + percent = $Percent + reveal = Get-DreamSkinThemeMediaOpacity -Theme $result.Theme + } | ConvertTo-Json -Depth 5 + } + 'Pause' { + Set-DreamSkinPaused -Paused $true -StateRoot $StateRoot | Out-Null + [pscustomobject]@{ action = $Action; paused = $true } | ConvertTo-Json + } + 'Resume' { + Set-DreamSkinPaused -Paused $false -StateRoot $StateRoot | Out-Null + [pscustomobject]@{ action = $Action; paused = $false } | ConvertTo-Json + } + 'Status' { + $active = $null + if (Test-Path -LiteralPath $paths.Active -PathType Container) { + try { + $active = Read-DreamSkinTheme -ThemeDirectory $paths.Active -SkipImageMetadata + } catch { + $active = $null + } + } + [pscustomobject]@{ + action = $Action + paused = Test-DreamSkinPaused -StateRoot $StateRoot + activeTheme = if ($null -ne $active) { $active.Theme.name } else { $null } + mediaPath = if ($null -ne $active) { $active.MediaPath } else { $null } + mediaType = if ($null -ne $active) { $active.MediaType } else { $null } + reveal = if ($null -ne $active) { + Get-DreamSkinThemeMediaOpacity -Theme $active.Theme + } else { [double]1 } + } | ConvertTo-Json -Depth 5 + } +} diff --git a/windows/tests/run-tests.ps1 b/windows/tests/run-tests.ps1 index fa5a9df..ba0ff82 100644 --- a/windows/tests/run-tests.ps1 +++ b/windows/tests/run-tests.ps1 @@ -780,6 +780,56 @@ try { )) { if (-not $css.Contains($requiredCss)) { throw "Windows immersive CSS is missing: $requiredCss" } } + + $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)) { + if (-not (Test-Path -LiteralPath $managerPath -PathType Leaf)) { + throw "Graphical manager file is missing: $managerPath" + } + } + foreach ($managerScriptPath in @($managerCommandPath, $managerBuildPath)) { + $managerTokens = $null + $managerParseErrors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $managerScriptPath, [ref]$managerTokens, [ref]$managerParseErrors + ) | Out-Null + if ($managerParseErrors.Count -gt 0) { + throw "Graphical manager PowerShell entry point failed to parse: $managerScriptPath" + } + } + $managerCommandSource = Read-DreamSkinUtf8File -Path $managerCommandPath + foreach ($managerAction in @('SetWallpaper', 'SetReveal', 'Pause', 'Resume', 'Status')) { + if (-not $managerCommandSource.Contains("'$managerAction'")) { + throw "Graphical manager command is missing: $managerAction" + } + } + $managerProjectSource = Read-DreamSkinUtf8File -Path $managerProjectPath + foreach ($managerPublishContract in @( + 'true', + 'true', + 'DreamSkin.Runtime.node.exe', + 'DreamSkin.Engine.scripts.manager-command.ps1' + )) { + if (-not $managerProjectSource.Contains($managerPublishContract)) { + throw "Graphical manager packaging contract is missing: $managerPublishContract" + } + } + $managerFormSource = Read-DreamSkinUtf8File -Path ( + Join-Path $Root 'app\CodexDreamSkin.Manager\MainForm.cs' + ) + foreach ($managerUiContract in @( + '100% · 原始壁纸画面', + '开机启动管理器', + '退出管理器', + '支持 PNG、JPEG、WebP、MP4、WebM' + )) { + if (-not $managerFormSource.Contains($managerUiContract)) { + throw "Graphical manager UI contract is missing: $managerUiContract" + } + } + $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')) { if (-not $traySource.Contains($requiredTrayAction)) { throw "Tray action is missing: $requiredTrayAction" }