自更新
自更新的流程是:拉清单 → 比版本 → 选形态 → 下载校验 → 退到后台脚本 → 重启。中间任何一步失败都不会让启动器变砖。
更新清单
服务端放一个 latest.json,默认地址写在代码里:
public const string DefaultManifestUrl = "https://starcloudfur.cn/downloads/latest.json";设置项 update_manifest_url 可以覆盖它(测试时指向本地服务器)。清单格式:
{
"version": "0.3.9",
"notes": "更新内容(中文)",
"notes_en": "Release notes (English)",
"published_at": "2026-10-22T15:30:12Z",
"setup": { "file": "Starcloud Minecraft Launcher_0.3.9_x64-setup-20261022-153012.exe", "sha256": "…", "size": 62345678 },
"portable": { "file": "Starcloud Minecraft Launcher_0.3.9_x64-portable-20261022-153012.zip", "sha256": "…", "size": 87234567 }
}file 只是文件名,实际 URL 是「清单 URL 所在目录 + 文件名」:
private Uri ResolveArtifactUrl(string fileName)
{
if (!Uri.TryCreate(ManifestUrl, UriKind.Absolute, out var baseUri) || baseUri.Scheme is not ("http" or "https"))
throw new InvalidOperationException($"清单地址无效:{ManifestUrl}");
return new Uri(baseUri, fileName);
}所以发布只要把三个文件(两个产物 + 清单)拷到服务器同一个目录,不用改配置。
版本比较规则
候选版本比当前版本新才提示更新。任一方解析失败,或候选不比当前新,一律返回 false——安全侧不误升级。TryParseVersion 会先切掉 +commit 后缀(0.3.9+abc123 → 0.3.9)。
坑一:System.Version 把缺省段当 -1
System.Version 里没写的段不是 0,是 -1:
new Version("0.2.1").Build // -1
new Version("0.2.1").CompareTo(new Version("0.2.1.0")) // -1,被判为"更旧""0.2.1" 和 "0.2.1.0" 直接比是不相等的。所以比较前必须归一化:
private static Version NormalizeVersion(Version v) => new(
v.Major, v.Minor, Math.Max(v.Build, 0), Math.Max(v.Revision, 0));不归一化的后果很具体:清单写 0.2.1、程序集版本是 0.2.1.0,用户永远看不到更新提示。
坑二:latest.json 必须 UTF-8 无 BOM
这是整个自更新功能最容易整体失效的一点。
System.Text.Json 的字符串重载遇到开头的 U+FEFF 会直接判定为非法 JSON。而 Windows PowerShell 5.1 的 Set-Content -Encoding UTF8 默认写 BOM。
打包脚本因此改用:
[IO.File]::WriteAllText($manifestPath, $json, (New-Object System.Text.UTF8Encoding($false)))运行时还有一道兜底:(await _http.GetStringAsync(ManifestUrl, ct)).TrimStart('\uFEFF')——手工放的清单不至于把整条更新路堵死。
反过来:生成的脚本需要 BOM
清单要无 BOM,但更新脚本要带 BOM——它是给 Windows PowerShell 5.1 执行的,脚本里有中文路径:
File.WriteAllText(scriptPath, script, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));一个无 BOM,一个有 BOM,方向相反,理由不同。
两种安装形态
DetectInstallForm() 判当前是哪种:注册表 InstallLocation 指向当前目录,或目录里有 uninstall.exe → NSIS 安装版;其余情况 → 便携版。
NSIS 安装版:下载 setup.exe,退出后跑 setup /S 静默原地升级(NSIS 静默安装自动沿用注册表里的原安装目录),再重启,最后自删脚本。
便携版:下载 zip,解压到 <数据根>/update/staging/,退出后用 robocopy /MIR 覆盖回 exe 所在目录,再重启:
& robocopy '@STAGING@' '@APPDIR@' /MIR /R:2 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null
if ($LASTEXITCODE -ge 8) { exit 1 }用 /MIR 而不是覆盖复制,是为了跟 NSIS 的 RMDir /r 语义对齐——杜绝新旧 dll 混装。混装的典型症状是「更新完启动崩,报某个方法的 MissingMethodException」。
两种脚本都先等两轮退出:等发起更新的那个实例(最多 60 秒),再等所有 Starcloud.App 实例(覆盖多开)。
清理残留会把缓存一起删
CleanupLeftovers() 在启动时清空整个自更新工作目录:
public static void CleanupLeftovers()
{
try
{
if (Directory.Exists(LauncherPaths.UpdateDir))
Directory.Delete(LauncherPaths.UpdateDir, recursive: true);
}
catch { /* 被占用等场景下忽略,下次再清 */ }
try { Directory.CreateDirectory(LauncherPaths.UpdateDir); } catch { }
}UpdateDir 下面有 downloads/(已下载的 setup / zip)和 staging/(解压好的新版)。整个目录被删,所以已下载校验通过的更新包缓存也没了——下次检查要重新下一遍。
这本身不是 bug(清理残留是必须的),但要知道:刚下载完更新包还没重启就退出启动器,那个包会被删掉。
校验
下载先写 .part,校验通过才 Move 到正式名。校验看两样:文件大小(清单里 size > 0 时)和 SHA256(清单里给了才校验)。已存在的文件会先比对一遍,匹配就跳过下载。