From 05925dacfb5c78de3c378761fb6bbcc64d6b2009 Mon Sep 17 00:00:00 2001 From: Kei-Luna Date: Mon, 27 Apr 2026 15:35:05 +0900 Subject: [PATCH] MikuSB.Updater --- .github/workflows/release.yml | 56 ++++++ Common/Configuration/ConfigContainer.cs | 2 +- Common/Util/BuildVersion.cs | 49 +++++ Directory.Build.props | 5 + Directory.Build.targets | 21 +++ MikuSB.Updater/MikuSB.Updater.csproj | 12 ++ MikuSB.Updater/Program.cs | 90 ++++++++++ MikuSB.sln | 6 + MikuSB/MikuSB.csproj | 41 +++++ MikuSB/Program/LoaderManager.cs | 1 + MikuSB/Program/MikuSB.cs | 6 +- MikuSB/Update/UpdateService.cs | 229 ++++++++++++++++++++++++ version.txt | 1 + 13 files changed, 517 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 Common/Util/BuildVersion.cs create mode 100644 Directory.Build.props create mode 100644 Directory.Build.targets create mode 100644 MikuSB.Updater/MikuSB.Updater.csproj create mode 100644 MikuSB.Updater/Program.cs create mode 100644 MikuSB/Update/UpdateService.cs create mode 100644 version.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1321af2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: release + +on: + push: + paths: + - version.txt + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-release: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Read version + id: version + shell: pwsh + run: | + $line = (Get-Content version.txt | Select-Object -First 1).Trim() + $version = $line -replace '^v=', '' + if ([string]::IsNullOrWhiteSpace($version)) { throw 'version.txt is empty.' } + "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "tag=v$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Publish server + run: dotnet publish .\MikuSB\MikuSB.csproj -c Release -p:PublishProfile=MikuSB-Win64-MultiFile -o .\artifacts\publish\MikuSB + + - name: Assemble release package + shell: pwsh + run: | + $packageDir = ".\artifacts\package\MikuSB-win-x64" + New-Item -ItemType Directory -Force -Path $packageDir | Out-Null + Copy-Item .\artifacts\publish\MikuSB\* $packageDir -Recurse -Force + if (-not (Test-Path "$packageDir\MikuSB.Updater.exe")) { throw 'MikuSB.Updater.exe was not bundled into the publish output.' } + Copy-Item .\version.txt $packageDir -Force + Compress-Archive -Path "$packageDir\*" -DestinationPath .\artifacts\MikuSB-win-x64.zip -Force + $hash = (Get-FileHash .\artifacts\MikuSB-win-x64.zip -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash MikuSB-win-x64.zip" | Set-Content .\artifacts\MikuSB-win-x64.zip.sha256 + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.tag }} + name: ${{ steps.version.outputs.tag }} + generate_release_notes: true + files: | + artifacts/MikuSB-win-x64.zip + artifacts/MikuSB-win-x64.zip.sha256 diff --git a/Common/Configuration/ConfigContainer.cs b/Common/Configuration/ConfigContainer.cs index f2fc369..528ed78 100644 --- a/Common/Configuration/ConfigContainer.cs +++ b/Common/Configuration/ConfigContainer.cs @@ -91,4 +91,4 @@ public class ProxyOptions public bool ManageSystemProxy { get; set; } = true; public bool RestoreSystemProxyOnStop { get; set; } = true; public string ProxyOverride { get; set; } = "localhost;127.*;10.*;192.168.*;"; -} \ No newline at end of file +} diff --git a/Common/Util/BuildVersion.cs b/Common/Util/BuildVersion.cs new file mode 100644 index 0000000..a7e60ff --- /dev/null +++ b/Common/Util/BuildVersion.cs @@ -0,0 +1,49 @@ +using System.Reflection; + +namespace MikuSB.Util; + +public static class BuildVersion +{ + public static string Current + { + get + { + var assembly = Assembly.GetEntryAssembly() ?? typeof(BuildVersion).Assembly; + var value = assembly.GetCustomAttribute()?.InformationalVersion; + return string.IsNullOrWhiteSpace(value) ? "0.0.0" : Normalize(value); + } + } + + public static string Normalize(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return "0.0.0"; + + var trimmed = value.Trim(); + if (trimmed.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + trimmed = trimmed[1..]; + + var separatorIndex = trimmed.IndexOfAny(['-', '+']); + return separatorIndex >= 0 ? trimmed[..separatorIndex] : trimmed; + } + + public static bool IsNewer(string candidate, string current) + { + return ToComparableVersion(candidate) > ToComparableVersion(current); + } + + private static Version ToComparableVersion(string? value) + { + var normalized = Normalize(value); + var parts = normalized.Split('.', StringSplitOptions.RemoveEmptyEntries); + var padded = new int[4]; + + for (var i = 0; i < padded.Length; i++) + { + if (i < parts.Length && int.TryParse(parts[i], out var parsed)) + padded[i] = parsed; + } + + return new Version(padded[0], padded[1], padded[2], padded[3]); + } +} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..d615516 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,5 @@ + + + false + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..f78f58c --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,21 @@ + + + + + + + + <_BuildVersionRaw>@(_BuildVersionLines->'%(Identity)', '') + <_BuildVersion>$([System.String]::Copy('$(_BuildVersionRaw)').Replace('v=', '').Trim()) + + + + + + + + + diff --git a/MikuSB.Updater/MikuSB.Updater.csproj b/MikuSB.Updater/MikuSB.Updater.csproj new file mode 100644 index 0000000..193d481 --- /dev/null +++ b/MikuSB.Updater/MikuSB.Updater.csproj @@ -0,0 +1,12 @@ + + + + Exe + net9.0 + enable + enable + MikuSB.Updater + MikuSB.Updater + + + diff --git a/MikuSB.Updater/Program.cs b/MikuSB.Updater/Program.cs new file mode 100644 index 0000000..e3a5801 --- /dev/null +++ b/MikuSB.Updater/Program.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; +using System.IO.Compression; + +var argsMap = ParseArgs(args); +if (!argsMap.TryGetValue("--package", out var packagePath) + || !argsMap.TryGetValue("--target", out var targetDirectory) + || !argsMap.TryGetValue("--restart", out var restartExecutable)) +{ + Console.Error.WriteLine("Missing required arguments."); + return 1; +} + +argsMap.TryGetValue("--pid", out var pidValue); + +try +{ + if (int.TryParse(pidValue, out var pid)) + WaitForExit(pid); + + var stagingDirectory = Path.Combine(Path.GetTempPath(), "MikuSB", "staging", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(stagingDirectory); + + ZipFile.ExtractToDirectory(packagePath, stagingDirectory, overwriteFiles: true); + CopyDirectory(stagingDirectory, targetDirectory); + + Process.Start(new ProcessStartInfo + { + FileName = restartExecutable, + UseShellExecute = false, + WorkingDirectory = targetDirectory + }); + + return 0; +} +catch (Exception ex) +{ + Console.Error.WriteLine(ex); + return 1; +} + +static Dictionary ParseArgs(string[] args) +{ + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < args.Length - 1; i += 2) + { + result[args[i]] = args[i + 1]; + } + + return result; +} + +static void WaitForExit(int pid) +{ + try + { + using var process = Process.GetProcessById(pid); + process.WaitForExit(30000); + } + catch + { + } +} + +static void CopyDirectory(string sourceDirectory, string targetDirectory) +{ + foreach (var directory in Directory.GetDirectories(sourceDirectory, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(sourceDirectory, directory); + if (ShouldSkip(relativePath)) + continue; + + Directory.CreateDirectory(Path.Combine(targetDirectory, relativePath)); + } + + foreach (var file in Directory.GetFiles(sourceDirectory, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(sourceDirectory, file); + if (ShouldSkip(relativePath)) + continue; + + var destinationPath = Path.Combine(targetDirectory, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + File.Copy(file, destinationPath, overwrite: true); + } +} + +static bool ShouldSkip(string relativePath) +{ + return relativePath.StartsWith("Config", StringComparison.OrdinalIgnoreCase); +} diff --git a/MikuSB.sln b/MikuSB.sln index 556d819..8e61100 100644 --- a/MikuSB.sln +++ b/MikuSB.sln @@ -22,6 +22,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TcpSharp", "TcpSharp\TcpSha EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Proxy", "Proxy\Proxy.csproj", "{B3C4D5E6-F7A8-9012-BCDE-F12345678901}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MikuSB.Updater", "MikuSB.Updater\MikuSB.Updater.csproj", "{CE0F3A4B-8C55-4A31-A1B5-A0CB1C7F0A11}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -56,6 +58,10 @@ Global {B3C4D5E6-F7A8-9012-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU {B3C4D5E6-F7A8-9012-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU {B3C4D5E6-F7A8-9012-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU + {CE0F3A4B-8C55-4A31-A1B5-A0CB1C7F0A11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE0F3A4B-8C55-4A31-A1B5-A0CB1C7F0A11}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE0F3A4B-8C55-4A31-A1B5-A0CB1C7F0A11}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE0F3A4B-8C55-4A31-A1B5-A0CB1C7F0A11}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/MikuSB/MikuSB.csproj b/MikuSB/MikuSB.csproj index c438f00..49490b6 100644 --- a/MikuSB/MikuSB.csproj +++ b/MikuSB/MikuSB.csproj @@ -18,4 +18,45 @@ + + + + + + + <_UpdaterBuildArtifacts Include="..\MikuSB.Updater\bin\$(Configuration)\$(TargetFramework)\MikuSB.Updater.exe" /> + <_UpdaterBuildArtifacts Include="..\MikuSB.Updater\bin\$(Configuration)\$(TargetFramework)\MikuSB.Updater.dll" /> + <_UpdaterBuildArtifacts Include="..\MikuSB.Updater\bin\$(Configuration)\$(TargetFramework)\MikuSB.Updater.deps.json" /> + <_UpdaterBuildArtifacts Include="..\MikuSB.Updater\bin\$(Configuration)\$(TargetFramework)\MikuSB.Updater.runtimeconfig.json" /> + + + + + + + + <_UpdaterPublishDir>$(PublishDir)updater-publish\ + + + + + + + + + diff --git a/MikuSB/Program/LoaderManager.cs b/MikuSB/Program/LoaderManager.cs index d940ea5..a6044f4 100644 --- a/MikuSB/Program/LoaderManager.cs +++ b/MikuSB/Program/LoaderManager.cs @@ -46,6 +46,7 @@ public class LoaderManager : MikuSB // Starting the server Logger.Info(I18NManager.Translate("Server.ServerInfo.StartingServer")); + Logger.Info($"Build version: {BuildVersion.Current}"); // Load the config Logger.Info(I18NManager.Translate("Server.ServerInfo.LoadingItem", I18NManager.Translate("Word.Config"))); diff --git a/MikuSB/Program/MikuSB.cs b/MikuSB/Program/MikuSB.cs index dd39b04..d20e124 100644 --- a/MikuSB/Program/MikuSB.cs +++ b/MikuSB/Program/MikuSB.cs @@ -4,6 +4,7 @@ using MikuSB.MikuSB.Tool; using MikuSB.GameServer.Command; using MikuSB.GameServer.Server; using MikuSB.Internationalization; +using MikuSB.MikuSB.Update; using MikuSB.TcpSharp; using MikuSB.Util; using System.Globalization; @@ -20,9 +21,12 @@ public class MikuSB public static async Task Main() { var time = DateTime.Now; - RegisterExitEvent(); IConsole.InitConsole(); LoaderManager.InitConfig(); + if (await UpdateService.TryStartSelfUpdateAsync()) + return; + + RegisterExitEvent(); await LoaderManager.InitSdkServer(); LoaderManager.InitPacket(); diff --git a/MikuSB/Update/UpdateService.cs b/MikuSB/Update/UpdateService.cs new file mode 100644 index 0000000..3a1561b --- /dev/null +++ b/MikuSB/Update/UpdateService.cs @@ -0,0 +1,229 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; +using MikuSB.Util; + +namespace MikuSB.MikuSB.Update; + +public static class UpdateService +{ + private static readonly Logger Logger = new("Updater"); + private static readonly bool UpdateEnabled = true; + private static readonly bool AskBeforeUpdate = true; + private static readonly string RepositoryOwner = "DevilProMT"; + private static readonly string RepositoryName = "MikuSB"; + private static readonly string AssetName = "MikuSB-win-x64.zip"; + private static readonly int TimeoutSeconds = 5; + + public static async Task TryStartSelfUpdateAsync() + { + if (!UpdateEnabled) + return false; + + if (string.IsNullOrWhiteSpace(RepositoryOwner) + || string.IsNullOrWhiteSpace(RepositoryName) + || string.IsNullOrWhiteSpace(AssetName)) + { + Logger.Debug("Auto update skipped because the GitHub release source is not configured."); + return false; + } + + var updaterPath = Path.Combine(AppContext.BaseDirectory, "MikuSB.Updater.exe"); + if (!File.Exists(updaterPath)) + { + Logger.Debug("Auto update skipped because MikuSB.Updater.exe was not found."); + return false; + } + + try + { + Logger.Info($"Current build version: {BuildVersion.Current}"); + + using var client = CreateHttpClient(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(Math.Max(1, TimeoutSeconds))); + var release = await GetLatestReleaseAsync(client, cts.Token); + if (release == null) + return false; + + var latestVersion = BuildVersion.Normalize(release.TagName); + if (!BuildVersion.IsNewer(latestVersion, BuildVersion.Current)) + return false; + + var asset = release.Assets.FirstOrDefault(x => + string.Equals(x.Name, AssetName, StringComparison.OrdinalIgnoreCase)); + if (asset == null) + { + Logger.Warn($"Latest release {release.TagName} does not contain asset {AssetName}."); + return false; + } + + if (AskBeforeUpdate && !ConfirmUpdate(latestVersion)) + { + Logger.Info($"Skipped update {latestVersion} by user choice."); + return false; + } + + var tempRoot = Path.Combine(Path.GetTempPath(), "MikuSB", "updates", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + var packagePath = Path.Combine(tempRoot, asset.Name); + Logger.Info($"Downloading update {release.TagName}."); + await DownloadFileAsync(client, asset.DownloadUrl, packagePath, cts.Token); + + var checksumAsset = release.Assets.FirstOrDefault(x => + string.Equals(x.Name, AssetName + ".sha256", StringComparison.OrdinalIgnoreCase)); + if (checksumAsset != null) + { + var checksumPath = Path.Combine(tempRoot, checksumAsset.Name); + await DownloadFileAsync(client, checksumAsset.DownloadUrl, checksumPath, cts.Token); + VerifySha256(packagePath, checksumPath); + } + + var stagedUpdaterPath = StageUpdaterExecutable(); + var process = Process.Start(new ProcessStartInfo + { + FileName = stagedUpdaterPath, + UseShellExecute = false, + WorkingDirectory = Path.GetDirectoryName(stagedUpdaterPath)!, + ArgumentList = + { + "--package", packagePath, + "--target", AppContext.BaseDirectory, + "--restart", Path.Combine(AppContext.BaseDirectory, "MikuSB.exe"), + "--pid", Environment.ProcessId.ToString() + } + }); + + if (process == null) + { + Logger.Warn("Failed to start MikuSB.Updater.exe."); + return false; + } + + Logger.Warn($"Update {latestVersion} found. Handing over to updater and shutting down."); + return true; + } + catch (Exception ex) + { + Logger.Warn("Auto update check failed. Continuing normal startup.", ex); + return false; + } + } + + private static string StageUpdaterExecutable() + { + var sourceDirectory = AppContext.BaseDirectory; + var stagingDirectory = Path.Combine(Path.GetTempPath(), "MikuSB", "updater", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(stagingDirectory); + + foreach (var sourcePath in Directory.EnumerateFiles(sourceDirectory, "MikuSB.Updater*", SearchOption.TopDirectoryOnly)) + { + var destinationPath = Path.Combine(stagingDirectory, Path.GetFileName(sourcePath)); + File.Copy(sourcePath, destinationPath, overwrite: true); + } + + var stagedUpdaterPath = Path.Combine(stagingDirectory, "MikuSB.Updater.exe"); + if (!File.Exists(stagedUpdaterPath)) + throw new FileNotFoundException("Failed to stage MikuSB.Updater.exe.", stagedUpdaterPath); + + return stagedUpdaterPath; + } + + private static bool ConfirmUpdate(string latestVersion) + { + Console.Write($"New version found: {BuildVersion.Current} -> {latestVersion}. Update now? [Y/n]: "); + + try + { + var key = Console.ReadKey(intercept: true); + Console.WriteLine(); + return key.Key is ConsoleKey.Enter or ConsoleKey.Y; + } + catch + { + Console.WriteLine(); + return false; + } + } + + private static HttpClient CreateHttpClient() + { + var client = new HttpClient + { + Timeout = TimeSpan.FromSeconds(Math.Max(1, TimeoutSeconds)) + }; + + client.DefaultRequestHeaders.UserAgent.Add( + new ProductInfoHeaderValue("MikuSB-Updater", BuildVersion.Current)); + client.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + + return client; + } + + private static async Task GetLatestReleaseAsync( + HttpClient client, + CancellationToken cancellationToken) + { + var requestUri = + $"https://api.github.com/repos/{RepositoryOwner}/{RepositoryName}/releases/latest"; + using var response = await client.GetAsync(requestUri, cancellationToken); + + if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + Logger.Warn("Latest GitHub release is not accessible. This is expected while the repository remains private."); + return null; + } + + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonSerializer.DeserializeAsync(stream, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }, cancellationToken); + } + + private static async Task DownloadFileAsync( + HttpClient client, + string downloadUrl, + string destinationPath, + CancellationToken cancellationToken) + { + using var response = await client.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + await using var source = await response.Content.ReadAsStreamAsync(cancellationToken); + await using var destination = File.Create(destinationPath); + await source.CopyToAsync(destination, cancellationToken); + } + + private static void VerifySha256(string packagePath, string checksumPath) + { + var expected = File.ReadAllText(checksumPath).Split(' ', StringSplitOptions.RemoveEmptyEntries)[0].Trim(); + var actual = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(packagePath))) + .ToLowerInvariant(); + + if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Downloaded update package checksum does not match the release checksum."); + } +} + +public sealed class GitHubReleaseResponse +{ + [JsonPropertyName("tag_name")] + public string TagName { get; set; } = ""; + + [JsonPropertyName("assets")] + public List Assets { get; set; } = []; +} + +public sealed class GitHubReleaseAssetResponse +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("browser_download_url")] + public string DownloadUrl { get; set; } = ""; +} diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..c1cf6fe --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +v=0.1 \ No newline at end of file