feat(skins): 添加离线自定义皮肤支持 - #3518
Conversation
- SkinType/TextureModel/Skin 皮肤配置模型,含 JSON 序列化与容错反序列化
- SkinTexture:贴图内容哈希(SHA256)与进程内缓存,对齐 HMCL Texture
- NormalizedSkin:尺寸校验、slim 自动判定、64x32 旧格式转 64x64
- LoadedSkin:皮肤加载结果
- RsaKeyUtils:RSA-SHA1withRSA 签名与公钥 PEM
- OfflineSkinServer:内嵌 Yggdrasil 皮肤服务器(7 路由,含 hasJoined 签名)
- HttpServer:新增带 {param} 的模板路由(精确>模板>通配,向后兼容)
- ModSkin: LoadSkinAsync 皮肤加载器(内置/本地文件+披风/CSL 两步协议) - SkinJson: CSL API 响应解析(default/slim/cape, 容错) - ModLaunch: Legacy 分支注入 authlib-injector 指向内嵌皮肤服务器; 导出启动脚本时跳过注入; finally 清理服务器生命周期 - ModProfile: McProfile.Skin 皮肤配置字段 + 序列化/反序列化(兼容旧配置) - OfflineSkinDialog: 离线皮肤设置对话框(类型/模型/本地文件/披风/CSL URL) - PageLoginProfileSkin: 离线启用皮肤入口, 打开 OfflineSkinDialog - PageLaunchLeft: skinLegacy 消费 selectedProfile.Skin 显示自定义头像 - 语言: zh-CN/en-US 新增 Launch.OfflineSkin.* 21 个键
- 接线 NormalizedSkin.IsSlim() 到选文件流程: 自动判定 Steve/Alex 模型(原为死代码, 现符合规格) - 删除 McLaunchEnd 中与 finally 重复的服务器销毁块 - OfflineSkinDialog 移除 TypeOrder 重复映射, 改读 Combo 项 Tag + Enum.TryParse - SkinTexture 移除 Has() 双重查询, 统一 Get() - Skin record 参数 TextureModel 改名 Model(与 LoadedSkin 一致) - NormalizeCslUrl 改 private - HttpServer.Start 守卫同时检查模板路由(防模板路由静默跳过) - OfflineSkinServer 版本号加同步注释
覆盖 SkinTexture 哈希(参考实现一致/透明归一化/缓存)、 NormalizedSkin(尺寸校验/旧格式转换/slim 三分支)、 Skin JSON 序列化(往返/snake_case/容错)
1. 皮肤不生效(根因):HttpServer 拼接 IPv6 前缀时未加方括号(::1),
导致 HttpListener.AddPrefix 抛异常、OfflineSkinServer 构造失败、注入被静默跳过。
- 修复:IPv6 地址用 [::1] 包裹
2. Host 头一致性:注入 javaagent 与 textures 载荷的皮肤 URL 统一改用
127.0.0.1(原 localhost 可能解析为 ::1 导致 Host 头与 HttpListener 前缀不匹配而 404)
3. 头像加载失败:LoadLegacyCustomSkin 对本地文件直接返回原路径,
但 MySkin.Load 依赖路径含 'Skin\\' 子串解析 skinHeadId,导致缓存目录拼错。
- 修复:一律导出到 Cache\\Skin\\{uuid}.png 再返回
4. OfflineSkinDialog 布局:根 Grid 显式左对齐固定宽度,消除偏右
5. 新增 OfflineSkinServerTest(4 项):IPv6 构造回归/元数据/hasJoined/404
根因:服务器在 McLaunchStart 的 finally 于游戏窗口出现后即被销毁, 但皮肤/贴图 URL 在进入世界/第三人称时才被游戏拉取,端口已不通。 - 服务器清理改为挂在游戏进程 Exited 事件(进程退出时 Dispose) - McLaunchStart finally 改为兜底:仅当进程未创建或已退出时清理, 游戏运行中则交给 Exited 事件 - HttpServer.Dispose 幂等化(进程事件与 finally 兜底可能竞态调用)
原横向 StackPanel + 固定宽度导致左右错位、按钮被挤压、输入框溢出。 改为:左列头像固定 104px、中列固定间距、右列表单自适应; 输入框+按钮用 *+8+92 三列,按钮固定宽度不被压缩; 所有 label 左对齐,垂直间距统一。
用户调整 OfflineSkinDialog.xaml(移除头像预览列)后,代码后置仍引用 ImgPreviewFace/ImgPreviewHair,导致编译失败、应用无法启动。 - 删除 UpdatePreview/GetPreviewPath 及所有调用 - 字段赋默认值消除可空警告
Reviewer's Guide通过引入皮肤领域模型、内嵌的兼容 Yggdrasil 的 HTTP 皮肤服务器、CSL/本地文件加载工具、用于配置皮肤的 UI,并将该服务器接入启动和档案(配置文件)流程(含完整生命周期管理和测试),为传统(离线)Minecraft 账号增加了完整的离线自定义皮肤支持。 启动过程中注入离线传统皮肤服务器的时序图sequenceDiagram
participant Launcher as McLaunch
participant OfflineSkinServer
participant AuthlibInjector as authlib_injector.jar
participant Game as MinecraftProcess
Launcher->>Launcher: McLaunchOfflineSkinInject(dataList)
Launcher->>ModSkin: LoadSkinAsync(skin, username)
ModSkin-->>Launcher: LoadedSkin?
alt server not created yet
Launcher->>OfflineSkinServer: new OfflineSkinServer()
end
Launcher->>OfflineSkinServer: AddCharacter(uuid, username, loadedSkin)
Launcher->>OfflineSkinServer: Start()
Launcher->>Launcher: dataList.Insert(0, "-javaagent:authlib-injector.jar=http://127.0.0.1:{Port} ...")
Launcher->>Game: McLaunchRun(...) starts gameProcess
Game->>AuthlibInjector: load javaagent
AuthlibInjector->>OfflineSkinServer: GET /, /sessionserver/... , /textures/{hash}
Game-->>Launcher: Exited event
Launcher->>OfflineSkinServer: Dispose()
OfflineSkinServer-->>Launcher: Port closed
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your Experience打开你的 dashboard 来:
Getting HelpOriginal review guide in EnglishReviewer's GuideAdds full offline custom skin support for legacy (offline) Minecraft accounts by introducing a skin domain model, an embedded Yggdrasil-compatible HTTP skin server, CSL/local-file loading utilities, UI for configuring skins, and wiring the server into the launch and profile flows with appropriate lifecycle management and tests. Sequence diagram for offline legacy skin server injection during launchsequenceDiagram
participant Launcher as McLaunch
participant OfflineSkinServer
participant AuthlibInjector as authlib_injector.jar
participant Game as MinecraftProcess
Launcher->>Launcher: McLaunchOfflineSkinInject(dataList)
Launcher->>ModSkin: LoadSkinAsync(skin, username)
ModSkin-->>Launcher: LoadedSkin?
alt server not created yet
Launcher->>OfflineSkinServer: new OfflineSkinServer()
end
Launcher->>OfflineSkinServer: AddCharacter(uuid, username, loadedSkin)
Launcher->>OfflineSkinServer: Start()
Launcher->>Launcher: dataList.Insert(0, "-javaagent:authlib-injector.jar=http://127.0.0.1:{Port} ...")
Launcher->>Game: McLaunchRun(...) starts gameProcess
Game->>AuthlibInjector: load javaagent
AuthlibInjector->>OfflineSkinServer: GET /, /sessionserver/... , /textures/{hash}
Game-->>Launcher: Exited event
Launcher->>OfflineSkinServer: Dispose()
OfflineSkinServer-->>Launcher: Port closed
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - 我发现了一个问题,并给出了一些整体反馈:
- 在 HttpServer.Stop/Dispose 中你从未重置
_started标志,因此已停止的服务器无法再次启动;建议在 Stop 中清除_started,以保证可安全复用,并与新的「可重复调用 Start」语义保持一致。 - LoadLegacyCustomSkin 会将临时 PNG 写入
Cache\Skin目录,但从不删除它们,这可能会导致离线账户的缓存文件无限增长;建议在保存或重新加载皮肤时添加一个简单的清理策略(例如覆盖或清理旧的缓存条目)。
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- 在 HttpServer.Stop/Dispose 中你从未重置 `_started` 标志,因此已停止的服务器无法再次启动;建议在 Stop 中清除 `_started`,以保证可安全复用,并与新的「可重复调用 Start」语义保持一致。
- LoadLegacyCustomSkin 会将临时 PNG 写入 `Cache\Skin` 目录,但从不删除它们,这可能会导致离线账户的缓存文件无限增长;建议在保存或重新加载皮肤时添加一个简单的清理策略(例如覆盖或清理旧的缓存条目)。
## Individual Comments
### Comment 1
<location path="PCL.Core/IO/Net/Http/HttpServer.cs" line_range="21-25" />
<code_context>
private readonly Dictionary<(HttpMethod method, string path), Func<HttpListenerRequest, Task<HttpRouteResponse>>> _handlers = new();
+ private readonly Dictionary<(HttpMethod method, string path), Func<HttpListenerRequest, IReadOnlyDictionary<string, string>, Task<HttpRouteResponse>>> _templateHandlers = new();
private bool _initialized = false;
+ private bool _started = false;
+ private bool _disposed = false;
</code_context>
<issue_to_address>
**issue (bug_risk):** Reset `_started` when stopping to keep `Start()`/`Stop()` semantics consistent.
`_started` 在 `Start()` 中被设置为 `true`,但在 `Stop()` 中从未被清除。调用 `Stop()` 后,对同一实例再次调用 `Start()` 会提前返回,并且不会重新启动 `HttpListener`,这与「重复调用安全」保证相矛盾,并破坏了复用场景。请在 `_server.Stop()` 成功后于 `Stop()` 中重置 `_started = false;`,并考虑在未启动时对 `Stop()` 调用进行保护。
</issue_to_address>帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据你的反馈改进后续评审。
Original comment in English
Hey - I've found 1 issue, and left some high level feedback:
- In HttpServer.Stop/Dispose you never reset the
_startedflag, so a stopped server cannot be started again; consider clearing_startedin Stop to make reuse safe and align with the new "repeat Start" semantics. - LoadLegacyCustomSkin writes temporary PNGs to
Cache\Skinbut never deletes them, which can lead to unbounded growth of cached files for offline accounts; consider adding a simple cleanup strategy (e.g., overwriting or pruning old cache entries) when saving or reloading skins.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In HttpServer.Stop/Dispose you never reset the `_started` flag, so a stopped server cannot be started again; consider clearing `_started` in Stop to make reuse safe and align with the new "repeat Start" semantics.
- LoadLegacyCustomSkin writes temporary PNGs to `Cache\Skin` but never deletes them, which can lead to unbounded growth of cached files for offline accounts; consider adding a simple cleanup strategy (e.g., overwriting or pruning old cache entries) when saving or reloading skins.
## Individual Comments
### Comment 1
<location path="PCL.Core/IO/Net/Http/HttpServer.cs" line_range="21-25" />
<code_context>
private readonly Dictionary<(HttpMethod method, string path), Func<HttpListenerRequest, Task<HttpRouteResponse>>> _handlers = new();
+ private readonly Dictionary<(HttpMethod method, string path), Func<HttpListenerRequest, IReadOnlyDictionary<string, string>, Task<HttpRouteResponse>>> _templateHandlers = new();
private bool _initialized = false;
+ private bool _started = false;
+ private bool _disposed = false;
</code_context>
<issue_to_address>
**issue (bug_risk):** Reset `_started` when stopping to keep `Start()`/`Stop()` semantics consistent.
`_started` is set to `true` in `Start()` but never cleared in `Stop()`. After calling `Stop()`, a subsequent `Start()` on the same instance will return early and not restart the `HttpListener`, contradicting the "重复调用安全" guarantee and breaking reuse scenarios. Please reset `_started = false;` in `Stop()` after `_server.Stop()` succeeds, and consider guarding `Stop()` against being called when not started.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| private bool _started = false; | ||
| private bool _disposed = false; | ||
|
|
||
| protected HttpServer(IPAddress[] listenAddr, ushort port = 0) | ||
| { |
There was a problem hiding this comment.
issue (bug_risk): Reset _started when stopping to keep Start()/Stop() semantics consistent.
_started 在 Start() 中被设置为 true,但在 Stop() 中从未被清除。调用 Stop() 后,对同一实例再次调用 Start() 会提前返回,并且不会重新启动 HttpListener,这与「重复调用安全」保证相矛盾,并破坏了复用场景。请在 _server.Stop() 成功后于 Stop() 中重置 _started = false;,并考虑在未启动时对 Stop() 调用进行保护。
Original comment in English
issue (bug_risk): Reset _started when stopping to keep Start()/Stop() semantics consistent.
_started is set to true in Start() but never cleared in Stop(). After calling Stop(), a subsequent Start() on the same instance will return early and not restart the HttpListener, contradicting the "重复调用安全" guarantee and breaking reuse scenarios. Please reset _started = false; in Stop() after _server.Stop() succeeds, and consider guarding Stop() against being called when not started.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6da5f66f1e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| { | ||
| // 仅当退出的正是当前记录的游戏进程时才关闭服务器: | ||
| // 多开时先退出的游戏不能关掉仍在服务后一局游戏的服务器 | ||
| if (ReferenceEquals(mcLaunchProcess, gameProcess) && mcLaunchOfflineSkinServer is not null) |
There was a problem hiding this comment.
Keep the skin server until every launched game exits
When a second launch happens while an earlier custom-skin game is still running, mcLaunchProcess is overwritten with the newer process. If that newer process exits first (or is killed after a launch cancel), this handler disposes the shared mcLaunchOfflineSkinServer, so the older game loses the local Yggdrasil/texture URLs it may still request after entering worlds or reloading skins; track all processes using the server and only dispose it when none remain.
Useful? React with 👍 / 👎.
|
在合并前得给 UI 先改了 |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2150c4b60a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (skinTex is null) | ||
| return null; |
There was a problem hiding this comment.
When a LittleSkin/CSL API response has a cape hash but no skin hash, this downloads capeTex and then discards the whole result because skinTex is null. The offline skin server emits CAPE independently of SKIN, and local-file mode already permits cape-only configurations, so users with only a cape configured get an empty texture profile instead of their cape; return a LoadedSkin whenever either texture is present.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f08a2f3ca2
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| private static async Task<SkinTexture?> DownloadCslTextureAsync(string api, string hash) | ||
| { | ||
| var directory = Path.Combine(ModBase.pathTemp, "Skin"); | ||
| var tempPath = Path.Combine(directory, hash + ".png"); |
There was a problem hiding this comment.
Validate CSL texture hashes before using them as paths
When a configured CSL/LittleSkin endpoint returns a texture hash containing path separators or an absolute path, this Path.Combine lets tempPath escape the intended temp skin directory; FileDownloader.DownloadAsync cleans up the target path before downloading, so a malicious or compromised skin API can delete or overwrite arbitrary .png paths reachable by the launcher. Please reject non-hash characters or map the remote hash to a safe local filename before using it as a path component.
Useful? React with 👍 / 👎.
| <sys:String x:Key="Launch.OfflineSkin.SelectCape">选择披风文件…</sys:String> | ||
| <sys:String x:Key="Launch.OfflineSkin.CslApiUrl">CSL API 地址</sys:String> | ||
| <sys:String x:Key="Launch.OfflineSkin.LittleSkinHint">LittleSkin 为 Minecraft 玩家提供皮肤托管服务。</sys:String> | ||
| <sys:String x:Key="Launch.OfflineSkin.OpenLittleSkin">打开其官网</sys:String> |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fc4aec2c3d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (!string.IsNullOrEmpty(GetString(textures, "slim"))) | ||
| return TextureModel.Slim; |
There was a problem hiding this comment.
Respect CSL texture preference order
When a CustomSkinAPI response includes both default and slim textures, this always returns Slim because it checks slim first. The CustomSkinAPI spec says the texture dictionary order is the user's preference order, so users whose server returns default before slim will still be launched/rendered with the slim skin/model; iterate the textures entries in JSON order and choose the first supported skin model instead.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 36ef7a282f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| data.output = skinConfig is not null && skinConfig.Type != SkinType.Default | ||
| ? LoadLegacyCustomSkin(skinConfig, data.input[0]) |
There was a problem hiding this comment.
Drop aborted legacy-skin results before publishing
When a CSL/LittleSkin avatar load is superseded, such as switching profiles or saving another skin while the network request is still in flight, LoaderTask.Start(isForceRestart: true) only marks the old task aborted; this branch still blocks in LoadLegacyCustomSkin, then writes data.output and proceeds to Skin.Load() because the later check only compares the shared static loader. That lets a stale profile's skin overwrite the current avatar/cache, so check data.IsAborted after the load before publishing the result, as the Ms/Auth loaders do.
Useful? React with 👍 / 👎.
Co-authored-by: ChilovenBustiangle <116699482+Chiloven945@users.noreply.github.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6ff5e73bbb
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| private void UpdateConfirmEnabled() | ||
| { | ||
| BtnConfirm.IsEnabled = CurrentType != SkinType.CustomSkinLoaderApi || | ||
| Uri.TryCreate(TextCslApi.Text.Trim(), UriKind.Absolute, out _); |
There was a problem hiding this comment.
Accept scheme-less CSL API URLs
When a user enters a CSL API base such as littleskin.cn/csl, this check disables OK and the confirm handler rejects the value because UriKind.Absolute requires an explicit scheme, even though ModSkin.NormalizeCslUrl is written to support exactly this input by prepending https://. That makes supported CustomSkinLoaderApi URLs impossible to save from the new UI; validate using the same normalization path or allow scheme-less hostnames before blocking confirmation.
Useful? React with 👍 / 👎.











实现离线自定义皮肤支持
本地皮肤验证
Littleskin验证
Summary by Sourcery
为旧版(离线)账户添加离线自定义皮肤支持,包括本地文件和 Custom Skin Loader API,并将其集成到启动流程和档案(Profile)管理中。
新特性:
改进:
测试:
Original summary in English
Summary by Sourcery
Add offline custom skin support for legacy (offline) accounts, including local files and Custom Skin Loader APIs, and integrate it into launch flow and profile management.
New Features:
Enhancements:
Tests: