Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ bash macos/tests/run-tests.sh
<details>
<summary><strong>这是 Wallpaper Engine 的替代品吗?</strong></summary>

不是。它只控制 Codex 应用内部的主题背景。Windows 管理器可以导入普通 MP4/WebM,但不渲染 Wallpaper Engine 的 Scene、Web 或打包项目
不是。它只控制 Codex 应用内部的主题背景。Windows 管理器可以扫描并直接引用 Wallpaper Engine 已下载到本地的 `type: video` MP4/WebM 项目;超过 1080p 时会生成本地流畅代理。`type: scene` 的 `scene.pkg` 目前可通过独立 GPL-3.0 侧车进行开发预览;Web 壁纸和项目脚本仍不会执行
</details>

<details>
Expand Down
1 change: 1 addition & 0 deletions docs/platforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Windows 启动、失败回滚与恢复重开均从已注册的 `OpenAI.Codex`
- macOS 的选图脚本会把这些字段写入主题库,可通过 `--appearance`、`--focus-x`、`--focus-y`、`--safe-area`、`--task-mode` 设置。
- Windows 安装会把运行所需的 `assets/` 与 `scripts/` 原子复制到 `%LOCALAPPDATA%\CodexDreamSkin\engine`,所有快捷方式均指向该受管副本,因此安装后可移动或删除源码目录。安装还会初始化 `active-theme`、`themes` 与 `images`,并把「桥本有菜」同时设为首次默认和可切换的已保存主题。系统托盘支持更换背景、保存当前主题、从「已保存主题」切换、暂停和恢复;图片与 `theme.json` 保存在主题仓库中,不写进 Codex 的 `config.toml`。安装会保留用户已有的 `appearanceTheme`;仅在识别到旧版精确托管的浅色三元组时按备份迁移。
- Windows 渲染器仍支持在注入前用 `window.__CODEX_DREAM_SKIN_CONFIG__` 提供内存级可选覆盖(形状同上,颜色覆盖使用 `palette.accent`),但普通用户应优先使用持久化主题仓库与托盘。
- Windows 管理器的「Wallpaper Engine」入口会扫描用户已经通过 Steam / Wallpaper Engine 下载到本机的 `steamapps\workshop\content\431960` 项目,并读取 Steam 的 `libraryfolders.vdf` 发现第二库。每个 `type: video` 的 MP4/WebM 或 `type: scene` 的 `scene.pkg` Workshop 项目只列出一次;用户可多选导入主页,之后从主页切换。视频继续直接引用或生成本地 1080p 流畅代理;Scene 由独立 GPL-3.0 侧车在本机渲染成 H.264 fragmented MP4,再通过带随机令牌的 `127.0.0.1` HTTP 流交给 Chromium MSE,MIT 主程序不链接或内嵌 GPL 代码。侧车支持多 renderer 客户端和标准输入优雅退出,临时流位于 `%LOCALAPPDATA%\CodexDreamSkin\scene-stream`。Web、脚本壁纸暂不执行。每次启动或刷新主页都会重新校验 Workshop ID、库根目录、相对路径和文件存在性,取消订阅、移动或删除的条目会自动消失。

## 预设与图片类型

Expand Down
5 changes: 4 additions & 1 deletion windows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Codex 动态壁纸通过本机回环 CDP 给官方 Codex Windows 桌面应用加
管理器支持:

- 浏览、搜索并预览桌面壁纸库中的 PNG、JPEG、WebP、MP4 和 WebM。
- 扫描 Wallpaper Engine 本地 Workshop 中的视频和 `scene.pkg` 场景;Scene 通过独立 GPL-3.0 本地渲染侧车输出回环 fMP4 流,Web 壁纸暂不支持。
- 一键启动或重新应用动态壁纸、切换壁纸、暂停/恢复以及还原官方外观。
- 调整「壁纸透出」程度,100% 时视频保持原始画面且整页主题蒙层为零。
- 系统托盘控制和可选的当前用户开机启动。
Expand Down Expand Up @@ -103,7 +104,9 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify-dream-s

导入图片必须是纯背景,不要使用包含窗口、侧栏、输入框、文字或按钮的效果截图。图片上限为 16 MB;宽或高不能超过 16384 像素,总像素不能超过 5000 万。

动态壁纸始终静音、循环且不拦截鼠标操作;Codex 页面隐藏时会自动暂停。视频上限为 128 MB,并通过有界 CDP 分块传入当前 renderer,不会上传到网络,也不会把整段视频嵌入启动脚本。当前 MVP 不提供声音、播放列表或 Live2D。
动态壁纸始终静音、循环且不拦截鼠标操作;Codex 页面隐藏时会自动暂停。视频上限为 128 MB,并通过有界 CDP 分块传入当前 renderer,不会上传到网络,也不会把整段视频嵌入启动脚本。Wallpaper Engine 的超 1080p 视频在首次应用时会生成本地 1080p 流畅代理,缓存位于 `%LOCALAPPDATA%\CodexDreamSkin\media-cache`;原 Workshop 文件不会被修改,仍会在每次刷新时校验。当前 MVP 不提供声音、播放列表或 Live2D。

Scene 支持目前是开发预览:`SceneViewer.exe` 必须作为独立组件放在 `%LOCALAPPDATA%\CodexDreamSkin\scene-runtime`,并随附 GPL-3.0 许可证与对应源码信息。主程序只通过标准输入和带随机令牌的 `127.0.0.1` HTTP 接口与其通信;不会把 GPL 二进制嵌入 MIT 管理器。发布前仍需完成独立侧车包、源码提供说明和更多场景兼容性验证。

## 恢复与卸载快捷方式

Expand Down
280 changes: 276 additions & 4 deletions windows/app/CodexDreamSkin.Manager/DreamSkinService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,52 @@

namespace CodexDreamSkin.Manager;

internal sealed class DreamSkinService
internal sealed class DreamSkinService : IDisposable
{
private readonly RuntimeProvisioner _runtime;
private readonly PowerShellRunner _runner;
private readonly SceneStreamHost _sceneStream;

public DreamSkinService(RuntimeProvisioner runtime)
{
_runtime = runtime;
_runner = new PowerShellRunner(runtime);
_sceneStream = new SceneStreamHost(runtime);
}

public Func<string, bool>? ConfirmCodexRestart { get; set; }

public async Task StartAsync(CancellationToken cancellationToken = default)
{
var arguments = new List<string> { "-Port", "9335" };
var status = GetStatus();
if (!status.WatcherRunning && status.CodexRunning)
{
if (ConfirmCodexRestart?.Invoke(
"Codex 需要重启一次以启用动态壁纸,未保存的输入可能丢失。现在重启吗?") != true)
{
throw new OperationCanceledException("已取消启动;Codex 未发生更改。");
}
arguments.Add("-RestartExisting");
}
var result = await _runner.RunScriptAsync(
_runtime.StartScript,
new[] { "-Port", "9335", "-PromptRestart" },
arguments,
cancellationToken);
result.ThrowIfFailed("启动 Codex 动态壁纸");
}

public async Task<bool> RestoreActiveSceneAsync(CancellationToken cancellationToken = default)
{
var scenePath = await GetActiveScenePathAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(scenePath) || !File.Exists(scenePath))
{
return false;
}
await ApplyWallpaperEngineAsync(scenePath, cancellationToken);
return true;
}

public async Task ApplyWallpaperAsync(string path, CancellationToken cancellationToken = default)
{
if (!File.Exists(path) || !WallpaperCatalog.IsSupported(path))
Expand All @@ -42,6 +68,124 @@ public async Task ApplyWallpaperAsync(string path, CancellationToken cancellatio
}
}

public async Task<IReadOnlyList<WallpaperEngineItem>> ListWallpaperEngineAsync(
CancellationToken cancellationToken = default)
{
await _sceneStream.StopAsync();
var result = await RunManagerCommandAsync(
new[] { "-Action", "ListWallpaperEngine" },
cancellationToken);
result.ThrowIfFailed("读取 Wallpaper Engine 本地壁纸");
using var response = JsonDocument.Parse(result.StandardOutput);
if (!response.RootElement.TryGetProperty("items", out var items) ||
items.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException("Wallpaper Engine 壁纸列表响应无效。");
}
return items.EnumerateArray().Select(ParseWallpaperEngineItem).ToArray();
}

public async Task<SavedTheme> ApplyWallpaperEngineAsync(
string mediaPath,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(mediaPath))
{
throw new ArgumentException("Wallpaper Engine 媒体路径不能为空。", nameof(mediaPath));
}
ProcessResult result;
if (Path.GetFileName(mediaPath).Equals("scene.pkg", StringComparison.OrdinalIgnoreCase))
{
var streamUrl = await _sceneStream.StartAsync(mediaPath, cancellationToken);
try
{
result = await RunManagerCommandAsync(
new[] { "-Action", "UseSceneStream", "-Path", mediaPath, "-StreamUrl", streamUrl },
cancellationToken);
}
catch
{
await _sceneStream.StopAsync();
throw;
}
}
else
{
await _sceneStream.StopAsync();
result = await RunManagerCommandAsync(
new[] { "-Action", "UseWallpaperEngine", "-Path", mediaPath },
cancellationToken);
}
try
{
result.ThrowIfFailed("应用 Wallpaper Engine 本地壁纸");
var theme = ParseThemeResult(result.StandardOutput);
if (!GetStatus().WatcherRunning)
{
await StartAsync(cancellationToken);
}
return theme;
}
catch
{
if (Path.GetFileName(mediaPath).Equals("scene.pkg", StringComparison.OrdinalIgnoreCase))
{
await _sceneStream.StopAsync();
}
throw;
}
}

public async Task<IReadOnlyList<SavedTheme>> ListSavedThemesAsync(
CancellationToken cancellationToken = default)
{
var result = await RunManagerCommandAsync(new[] { "-Action", "ListThemes" }, cancellationToken);
result.ThrowIfFailed("读取已保存主题");
using var response = JsonDocument.Parse(result.StandardOutput);
if (!response.RootElement.TryGetProperty("themes", out var themes) ||
themes.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException("主题列表响应无效。");
}
return themes.EnumerateArray().Select(ParseSavedTheme).ToArray();
}

public async Task<SavedTheme> SaveCurrentThemeAsync(
string name,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("主题名称不能为空。", nameof(name));
}
var result = await RunManagerCommandAsync(
new[] { "-Action", "SaveTheme", "-Name", name.Trim() },
cancellationToken);
result.ThrowIfFailed("保存当前主题");
return ParseThemeResult(result.StandardOutput);
}

public async Task<SavedTheme> ApplySavedThemeAsync(
string themeId,
CancellationToken cancellationToken = default)
{
await _sceneStream.StopAsync();
if (string.IsNullOrWhiteSpace(themeId))
{
throw new ArgumentException("主题标识不能为空。", nameof(themeId));
}
var result = await RunManagerCommandAsync(
new[] { "-Action", "UseTheme", "-ThemeId", themeId },
cancellationToken);
result.ThrowIfFailed("应用已保存主题");
var theme = ParseThemeResult(result.StandardOutput);
if (!GetStatus().WatcherRunning)
{
await StartAsync(cancellationToken);
}
return theme;
}

public async Task SetRevealAsync(int percent, CancellationToken cancellationToken = default)
{
if (percent is < 0 or > 100)
Expand All @@ -56,17 +200,40 @@ public async Task SetRevealAsync(int percent, CancellationToken cancellationToke

public async Task SetPausedAsync(bool paused, CancellationToken cancellationToken = default)
{
if (!paused)
{
var scenePath = await GetActiveScenePathAsync(cancellationToken);
if (!string.IsNullOrWhiteSpace(scenePath) && File.Exists(scenePath))
{
await ApplyWallpaperEngineAsync(scenePath, cancellationToken);
}
}
var result = await RunManagerCommandAsync(
new[] { "-Action", paused ? "Pause" : "Resume" },
cancellationToken);
result.ThrowIfFailed(paused ? "暂停皮肤" : "恢复皮肤");
if (paused)
{
await _sceneStream.StopAsync();
}
}

public async Task RestoreAsync(CancellationToken cancellationToken = default)
{
await _sceneStream.StopAsync();
var arguments = new List<string> { "-Port", "9335", "-RestoreBaseTheme" };
if (GetStatus().CodexRunning)
{
if (ConfirmCodexRestart?.Invoke(
"恢复官方外观将关闭 Codex、移除动态壁纸,然后重新打开官方应用。是否继续?") != true)
{
throw new OperationCanceledException("已取消恢复;Codex 未发生更改。");
}
arguments.Add("-ForceRestart");
}
var result = await _runner.RunScriptAsync(
_runtime.RestoreScript,
new[] { "-Port", "9335", "-RestoreBaseTheme", "-PromptRestart" },
arguments,
cancellationToken);
result.ThrowIfFailed("恢复 Codex 官方外观");
}
Expand All @@ -87,14 +254,24 @@ public DreamSkinStatus GetStatus()
if (File.Exists(statePath))
{
using var state = JsonDocument.Parse(File.ReadAllText(statePath));
var cdpPort = state.RootElement.TryGetProperty("port", out var portElement) &&
portElement.TryGetInt32(out var savedPort)
? savedPort
: 0;
if (state.RootElement.TryGetProperty("injectorPid", out var pidElement) &&
pidElement.TryGetInt32(out var pid))
{
try
{
using var process = Process.GetProcessById(pid);
watcherRunning = !process.HasExited &&
process.ProcessName.Equals("node", StringComparison.OrdinalIgnoreCase);
process.ProcessName.Equals("node", StringComparison.OrdinalIgnoreCase) &&
cdpPort is >= 1024 and <= 65535 &&
System.Net.NetworkInformation.IPGlobalProperties.GetIPGlobalProperties()
.GetActiveTcpListeners()
.Any(endpoint =>
endpoint.Port == cdpPort &&
System.Net.IPAddress.IsLoopback(endpoint.Address));
}
catch
{
Expand Down Expand Up @@ -125,6 +302,19 @@ public DreamSkinStatus GetStatus()
{
reveal = Math.Clamp((int)Math.Round(opacity * 100), 0, 100);
}
if (theme.RootElement.TryGetProperty("media", out mediaElement) &&
mediaElement.TryGetProperty("type", out var mediaTypeElement) &&
mediaTypeElement.ValueKind == JsonValueKind.String &&
mediaTypeElement.GetString()?.Equals("scene", StringComparison.OrdinalIgnoreCase) == true)
{
mediaKind = WallpaperKind.Scene;
if (mediaElement.TryGetProperty("scenePath", out var scenePathElement) &&
scenePathElement.ValueKind == JsonValueKind.String &&
File.Exists(scenePathElement.GetString()))
{
mediaPath = scenePathElement.GetString();
}
}
}
}
catch
Expand All @@ -149,6 +339,88 @@ private Task<ProcessResult> RunManagerCommandAsync(
CancellationToken cancellationToken) =>
_runner.RunScriptAsync(_runtime.ManagerCommandScript, arguments, cancellationToken);

private static SavedTheme ParseThemeResult(string json)
{
using var response = JsonDocument.Parse(json);
return ParseSavedTheme(response.RootElement);
}

public void Dispose()
{
_sceneStream.Dispose();
}

private async Task<string?> GetActiveScenePathAsync(CancellationToken cancellationToken)
{
var themePath = Path.Combine(_runtime.StateRoot, "active-theme", "theme.json");
if (!File.Exists(themePath))
{
return null;
}
using var theme = JsonDocument.Parse(await File.ReadAllTextAsync(themePath, cancellationToken));
if (!theme.RootElement.TryGetProperty("media", out var media) ||
!media.TryGetProperty("type", out var mediaType) ||
!mediaType.ValueEquals("scene") ||
!media.TryGetProperty("scenePath", out var scenePathElement))
{
return null;
}
return scenePathElement.GetString();
}

private static SavedTheme ParseSavedTheme(JsonElement element)
{
return new SavedTheme(
RequireJsonString(element, "themeId", "id"),
RequireJsonString(element, "name"),
ParseWallpaperKind(RequireJsonString(element, "mediaType")));
}

private static WallpaperEngineItem ParseWallpaperEngineItem(JsonElement element)
{
if (!element.TryGetProperty("length", out var lengthElement) || !lengthElement.TryGetInt64(out var length) ||
length < 1)
{
throw new InvalidOperationException("Wallpaper Engine 条目缺少有效的媒体大小。");
}
return new WallpaperEngineItem(
RequireJsonString(element, "workshopId"),
RequireJsonString(element, "name"),
RequireJsonString(element, "workshopRoot"),
RequireJsonString(element, "projectDirectory"),
RequireJsonString(element, "relativePath"),
RequireJsonString(element, "mediaPath"),
ParseWallpaperKind(RequireJsonString(element, "mediaType")),
length);
}

private static string RequireJsonString(
JsonElement element,
string property,
string? fallbackProperty = null)
{
if (element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(value.GetString()))
{
return value.GetString()!;
}
if (fallbackProperty is not null && element.TryGetProperty(fallbackProperty, out value) &&
value.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(value.GetString()))
{
return value.GetString()!;
}
throw new InvalidOperationException($"主题响应缺少有效字段:{property}。");
}

private static WallpaperKind ParseWallpaperKind(string mediaType) =>
mediaType.Equals("video", StringComparison.OrdinalIgnoreCase)
? WallpaperKind.Video
: mediaType.Equals("scene", StringComparison.OrdinalIgnoreCase)
? WallpaperKind.Scene
: mediaType.Equals("image", StringComparison.OrdinalIgnoreCase)
? WallpaperKind.Image
: throw new InvalidOperationException($"主题响应包含未知媒体类型:{mediaType}。");

private static bool IsAnyProcessRunning(string processName)
{
var processes = Process.GetProcessesByName(processName);
Expand Down
Loading
Loading