diff --git a/windows/CHANGELOG.md b/windows/CHANGELOG.md index 972f3362..a1c97904 100644 --- a/windows/CHANGELOG.md +++ b/windows/CHANGELOG.md @@ -1,5 +1,10 @@ # Windows Changelog +## Unreleased + +### 新增 + +- 系统托盘新增缩略图主题选择器入口;选择器支持单击应用、显示当前主题并删除非当前主题。托盘同时新增已保存主题自动轮换,可设置 15、30、60、120 或 240 分钟间隔,并可随时关闭。 ## 1.5.6 — 2026-07-26 ### 安全 diff --git a/windows/README.en.md b/windows/README.en.md index cf8bbee8..b40bbb8f 100644 --- a/windows/README.en.md +++ b/windows/README.en.md @@ -90,6 +90,8 @@ Open `Codex Dream Skin - Tray` to: - Import a PNG, JPEG, or WebP background. - Import an ordinary `.zip` theme pack into Saved Themes (`.dreamskin` is not supported). - Save the active theme and switch through saved themes. +- Open a thumbnail theme picker. Clicking a card applies that theme, and non-active themes can be deleted. +- Rotate saved themes every 15, 30, 60, 120, or 240 minutes, or turn rotation off at any time. - Pause or resume the skin. - Reapply the theme or fully restore Codex. diff --git a/windows/README.md b/windows/README.md index 1f7e2e2d..f21572dc 100644 --- a/windows/README.md +++ b/windows/README.md @@ -83,6 +83,8 @@ powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File .\scripts\verify-d - 更换 PNG、JPEG 或 WebP 背景图。 - 导入普通 `.zip` 主题包到“已保存主题”(不支持 `.dreamskin`)。 - 保存当前主题并从「已保存主题」切换。 +- 打开带缩略图的主题选择器;单击卡片会应用主题,非当前主题可删除。 +- 开启 15、30、60、120 或 240 分钟自动轮换,或随时关闭。 - 暂停或继续显示皮肤。 - 重新应用主题,或完整恢复 Codex。 diff --git a/windows/scripts/common-windows.ps1 b/windows/scripts/common-windows.ps1 index 54c5497c..4a20a6d6 100644 --- a/windows/scripts/common-windows.ps1 +++ b/windows/scripts/common-windows.ps1 @@ -68,6 +68,7 @@ function Get-DreamSkinRuntimeEnginePaths { Start = Join-Path $scripts 'start-dream-skin.ps1' Restore = Join-Path $scripts 'restore-dream-skin.ps1' Tray = Join-Path $scripts 'tray-dream-skin.ps1' + ThemePicker = Join-Path $scripts 'theme-picker.ps1' CheckUpdate = Join-Path $scripts 'check-update.ps1' } } @@ -202,6 +203,7 @@ function Install-DreamSkinRuntimeEngine { 'scripts\install-dream-skin.ps1', 'scripts\restore-dream-skin.ps1', 'scripts\start-dream-skin.ps1', + 'scripts\theme-picker.ps1', 'scripts\theme-windows.ps1', 'scripts\tray-dream-skin.ps1', 'scripts\validate-safe-css-file.mjs', diff --git a/windows/scripts/install-dream-skin.ps1 b/windows/scripts/install-dream-skin.ps1 index 5548d56d..3d979b27 100644 --- a/windows/scripts/install-dream-skin.ps1 +++ b/windows/scripts/install-dream-skin.ps1 @@ -53,6 +53,7 @@ try { $startScript = $engine.Start $restoreScript = $engine.Restore $trayScript = $engine.Tray + $themePickerScript = $engine.ThemePicker $portArgument = if ($PortExplicit) { " -Port $Port" } else { '' } foreach ($folder in @($desktop, $startMenu)) { @@ -61,6 +62,7 @@ try { $shortcut.Arguments = "-NoProfile -ExecutionPolicy RemoteSigned -File `"$startScript`"$portArgument -PromptRestart" $shortcut.WorkingDirectory = $engine.Root $shortcut.Description = 'Launch the official Codex app with Codex Dream Skin' + $shortcut.WindowStyle = 7 $shortcut.Save() } @@ -69,18 +71,20 @@ try { $restore.Arguments = "-NoProfile -ExecutionPolicy RemoteSigned -File `"$restoreScript`"$portArgument -RestoreBaseTheme -PromptRestart" $restore.WorkingDirectory = $engine.Root $restore.Description = 'Restore the official Codex appearance and close the CDP session' + $restore.WindowStyle = 7 $restore.Save() foreach ($folder in @($desktop, $startMenu)) { $tray = $shell.CreateShortcut((Join-Path $folder 'Codex Dream Skin - Tray.lnk')) $tray.TargetPath = $powershell - $tray.Arguments = "-NoProfile -STA -WindowStyle Hidden -ExecutionPolicy RemoteSigned -File `"$trayScript`"$portArgument" + $tray.Arguments = "-NoProfile -STA -WindowStyle Hidden -ExecutionPolicy RemoteSigned -File `"$trayScript`"$portArgument -AutoApply" $tray.WorkingDirectory = $engine.Root $tray.Description = 'Open Codex Dream Skin status and theme controls in the system tray' + $tray.WindowStyle = 7 $tray.Save() } Start-Process -FilePath $powershell -ArgumentList ` - "-NoProfile -STA -WindowStyle Hidden -ExecutionPolicy RemoteSigned -File `"$trayScript`"$portArgument" ` + "-NoProfile -STA -WindowStyle Hidden -ExecutionPolicy RemoteSigned -File `"$trayScript`"$portArgument -AutoApply" ` -WindowStyle Hidden | Out-Null } diff --git a/windows/scripts/theme-picker.ps1 b/windows/scripts/theme-picker.ps1 new file mode 100644 index 00000000..b79ddd1d --- /dev/null +++ b/windows/scripts/theme-picker.ps1 @@ -0,0 +1,472 @@ +[CmdletBinding()] +param( + [int]$Port = 9335, + [string]$ScreenshotPath +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +Add-Type -AssemblyName Microsoft.VisualBasic +. (Join-Path $PSScriptRoot 'common-windows.ps1') +. (Join-Path $PSScriptRoot 'theme-windows.ps1') + +Assert-DreamSkinPort -Port $Port +[System.Windows.Forms.Application]::EnableVisualStyles() + +$windowTitle = 'Codex Dream Skin 主题选择器' +$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value +$mutex = [System.Threading.Mutex]::new($false, "Local\CodexDreamSkin.$sid.ThemePicker") +$acquired = $false +$thumbnailImages = [System.Collections.ArrayList]::new() +$toolTip = $null +$form = $null + +try { + try { $acquired = $mutex.WaitOne(0) } catch [System.Threading.AbandonedMutexException] { $acquired = $true } + if (-not $acquired) { + try { [Microsoft.VisualBasic.Interaction]::AppActivate($windowTitle) | Out-Null } catch {} + exit 0 + } + + $SkillRoot = Split-Path -Parent $PSScriptRoot + $StateRoot = Join-Path $env:LOCALAPPDATA 'CodexDreamSkin' + $paths = Initialize-DreamSkinThemeStore -SkillRoot $SkillRoot -StateRoot $StateRoot + $powershell = (Get-Command powershell.exe -ErrorAction Stop).Source + $startScript = Join-Path $PSScriptRoot 'start-dream-skin.ps1' + + function Start-DreamSkinPickerPowerShell { + param([Parameter(Mandatory = $true)][string]$Script, [string[]]$Arguments = @()) + $scriptToken = ConvertTo-DreamSkinProcessArgument -Value $Script + $argumentLine = '-NoProfile -WindowStyle Hidden -ExecutionPolicy RemoteSigned -File ' + $scriptToken + if ($Arguments.Count -gt 0) { $argumentLine += ' ' + ($Arguments -join ' ') } + Start-Process -FilePath $powershell -ArgumentList $argumentLine -WindowStyle Hidden | Out-Null + } + + function Test-DreamSkinPickerWatcherRunning { + param([AllowNull()][object]$State) + if ($null -eq $State -or -not $State.injectorPid) { return $false } + $injectorProcessId = 0 + if (-not [int]::TryParse("$($State.injectorPid)", [ref]$injectorProcessId) -or + $injectorProcessId -le 0) { return $false } + $startedAt = Get-DreamSkinProcessStartedAt -ProcessId $injectorProcessId + if (-not $startedAt) { return $false } + return -not $State.injectorStartedAt -or $startedAt -eq "$($State.injectorStartedAt)" + } + + function New-DreamSkinThumbnail { + param( + [Parameter(Mandatory = $true)][string]$Path, + [int]$Width = 224, + [int]$Height = 126 + ) + $stream = $null + $source = $null + $graphics = $null + try { + $bytes = [System.IO.File]::ReadAllBytes($Path) + $stream = [System.IO.MemoryStream]::new($bytes, $false) + $source = [System.Drawing.Image]::FromStream($stream, $true, $true) + $thumbnail = [System.Drawing.Bitmap]::new( + $Width, $Height, [System.Drawing.Imaging.PixelFormat]::Format32bppPArgb + ) + $graphics = [System.Drawing.Graphics]::FromImage($thumbnail) + $graphics.Clear([System.Drawing.Color]::FromArgb(14, 17, 23)) + $graphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality + $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality + $scale = [Math]::Max($Width / $source.Width, $Height / $source.Height) + $drawWidth = $source.Width * $scale + $drawHeight = $source.Height * $scale + $drawX = ($Width - $drawWidth) / 2 + $drawY = ($Height - $drawHeight) / 2 + $destination = [System.Drawing.RectangleF]::new($drawX, $drawY, $drawWidth, $drawHeight) + $graphics.DrawImage($source, $destination) + return $thumbnail + } catch { + if ($null -ne $thumbnail) { $thumbnail.Dispose() } + return $null + } finally { + if ($null -ne $graphics) { $graphics.Dispose() } + if ($null -ne $source) { $source.Dispose() } + if ($null -ne $stream) { $stream.Dispose() } + } + } + + $colors = [pscustomobject]@{ + Background = [System.Drawing.Color]::FromArgb(14, 17, 23) + Header = [System.Drawing.Color]::FromArgb(22, 26, 35) + Card = [System.Drawing.Color]::FromArgb(27, 31, 41) + CardHover = [System.Drawing.Color]::FromArgb(34, 40, 53) + Border = [System.Drawing.Color]::FromArgb(48, 55, 70) + Accent = [System.Drawing.Color]::FromArgb(124, 92, 255) + AccentSoft = [System.Drawing.Color]::FromArgb(185, 168, 255) + Success = [System.Drawing.Color]::FromArgb(110, 231, 183) + Text = [System.Drawing.Color]::FromArgb(244, 246, 250) + Muted = [System.Drawing.Color]::FromArgb(152, 162, 179) + Error = [System.Drawing.Color]::FromArgb(248, 113, 113) + } + + $active = Read-DreamSkinTheme -ThemeDirectory $paths.Active -SkipImageMetadata + $script:pickerActiveThemeId = "$($active.Theme.id)" + $script:pickerActiveThemeName = "$($active.Theme.name)" + $script:pickerBusy = $false + $script:pickerCards = [System.Collections.ArrayList]::new() + + $themeInfos = @() + foreach ($saved in @(Get-DreamSkinSavedThemes -StateRoot $StateRoot -SkipImageMetadata)) { + try { + $loaded = Read-DreamSkinTheme -ThemeDirectory $saved.Path -SkipImageMetadata + $themeInfos += [pscustomobject]@{ + Id = $saved.Id + Name = $saved.Name + Path = $saved.Path + ImagePath = $loaded.ImagePath + } + } catch {} + } + $themeInfos = @( + @($themeInfos | Where-Object { $_.Id -eq $script:pickerActiveThemeId }) + + @($themeInfos | Where-Object { $_.Id -ne $script:pickerActiveThemeId }) + ) + + $workingArea = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea + $windowWidth = [Math]::Min(1120, [Math]::Max(760, $workingArea.Width - 100)) + $windowHeight = [Math]::Min(820, [Math]::Max(600, $workingArea.Height - 100)) + + $form = [System.Windows.Forms.Form]::new() + $form.Text = $windowTitle + $form.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterScreen + $form.Size = [System.Drawing.Size]::new($windowWidth, $windowHeight) + $form.MinimumSize = [System.Drawing.Size]::new(760, 600) + $form.BackColor = $colors.Background + $form.ForeColor = $colors.Text + $form.AutoScaleMode = [System.Windows.Forms.AutoScaleMode]::Dpi + $form.Font = [System.Drawing.Font]::new('Microsoft YaHei UI', 9) + $form.ShowIcon = $false + + $layout = [System.Windows.Forms.TableLayoutPanel]::new() + $layout.Dock = [System.Windows.Forms.DockStyle]::Fill + $layout.ColumnCount = 1 + $layout.RowCount = 3 + [void]$layout.ColumnStyles.Add([System.Windows.Forms.ColumnStyle]::new([System.Windows.Forms.SizeType]::Percent, 100)) + [void]$layout.RowStyles.Add([System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Absolute, 78)) + [void]$layout.RowStyles.Add([System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Percent, 100)) + [void]$layout.RowStyles.Add([System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Absolute, 50)) + $form.Controls.Add($layout) + + $header = [System.Windows.Forms.Panel]::new() + $header.Dock = [System.Windows.Forms.DockStyle]::Fill + $header.BackColor = $colors.Header + $layout.Controls.Add($header, 0, 0) + + $titleLabel = [System.Windows.Forms.Label]::new() + $titleLabel.Text = '选择你的主题' + $titleLabel.Location = [System.Drawing.Point]::new(22, 13) + $titleLabel.Size = [System.Drawing.Size]::new(700, 30) + $titleLabel.Font = [System.Drawing.Font]::new('Microsoft YaHei UI', 16, [System.Drawing.FontStyle]::Bold) + $titleLabel.ForeColor = $colors.Text + $header.Controls.Add($titleLabel) + + $script:pickerSubtitle = [System.Windows.Forms.Label]::new() + $script:pickerSubtitle.Text = "当前:$($script:pickerActiveThemeName) · $($themeInfos.Count) 个主题" + $script:pickerSubtitle.Location = [System.Drawing.Point]::new(24, 46) + $script:pickerSubtitle.Size = [System.Drawing.Size]::new(900, 22) + $script:pickerSubtitle.ForeColor = $colors.Muted + $header.Controls.Add($script:pickerSubtitle) + + $flow = [System.Windows.Forms.FlowLayoutPanel]::new() + $flow.Dock = [System.Windows.Forms.DockStyle]::Fill + $flow.AutoScroll = $true + $flow.WrapContents = $true + $flow.FlowDirection = [System.Windows.Forms.FlowDirection]::LeftToRight + $flow.Padding = [System.Windows.Forms.Padding]::new(12) + $flow.BackColor = $colors.Background + $layout.Controls.Add($flow, 0, 1) + + $footer = [System.Windows.Forms.Panel]::new() + $footer.Dock = [System.Windows.Forms.DockStyle]::Fill + $footer.BackColor = $colors.Header + $layout.Controls.Add($footer, 0, 2) + + $script:pickerStatusLabel = [System.Windows.Forms.Label]::new() + $script:pickerStatusLabel.Text = '单击卡片自动应用 · “删”可删除非当前主题 · ✓ 为当前主题' + $script:pickerStatusLabel.Dock = [System.Windows.Forms.DockStyle]::Fill + $script:pickerStatusLabel.Padding = [System.Windows.Forms.Padding]::new(22, 0, 12, 0) + $script:pickerStatusLabel.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft + $script:pickerStatusLabel.ForeColor = $colors.Muted + $footer.Controls.Add($script:pickerStatusLabel) + + $toolTip = [System.Windows.Forms.ToolTip]::new() + $toolTip.InitialDelay = 350 + $toolTip.ReshowDelay = 100 + + function Update-DreamSkinPickerCardStates { + foreach ($entry in @($script:pickerCards)) { + $isActive = $entry.Id -eq $script:pickerActiveThemeId + $entry.Frame.BackColor = if ($isActive) { $colors.Accent } else { $colors.Border } + $entry.Check.Text = if ($isActive) { '✓' } else { '' } + $entry.Check.BackColor = if ($isActive) { $colors.Accent } else { $colors.Card } + $entry.Delete.Enabled = -not $isActive + $entry.Delete.Visible = -not $isActive + $entry.Delete.ForeColor = if ($isActive) { $colors.Muted } else { $colors.Error } + $entry.NameLabel.ForeColor = if ($isActive) { $colors.AccentSoft } else { $colors.Text } + $entry.NameLabel.Font = [System.Drawing.Font]::new( + 'Microsoft YaHei UI', 10, $(if ($isActive) { [System.Drawing.FontStyle]::Bold } else { [System.Drawing.FontStyle]::Regular }) + ) + } + } + + function Invoke-DreamSkinPickerSelection { + param([Parameter(Mandatory = $true)][object]$ThemeInfo) + if ($script:pickerBusy) { return } + $script:pickerBusy = $true + $form.UseWaitCursor = $true + $script:pickerStatusLabel.ForeColor = $colors.AccentSoft + $script:pickerStatusLabel.Text = "正在自动应用:$($ThemeInfo.Name)…" + [System.Windows.Forms.Application]::DoEvents() + try { + $null = Use-DreamSkinSavedTheme -ThemeDirectory $ThemeInfo.Path -StateRoot $StateRoot + Set-DreamSkinPaused -Paused $false -StateRoot $StateRoot | Out-Null + $liveState = $null + try { $liveState = Read-DreamSkinState -Path $paths.State } catch {} + $watcherWasRunning = Test-DreamSkinPickerWatcherRunning -State $liveState + if (-not $watcherWasRunning) { + Start-DreamSkinPickerPowerShell -Script $startScript -Arguments @('-Port', "$Port", '-PromptRestart') + } + $script:pickerActiveThemeId = $ThemeInfo.Id + $script:pickerActiveThemeName = $ThemeInfo.Name + Update-DreamSkinPickerCardStates + $script:pickerSubtitle.Text = "当前:$($ThemeInfo.Name) · $($script:pickerCards.Count) 个主题" + $script:pickerStatusLabel.ForeColor = $colors.Success + $script:pickerStatusLabel.Text = if ($watcherWasRunning) { + "✓ 已自动应用:$($ThemeInfo.Name)" + } else { + "✓ 已选择:$($ThemeInfo.Name),皮肤服务正在启动" + } + } catch { + $script:pickerStatusLabel.ForeColor = $colors.Error + $script:pickerStatusLabel.Text = "应用失败:$($_.Exception.Message)" + [void][System.Windows.Forms.MessageBox]::Show( + $form, + $_.Exception.Message, + $windowTitle, + [System.Windows.Forms.MessageBoxButtons]::OK, + [System.Windows.Forms.MessageBoxIcon]::Error + ) + } finally { + $form.UseWaitCursor = $false + $script:pickerBusy = $false + } + } + + function Invoke-DreamSkinPickerDeletion { + param( + [Parameter(Mandatory = $true)][object]$ThemeInfo, + [Parameter(Mandatory = $true)][object]$Entry + ) + if ($script:pickerBusy) { return } + if ($ThemeInfo.Id -eq $script:pickerActiveThemeId) { + [void][System.Windows.Forms.MessageBox]::Show( + $form, + '当前主题不能删除,请先应用另一个主题。', + $windowTitle, + [System.Windows.Forms.MessageBoxButtons]::OK, + [System.Windows.Forms.MessageBoxIcon]::Information + ) + return + } + $confirmationText = ('确定删除主题“{0}”吗?' -f $ThemeInfo.Name) + "`r`n`r`n" + + '此操作会删除该主题及其壁纸文件,无法撤销。' + $confirmation = [System.Windows.Forms.MessageBox]::Show( + $form, + $confirmationText, + '删除主题', + [System.Windows.Forms.MessageBoxButtons]::YesNo, + [System.Windows.Forms.MessageBoxIcon]::Warning, + [System.Windows.Forms.MessageBoxDefaultButton]::Button2 + ) + if ($confirmation -ne [System.Windows.Forms.DialogResult]::Yes) { return } + + $script:pickerBusy = $true + $form.UseWaitCursor = $true + $script:pickerStatusLabel.ForeColor = $colors.AccentSoft + $script:pickerStatusLabel.Text = "正在删除:$($ThemeInfo.Name)…" + [System.Windows.Forms.Application]::DoEvents() + try { + $null = Remove-DreamSkinSavedTheme -ThemeDirectory $ThemeInfo.Path -StateRoot $StateRoot + $flow.Controls.Remove($Entry.Frame) + [void]$script:pickerCards.Remove($Entry) + if ($null -ne $Entry.Thumbnail) { + $Entry.Picture.Image = $null + [void]$thumbnailImages.Remove($Entry.Thumbnail) + $Entry.Thumbnail.Dispose() + } + $Entry.Frame.Dispose() + if ($script:pickerCards.Count -eq 0) { + $emptyLabel = [System.Windows.Forms.Label]::new() + $emptyLabel.Text = '暂无已保存主题' + $emptyLabel.Size = [System.Drawing.Size]::new(500, 80) + $emptyLabel.Margin = [System.Windows.Forms.Padding]::new(20) + $emptyLabel.ForeColor = $colors.Muted + $emptyLabel.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter + [void]$flow.Controls.Add($emptyLabel) + } + $script:pickerSubtitle.Text = "当前:$($script:pickerActiveThemeName) · $($script:pickerCards.Count) 个主题" + $script:pickerStatusLabel.ForeColor = $colors.Success + $script:pickerStatusLabel.Text = "✓ 已删除:$($ThemeInfo.Name)" + } catch { + $script:pickerStatusLabel.ForeColor = $colors.Error + $script:pickerStatusLabel.Text = "删除失败:$($_.Exception.Message)" + [void][System.Windows.Forms.MessageBox]::Show( + $form, + $_.Exception.Message, + $windowTitle, + [System.Windows.Forms.MessageBoxButtons]::OK, + [System.Windows.Forms.MessageBoxIcon]::Error + ) + } finally { + $form.UseWaitCursor = $false + $script:pickerBusy = $false + } + } + + function Add-DreamSkinPickerClickHandler { + param( + [Parameter(Mandatory = $true)][System.Windows.Forms.Control]$Control, + [Parameter(Mandatory = $true)][object]$ThemeInfo + ) + if ("$($Control.Tag)" -eq 'DreamSkinDeleteAction') { return } + $Control.Cursor = [System.Windows.Forms.Cursors]::Hand + $handler = { + param($sender, $eventArgs) + Invoke-DreamSkinPickerSelection -ThemeInfo $ThemeInfo + }.GetNewClosure() + $Control.add_Click($handler) + foreach ($child in $Control.Controls) { + Add-DreamSkinPickerClickHandler -Control $child -ThemeInfo $ThemeInfo + } + } + + foreach ($themeInfo in $themeInfos) { + $frame = [System.Windows.Forms.Panel]::new() + $frame.Size = [System.Drawing.Size]::new(244, 190) + $frame.Margin = [System.Windows.Forms.Padding]::new(8) + $frame.Padding = [System.Windows.Forms.Padding]::new(2) + $frame.BackColor = $colors.Border + + $card = [System.Windows.Forms.Panel]::new() + $card.Dock = [System.Windows.Forms.DockStyle]::Fill + $card.BackColor = $colors.Card + $frame.Controls.Add($card) + + $picture = [System.Windows.Forms.PictureBox]::new() + $picture.Location = [System.Drawing.Point]::new(8, 8) + $picture.Size = [System.Drawing.Size]::new(224, 126) + $picture.BackColor = $colors.Background + $picture.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::Normal + $thumbnail = New-DreamSkinThumbnail -Path $themeInfo.ImagePath + if ($null -ne $thumbnail) { + [void]$thumbnailImages.Add($thumbnail) + $picture.Image = $thumbnail + } + $card.Controls.Add($picture) + + $nameLabel = [System.Windows.Forms.Label]::new() + $nameLabel.Text = $themeInfo.Name + $nameLabel.Location = [System.Drawing.Point]::new(9, 143) + $nameLabel.Size = [System.Drawing.Size]::new(150, 31) + $nameLabel.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft + $nameLabel.AutoEllipsis = $true + $nameLabel.ForeColor = $colors.Text + $nameLabel.BackColor = $colors.Card + $card.Controls.Add($nameLabel) + + $checkLabel = [System.Windows.Forms.Label]::new() + $checkLabel.Location = [System.Drawing.Point]::new(202, 145) + $checkLabel.Size = [System.Drawing.Size]::new(28, 28) + $checkLabel.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter + $checkLabel.Font = [System.Drawing.Font]::new('Segoe UI Symbol', 12, [System.Drawing.FontStyle]::Bold) + $checkLabel.ForeColor = [System.Drawing.Color]::White + $checkLabel.BackColor = $colors.Card + $card.Controls.Add($checkLabel) + + $deleteButton = [System.Windows.Forms.Button]::new() + $deleteButton.Text = '删' + $deleteButton.Tag = 'DreamSkinDeleteAction' + $deleteButton.Location = [System.Drawing.Point]::new(164, 145) + $deleteButton.Size = [System.Drawing.Size]::new(32, 28) + $deleteButton.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat + $deleteButton.FlatAppearance.BorderSize = 0 + $deleteButton.BackColor = $colors.Card + $deleteButton.ForeColor = $colors.Error + $deleteButton.Font = [System.Drawing.Font]::new('Microsoft YaHei UI', 9, [System.Drawing.FontStyle]::Bold) + $deleteButton.Cursor = [System.Windows.Forms.Cursors]::Hand + $card.Controls.Add($deleteButton) + + $pickerEntry = [pscustomobject]@{ + Id = $themeInfo.Id + Frame = $frame + Card = $card + Picture = $picture + Thumbnail = $thumbnail + NameLabel = $nameLabel + Check = $checkLabel + Delete = $deleteButton + } + [void]$script:pickerCards.Add($pickerEntry) + $toolTip.SetToolTip($picture, "点击自动应用:$($themeInfo.Name)") + $toolTip.SetToolTip($deleteButton, "删除主题:$($themeInfo.Name)") + $deleteHandler = { + param($sender, $eventArgs) + Invoke-DreamSkinPickerDeletion -ThemeInfo $themeInfo -Entry $pickerEntry + }.GetNewClosure() + $deleteButton.add_Click($deleteHandler) + Add-DreamSkinPickerClickHandler -Control $frame -ThemeInfo $themeInfo + [void]$flow.Controls.Add($frame) + } + + if ($themeInfos.Count -eq 0) { + $emptyLabel = [System.Windows.Forms.Label]::new() + $emptyLabel.Text = '暂无已保存主题' + $emptyLabel.Size = [System.Drawing.Size]::new(500, 80) + $emptyLabel.Margin = [System.Windows.Forms.Padding]::new(20) + $emptyLabel.ForeColor = $colors.Muted + $emptyLabel.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter + $flow.Controls.Add($emptyLabel) + } + + Update-DreamSkinPickerCardStates + + if ($ScreenshotPath) { + $fullScreenshotPath = [System.IO.Path]::GetFullPath($ScreenshotPath) + $screenshotDirectory = [System.IO.Path]::GetDirectoryName($fullScreenshotPath) + New-Item -ItemType Directory -Force -Path $screenshotDirectory | Out-Null + $captureTimer = [System.Windows.Forms.Timer]::new() + $captureTimer.Interval = 700 + $captureTimer.add_Tick({ + $captureTimer.Stop() + $bitmap = [System.Drawing.Bitmap]::new($form.Width, $form.Height) + try { + $form.DrawToBitmap( + $bitmap, + [System.Drawing.Rectangle]::new(0, 0, $bitmap.Width, $bitmap.Height) + ) + $bitmap.Save($fullScreenshotPath, [System.Drawing.Imaging.ImageFormat]::Png) + } finally { + $bitmap.Dispose() + $captureTimer.Dispose() + } + $form.Close() + }.GetNewClosure()) + $form.add_Shown({ $captureTimer.Start() }.GetNewClosure()) + } + + [void]$form.ShowDialog() +} finally { + if ($null -ne $toolTip) { $toolTip.Dispose() } + foreach ($thumbnail in @($thumbnailImages)) { $thumbnail.Dispose() } + if ($null -ne $form) { $form.Dispose() } + if ($acquired) { try { $mutex.ReleaseMutex() } catch {} } + $mutex.Dispose() +} diff --git a/windows/scripts/theme-windows.ps1 b/windows/scripts/theme-windows.ps1 index 17434594..e93f98dd 100644 --- a/windows/scripts/theme-windows.ps1 +++ b/windows/scripts/theme-windows.ps1 @@ -311,6 +311,7 @@ function Get-DreamSkinThemePaths { Saved = Join-Path $fullRoot 'themes' Images = Join-Path $fullRoot 'images' PauseFile = Join-Path $fullRoot 'paused' + Rotation = Join-Path $fullRoot 'rotation.json' State = Join-Path $fullRoot 'state.json' } } @@ -1229,6 +1230,183 @@ function Use-DreamSkinSavedTheme { -SafeCssPath $safeCssPath -StateRoot $StateRoot } +function Remove-DreamSkinSavedTheme { + param( + [Parameter(Mandatory = $true)][string]$ThemeDirectory, + [string]$StateRoot = (Join-Path $env:LOCALAPPDATA 'CodexDreamSkin') + ) + $paths = Get-DreamSkinThemePaths -StateRoot $StateRoot + Ensure-DreamSkinManagedDirectory -Path $paths.Root -Root $paths.Root + Ensure-DreamSkinManagedDirectory -Path $paths.Saved -Root $paths.Root + $directory = [System.IO.Path]::GetFullPath($ThemeDirectory).TrimEnd('\') + $savedRoot = [System.IO.Path]::GetFullPath($paths.Saved).TrimEnd('\') + $parent = [System.IO.Path]::GetDirectoryName($directory) + if (-not $parent -or + -not $parent.Equals($savedRoot, [System.StringComparison]::OrdinalIgnoreCase) -or + -not (Test-DreamSkinThemePathWithin -Path $directory -Root $savedRoot)) { + throw 'Only a direct child of the Dream Skin themes folder can be deleted.' + } + + $saved = Read-DreamSkinTheme -ThemeDirectory $directory -SkipImageMetadata + $active = Read-DreamSkinTheme -ThemeDirectory $paths.Active -SkipImageMetadata + if ("$($saved.Theme.id)" -ceq "$($active.Theme.id)") { + throw 'The current theme cannot be deleted. Apply another theme first.' + } + + Assert-DreamSkinNoReparseComponents -Path $directory + Remove-Item -LiteralPath $directory -Recurse -Force -ErrorAction Stop + return [pscustomobject]@{ + Id = "$($saved.Theme.id)" + Name = if ($saved.Theme.name) { "$($saved.Theme.name)" } else { [System.IO.Path]::GetFileName($directory) } + Path = $directory + } +} + +function New-DreamSkinDefaultThemeRotationSettings { + return [pscustomobject]@{ + SchemaVersion = 1 + Enabled = $false + IntervalMinutes = 30 + LastRotatedAt = '' + LastThemeId = '' + } +} + +function Get-DreamSkinThemeRotationSettings { + param([string]$StateRoot = (Join-Path $env:LOCALAPPDATA 'CodexDreamSkin')) + $paths = Get-DreamSkinThemePaths -StateRoot $StateRoot + Ensure-DreamSkinManagedDirectory -Path $paths.Root -Root $paths.Root + if (-not (Test-Path -LiteralPath $paths.Rotation -PathType Leaf)) { + return New-DreamSkinDefaultThemeRotationSettings + } + Assert-DreamSkinNoReparseComponents -Path $paths.Rotation + try { + $stored = (Read-DreamSkinUtf8File -Path $paths.Rotation) | ConvertFrom-Json -ErrorAction Stop + } catch { + throw "Theme rotation settings are invalid JSON: $($paths.Rotation)" + } + if ($null -eq $stored -or $stored -is [string] -or $stored -is [array] -or + $stored.enabled -isnot [bool]) { + throw 'Theme rotation settings must be an object with a boolean enabled value.' + } + $intervalMinutes = 0 + if (-not [int]::TryParse("$($stored.intervalMinutes)", [ref]$intervalMinutes) -or + $intervalMinutes -notin @(15, 30, 60, 120, 240)) { + throw 'Theme rotation interval must be 15, 30, 60, 120, or 240 minutes.' + } + $lastRotatedAt = if ($stored.lastRotatedAt -is [datetime]) { + ([datetime]$stored.lastRotatedAt).ToUniversalTime().ToString('o') + } elseif ($stored.lastRotatedAt) { + "$($stored.lastRotatedAt)" + } else { + '' + } + if ($lastRotatedAt) { + $parsedTimestamp = [datetime]::MinValue + if (-not [datetime]::TryParse( + $lastRotatedAt, + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::RoundtripKind, + [ref]$parsedTimestamp + )) { + throw 'Theme rotation lastRotatedAt must be an ISO-8601 timestamp.' + } + } + return [pscustomobject]@{ + SchemaVersion = 1 + Enabled = [bool]$stored.enabled + IntervalMinutes = $intervalMinutes + LastRotatedAt = $lastRotatedAt + LastThemeId = if ($stored.lastThemeId) { "$($stored.lastThemeId)" } else { '' } + } +} + +function Write-DreamSkinThemeRotationSettings { + param( + [Parameter(Mandatory = $true)][bool]$Enabled, + [Parameter(Mandatory = $true)][ValidateSet(15, 30, 60, 120, 240)][int]$IntervalMinutes, + [string]$LastRotatedAt = '', + [string]$LastThemeId = '', + [string]$StateRoot = (Join-Path $env:LOCALAPPDATA 'CodexDreamSkin') + ) + $paths = Get-DreamSkinThemePaths -StateRoot $StateRoot + Ensure-DreamSkinManagedDirectory -Path $paths.Root -Root $paths.Root + Assert-DreamSkinNoReparseComponents -Path $paths.Rotation + $document = [ordered]@{ + schemaVersion = 1 + enabled = $Enabled + intervalMinutes = $IntervalMinutes + lastRotatedAt = $LastRotatedAt + lastThemeId = $LastThemeId + } + Write-DreamSkinUtf8FileAtomically -Path $paths.Rotation ` + -Content (($document | ConvertTo-Json -Depth 4) + "`r`n") + return Get-DreamSkinThemeRotationSettings -StateRoot $StateRoot +} + +function Set-DreamSkinThemeRotationSettings { + param( + [Parameter(Mandatory = $true)][bool]$Enabled, + [ValidateSet(15, 30, 60, 120, 240)][int]$IntervalMinutes = 30, + [datetime]$Now = (Get-Date), + [string]$StateRoot = (Join-Path $env:LOCALAPPDATA 'CodexDreamSkin') + ) + $current = Get-DreamSkinThemeRotationSettings -StateRoot $StateRoot + $lastRotatedAt = $current.LastRotatedAt + if ($Enabled) { $lastRotatedAt = $Now.ToUniversalTime().ToString('o') } + return Write-DreamSkinThemeRotationSettings -Enabled $Enabled ` + -IntervalMinutes $IntervalMinutes -LastRotatedAt $lastRotatedAt ` + -LastThemeId $current.LastThemeId -StateRoot $StateRoot +} + +function Get-DreamSkinNextRotationTheme { + param( + [string]$ActiveThemeId, + [string]$StateRoot = (Join-Path $env:LOCALAPPDATA 'CodexDreamSkin') + ) + $themes = @(Get-DreamSkinSavedThemes -StateRoot $StateRoot -SkipImageMetadata) + if ($themes.Count -eq 0) { return $null } + if ($themes.Count -eq 1) { + if ($themes[0].Id -eq $ActiveThemeId) { return $null } + return $themes[0] + } + $activeIndex = -1 + for ($index = 0; $index -lt $themes.Count; $index++) { + if ($themes[$index].Id -eq $ActiveThemeId) { $activeIndex = $index; break } + } + if ($activeIndex -lt 0) { return $themes[0] } + return $themes[($activeIndex + 1) % $themes.Count] +} + +function Invoke-DreamSkinThemeRotation { + param( + [switch]$Force, + [datetime]$Now = (Get-Date), + [string]$StateRoot = (Join-Path $env:LOCALAPPDATA 'CodexDreamSkin') + ) + if (Test-DreamSkinPaused -StateRoot $StateRoot) { return $null } + $settings = Get-DreamSkinThemeRotationSettings -StateRoot $StateRoot + if (-not $Force -and -not $settings.Enabled) { return $null } + $nowUtc = $Now.ToUniversalTime() + if (-not $Force -and $settings.LastRotatedAt) { + $lastRotatedAt = [datetime]::Parse( + $settings.LastRotatedAt, + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::RoundtripKind + ).ToUniversalTime() + if (($nowUtc - $lastRotatedAt).TotalMinutes -lt $settings.IntervalMinutes) { return $null } + } + $paths = Get-DreamSkinThemePaths -StateRoot $StateRoot + $active = Read-DreamSkinTheme -ThemeDirectory $paths.Active -SkipImageMetadata + $nextTheme = Get-DreamSkinNextRotationTheme -ActiveThemeId "$($active.Theme.id)" -StateRoot $StateRoot + if ($null -eq $nextTheme) { return $null } + $applied = Use-DreamSkinSavedTheme -ThemeDirectory $nextTheme.Path -StateRoot $StateRoot + $null = Write-DreamSkinThemeRotationSettings -Enabled $settings.Enabled ` + -IntervalMinutes $settings.IntervalMinutes -LastRotatedAt $nowUtc.ToString('o') ` + -LastThemeId "$($applied.Theme.id)" -StateRoot $StateRoot + return $applied +} + function Set-DreamSkinPaused { param( [Parameter(Mandatory = $true)][bool]$Paused, diff --git a/windows/scripts/tray-dream-skin.ps1 b/windows/scripts/tray-dream-skin.ps1 index 7f2f3c94..fd5639ba 100644 --- a/windows/scripts/tray-dream-skin.ps1 +++ b/windows/scripts/tray-dream-skin.ps1 @@ -1,5 +1,8 @@ [CmdletBinding()] -param([int]$Port = 9335) +param( + [int]$Port = 9335, + [switch]$AutoApply +) $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.Windows.Forms @@ -16,6 +19,7 @@ $powershell = (Get-Command powershell.exe -ErrorAction Stop).Source $startScript = Join-Path $PSScriptRoot 'start-dream-skin.ps1' $restoreScript = Join-Path $PSScriptRoot 'restore-dream-skin.ps1' $checkUpdateScript = Join-Path $PSScriptRoot 'check-update.ps1' +$themePickerScript = Join-Path $PSScriptRoot 'theme-picker.ps1' $startupShortcut = Join-Path ([Environment]::GetFolderPath('Startup')) 'Codex Dream Skin.lnk' $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value @@ -23,6 +27,7 @@ $mutex = [System.Threading.Mutex]::new($false, "Local\CodexDreamSkin.$sid.Tray") $acquired = $false $notify = $null $trayIcon = $null +$rotationTimer = $null try { try { $acquired = $mutex.WaitOne(0) } catch [System.Threading.AbandonedMutexException] { $acquired = $true } if (-not $acquired) { exit 0 } @@ -106,7 +111,7 @@ try { $shell = New-Object -ComObject WScript.Shell $shortcut = $shell.CreateShortcut($startupShortcut) $shortcut.TargetPath = $powershell - $shortcut.Arguments = "-NoProfile -STA -WindowStyle Hidden -ExecutionPolicy RemoteSigned -File `"$PSScriptRoot\tray-dream-skin.ps1`"" + $shortcut.Arguments = "-NoProfile -STA -WindowStyle Hidden -ExecutionPolicy RemoteSigned -File `"$PSScriptRoot\tray-dream-skin.ps1`" -Port $Port -AutoApply" $shortcut.WorkingDirectory = $SkillRoot $shortcut.Description = 'Start Codex Dream Skin in the notification area' $shortcut.Save() @@ -233,6 +238,9 @@ try { $notify.ShowBalloonTip(1800, 'Codex Dream Skin', "已保存:$($saved.Theme.name)", [System.Windows.Forms.ToolTipIcon]::Info) } } + $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '打开主题选择器…' -Action { + Start-DreamSkinPowerShell -Script $themePickerScript -Arguments @('-Port', "$Port") + } $savedMenu = [System.Windows.Forms.ToolStripMenuItem]::new('已保存主题') $savedThemes = @(Get-DreamSkinSavedThemes -StateRoot $StateRoot -SkipImageMetadata) @@ -256,6 +264,47 @@ try { } [void]$menu.Items.Add($savedMenu) + $rotation = Get-DreamSkinThemeRotationSettings -StateRoot $StateRoot + $rotationText = if ($rotation.Enabled) { + "自动轮换主题:每 $($rotation.IntervalMinutes) 分钟" + } else { + '自动轮换主题:关闭' + } + $rotationMenu = [System.Windows.Forms.ToolStripMenuItem]::new($rotationText) + foreach ($minutes in @(15, 30, 60, 120, 240)) { + $intervalAction = { + $null = Invoke-DreamSkinTrayThemeOperation -Action { + Set-DreamSkinThemeRotationSettings -Enabled $true -IntervalMinutes $minutes -StateRoot $StateRoot + } + $notify.ShowBalloonTip( + 1800, + 'Codex Dream Skin', + "自动轮换已开启:每 $minutes 分钟", + [System.Windows.Forms.ToolTipIcon]::Info + ) + }.GetNewClosure() + $intervalItem = Add-DreamSkinTrayItem -Items $rotationMenu.DropDownItems ` + -Text "每 $minutes 分钟" -Action $intervalAction + $intervalItem.Checked = $rotation.Enabled -and $rotation.IntervalMinutes -eq $minutes + } + [void]$rotationMenu.DropDownItems.Add([System.Windows.Forms.ToolStripSeparator]::new()) + $null = Add-DreamSkinTrayItem -Items $rotationMenu.DropDownItems -Text '立即轮换一次' -Action { + $rotated = Invoke-DreamSkinTrayThemeOperation -Action { + Invoke-DreamSkinThemeRotation -Force -StateRoot $StateRoot + } + $message = if ($null -eq $rotated) { '没有可轮换的已保存主题。' } else { "已应用:$($rotated.Theme.name)" } + $notify.ShowBalloonTip(1800, 'Codex Dream Skin', $message, [System.Windows.Forms.ToolTipIcon]::Info) + } + $null = Add-DreamSkinTrayItem -Items $rotationMenu.DropDownItems ` + -Text '关闭自动轮换' -Enabled $rotation.Enabled -Action { + $null = Invoke-DreamSkinTrayThemeOperation -Action { + Set-DreamSkinThemeRotationSettings -Enabled $false ` + -IntervalMinutes $rotation.IntervalMinutes -StateRoot $StateRoot + } + $notify.ShowBalloonTip(1800, 'Codex Dream Skin', '自动轮换已关闭。', [System.Windows.Forms.ToolTipIcon]::Info) + } + [void]$menu.Items.Add($rotationMenu) + $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '打开主题文件夹' -Action { $themeDirectoryToken = ConvertTo-DreamSkinProcessArgument -Value $paths.Saved Start-Process -FilePath explorer.exe -ArgumentList $themeDirectoryToken | Out-Null @@ -305,8 +354,27 @@ try { Show-DreamSkinTrayError -Message $_.Exception.Message } }) + if ($AutoApply) { + try { + Start-DreamSkinPowerShell -Script $startScript -Arguments @('-Port', "$Port") + } catch { + Write-Warning "Dream Skin auto-apply failed: $($_.Exception.Message)" + } + } + $rotationTimer = [System.Windows.Forms.Timer]::new() + $rotationTimer.Interval = 30000 + $rotationTimer.add_Tick({ + try { $null = Invoke-DreamSkinThemeRotation -StateRoot $StateRoot } catch { + Write-Warning "Dream Skin theme rotation failed: $($_.Exception.Message)" + } + }) + $rotationTimer.Start() [System.Windows.Forms.Application]::Run() } finally { + if ($null -ne $rotationTimer) { + $rotationTimer.Stop() + $rotationTimer.Dispose() + } if ($null -ne $notify) { $notify.Dispose() } if ($null -ne $trayIcon) { $trayIcon.Dispose() } if ($acquired) { try { $mutex.ReleaseMutex() } catch {} } diff --git a/windows/tests/run-tests.ps1 b/windows/tests/run-tests.ps1 index e25cf5b1..3f2e5d54 100644 --- a/windows/tests/run-tests.ps1 +++ b/windows/tests/run-tests.ps1 @@ -222,9 +222,13 @@ try { '$startScript = $engine.Start', '$restoreScript = $engine.Restore', '$trayScript = $engine.Tray', + '$themePickerScript = $engine.ThemePicker', '$shortcut.WorkingDirectory = $engine.Root', '$restore.WorkingDirectory = $engine.Root', - '$tray.WorkingDirectory = $engine.Root' + '$tray.WorkingDirectory = $engine.Root', + '$shortcut.WindowStyle = 7', + '$restore.WindowStyle = 7', + '$tray.WindowStyle = 7' )) { if (-not $installSource.Contains($requiredShortcutBinding)) { throw "Installer shortcut still depends on its source checkout: $requiredShortcutBinding" @@ -249,7 +253,8 @@ try { if (-not (Test-Path -LiteralPath $engine.CommunityApply -PathType Leaf) -or -not (Test-Path -LiteralPath $engine.Start -PathType Leaf) -or -not (Test-Path -LiteralPath $engine.Restore -PathType Leaf) -or - -not (Test-Path -LiteralPath $engine.Tray -PathType Leaf)) { + -not (Test-Path -LiteralPath $engine.Tray -PathType Leaf) -or + -not (Test-Path -LiteralPath $engine.ThemePicker -PathType Leaf)) { throw 'Installed launch, restore, or tray entry point disappeared with the source checkout.' } Remove-Item -LiteralPath $invalidRuntimeRoot, $runtimeStateRoot -Recurse -Force @@ -1034,6 +1039,7 @@ try { Copy-Item -LiteralPath (Join-Path $Root 'scripts\install-dream-skin.ps1') -Destination $releaseFixtureScripts -Force Copy-Item -LiteralPath (Join-Path $Root 'scripts\restore-dream-skin.ps1') -Destination $releaseFixtureScripts -Force Copy-Item -LiteralPath (Join-Path $Root 'scripts\start-dream-skin.ps1') -Destination $releaseFixtureScripts -Force + Copy-Item -LiteralPath (Join-Path $Root 'scripts\theme-picker.ps1') -Destination $releaseFixtureScripts -Force Copy-Item -LiteralPath (Join-Path $Root 'scripts\theme-windows.ps1') -Destination $releaseFixtureScripts -Force Copy-Item -LiteralPath (Join-Path $Root 'scripts\tray-dream-skin.ps1') -Destination $releaseFixtureScripts -Force Copy-Item -LiteralPath (Join-Path $Root 'scripts\validate-safe-css-file.mjs') -Destination $releaseFixtureScripts -Force @@ -1069,6 +1075,50 @@ try { throw 'Saved theme creation or discovery failed.' } $null = Use-DreamSkinSavedTheme -ThemeDirectory $savedTheme.Directory -StateRoot $themeStateRoot + $currentDeleteRejected = $false + try { $null = Remove-DreamSkinSavedTheme -ThemeDirectory $savedTheme.Directory -StateRoot $themeStateRoot } catch { + $currentDeleteRejected = $true + } + if (-not $currentDeleteRejected) { throw 'The current saved theme could be deleted.' } + $deletableTheme = Save-DreamSkinCurrentTheme -Name '待删除主题' -StateRoot $themeStateRoot + $null = Use-DreamSkinSavedTheme -ThemeDirectory $savedTheme.Directory -StateRoot $themeStateRoot + $deletedTheme = Remove-DreamSkinSavedTheme -ThemeDirectory $deletableTheme.Directory -StateRoot $themeStateRoot + if ($deletedTheme.Id -cne "$($deletableTheme.Theme.id)" -or + (Test-Path -LiteralPath $deletableTheme.Directory)) { + throw 'A non-current saved theme was not deleted safely.' + } + $rotationDefaults = Get-DreamSkinThemeRotationSettings -StateRoot $themeStateRoot + if ($rotationDefaults.Enabled -or $rotationDefaults.IntervalMinutes -ne 30 -or + $rotationDefaults.LastRotatedAt -or $rotationDefaults.LastThemeId) { + throw 'Theme rotation defaults are not disabled with a 30-minute interval.' + } + $rotationStart = [datetime]::Parse('2026-07-20T12:00:00Z').ToUniversalTime() + $rotationEnabled = Set-DreamSkinThemeRotationSettings -Enabled $true -IntervalMinutes 15 ` + -Now $rotationStart -StateRoot $themeStateRoot + if (-not $rotationEnabled.Enabled -or $rotationEnabled.IntervalMinutes -ne 15 -or + -not $rotationEnabled.LastRotatedAt) { + throw 'Theme rotation settings were not enabled and persisted.' + } + $notDueRotation = Invoke-DreamSkinThemeRotation -Now $rotationStart.AddMinutes(14) ` + -StateRoot $themeStateRoot + if ($null -ne $notDueRotation) { + throw 'Theme rotation switched before its configured interval elapsed.' + } + $dueRotation = Invoke-DreamSkinThemeRotation -Now $rotationStart.AddMinutes(16) ` + -StateRoot $themeStateRoot + $rotationProgress = Get-DreamSkinThemeRotationSettings -StateRoot $themeStateRoot + if ($null -eq $dueRotation -or $rotationProgress.LastThemeId -cne $dueRotation.Theme.id -or + -not $rotationProgress.LastRotatedAt) { + throw 'Due theme rotation did not select the next theme and persist its progress.' + } + $rotationDisabled = Set-DreamSkinThemeRotationSettings -Enabled $false -IntervalMinutes 15 ` + -StateRoot $themeStateRoot + $disabledTick = Invoke-DreamSkinThemeRotation -Now $rotationStart.AddHours(2) ` + -StateRoot $themeStateRoot + if ($rotationDisabled.Enabled -or $null -ne $disabledTick -or + (Get-DreamSkinThemeRotationSettings -StateRoot $themeStateRoot).Enabled) { + throw 'Disabled theme rotation still changed the active theme.' + } $outsideTheme = Join-Path $temporaryRoot 'outside-theme' New-Item -ItemType Directory -Path $outsideTheme | Out-Null @@ -1163,7 +1213,17 @@ try { throw 'macOS and Windows selector contract assets are not byte-identical.' } $traySource = Read-DreamSkinUtf8File -Path (Join-Path $Root 'scripts\tray-dream-skin.ps1') - foreach ($requiredTrayAction in @('System.Windows.Forms.NotifyIcon', '暂停皮肤', '继续显示皮肤', '更换背景图', '已保存主题', '完全恢复 Codex')) { + foreach ($requiredTrayAction in @( + 'System.Windows.Forms.NotifyIcon', + '暂停皮肤', + '继续显示皮肤', + '更换背景图', + '打开主题选择器', + '已保存主题', + '自动轮换主题', + 'Invoke-DreamSkinThemeRotation', + '完全恢复 Codex' + )) { if (-not $traySource.Contains($requiredTrayAction)) { throw "Tray action is missing: $requiredTrayAction" } } if (-not $traySource.Contains('Invoke-DreamSkinLiveRemove') -or @@ -1210,11 +1270,27 @@ try { -not $traySource.Contains('Get-DreamSkinSavedThemes -StateRoot $StateRoot -SkipImageMetadata')) { throw 'Tray menu metadata enumeration still performs full image parsing on every open.' } - foreach ($requiredReleaseAction in @('check-update.ps1', '检查更新', '打开 DreamSkin.cc', '登录时启动')) { + foreach ($requiredReleaseAction in @('check-update.ps1', '检查更新', '打开 DreamSkin.cc', '登录时启动', '-AutoApply')) { if (-not $traySource.Contains($requiredReleaseAction)) { throw "Tray release action is missing: $requiredReleaseAction" } } + if ([regex]::IsMatch($traySource, '(?s)if \(\$AutoApply\) \{.*?-PromptRestart.*?\n \}')) { + throw 'Tray auto-apply must not request a Codex restart while the tray is starting.' + } + $pickerSource = Read-DreamSkinUtf8File -Path (Join-Path $Root 'scripts\theme-picker.ps1') + foreach ($requiredPickerAction in @( + 'Use-DreamSkinSavedTheme', + 'Remove-DreamSkinSavedTheme', + '删除主题', + '✓', + 'Start-DreamSkinPickerPowerShell', + 'Start-Process -FilePath $powershell -ArgumentList $argumentLine -WindowStyle Hidden' + )) { + if (-not $pickerSource.Contains($requiredPickerAction)) { + throw "Theme picker action is missing: $requiredPickerAction" + } + } $trayTokens = $null $trayParseErrors = $null $trayAst = [System.Management.Automation.Language.Parser]::ParseInput(