MikuSB.Updater

This commit is contained in:
Kei-Luna
2026-04-27 15:35:05 +09:00
parent 4bf3f0d715
commit 05925dacfb
13 changed files with 517 additions and 2 deletions

56
.github/workflows/release.yml vendored Normal file
View File

@@ -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

View File

@@ -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.*;<local>";
}
}

View File

@@ -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<AssemblyInformationalVersionAttribute>()?.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]);
}
}

5
Directory.Build.props Normal file
View File

@@ -0,0 +1,5 @@
<Project>
<PropertyGroup>
<GenerateAssemblyInformationalVersionAttribute>false</GenerateAssemblyInformationalVersionAttribute>
</PropertyGroup>
</Project>

21
Directory.Build.targets Normal file
View File

@@ -0,0 +1,21 @@
<Project>
<Target Name="GenerateBuildVersionInfo" BeforeTargets="BeforeCompile">
<ReadLinesFromFile File="$(MSBuildThisFileDirectory)version.txt">
<Output TaskParameter="Lines" ItemName="_BuildVersionLines" />
</ReadLinesFromFile>
<PropertyGroup>
<_BuildVersionRaw>@(_BuildVersionLines->'%(Identity)', '')</_BuildVersionRaw>
<_BuildVersion>$([System.String]::Copy('$(_BuildVersionRaw)').Replace('v=', '').Trim())</_BuildVersion>
</PropertyGroup>
<WriteLinesToFile
File="$(IntermediateOutputPath)BuildVersion.g.cs"
Overwrite="true"
Lines="// &lt;auto-generated/&gt;&#x0A;[assembly: System.Reflection.AssemblyInformationalVersionAttribute(&quot;$(_BuildVersion)&quot;)]" />
<ItemGroup>
<Compile Include="$(IntermediateOutputPath)BuildVersion.g.cs" Visible="false" />
</ItemGroup>
</Target>
</Project>

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>MikuSB.Updater</AssemblyName>
<RootNamespace>MikuSB.Updater</RootNamespace>
</PropertyGroup>
</Project>

90
MikuSB.Updater/Program.cs Normal file
View File

@@ -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<string, string> ParseArgs(string[] args)
{
var result = new Dictionary<string, string>(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);
}

View File

@@ -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

View File

@@ -18,4 +18,45 @@
<ProjectReference Include="..\SdkServer\SdkServer.csproj" />
</ItemGroup>
<Target Name="BuildBundledUpdater" BeforeTargets="Build">
<MSBuild
Projects="..\MikuSB.Updater\MikuSB.Updater.csproj"
Targets="Build"
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework)" />
</Target>
<Target Name="CopyUpdaterAfterBuild" AfterTargets="Build">
<ItemGroup>
<_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" />
</ItemGroup>
<Copy
SourceFiles="@(_UpdaterBuildArtifacts)"
DestinationFolder="$(OutDir)"
SkipUnchangedFiles="true"
Condition="Exists('%(_UpdaterBuildArtifacts.Identity)')" />
</Target>
<Target Name="PublishBundledUpdater" AfterTargets="Publish">
<PropertyGroup>
<_UpdaterPublishDir>$(PublishDir)updater-publish\</_UpdaterPublishDir>
</PropertyGroup>
<MSBuild
Projects="..\MikuSB.Updater\MikuSB.Updater.csproj"
Targets="Restore;Publish"
RemoveProperties="PublishProfile"
Properties="Configuration=$(Configuration);RuntimeIdentifier=$(RuntimeIdentifier);SelfContained=true;PublishSingleFile=true;PublishDir=$(_UpdaterPublishDir)" />
<Copy
SourceFiles="$(_UpdaterPublishDir)MikuSB.Updater.exe"
DestinationFiles="$(PublishDir)MikuSB.Updater.exe"
Condition="Exists('$(_UpdaterPublishDir)MikuSB.Updater.exe')" />
<RemoveDir Directories="$(_UpdaterPublishDir)" Condition="Exists('$(_UpdaterPublishDir)')" />
</Target>
</Project>

View File

@@ -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")));

View File

@@ -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();

View File

@@ -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<bool> 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<GitHubReleaseResponse?> 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<GitHubReleaseResponse>(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<GitHubReleaseAssetResponse> Assets { get; set; } = [];
}
public sealed class GitHubReleaseAssetResponse
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("browser_download_url")]
public string DownloadUrl { get; set; } = "";
}

1
version.txt Normal file
View File

@@ -0,0 +1 @@
v=0.1