mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-24 03:38:52 +08:00
GUI: add cross-platform release updater (#243)
This commit is contained in:
@@ -63,6 +63,11 @@ internal static partial class Program
|
|||||||
|
|
||||||
private static int Run(string[] args)
|
private static int Run(string[] args)
|
||||||
{
|
{
|
||||||
|
if (Updater.TryApply(args, out var updateExitCode))
|
||||||
|
{
|
||||||
|
return updateExitCode;
|
||||||
|
}
|
||||||
|
|
||||||
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
||||||
if (args.Length == 0 && !isMitigatedChild)
|
if (args.Length == 0 && !isMitigatedChild)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ public sealed class GuiSettings
|
|||||||
/// <summary>Publish launcher/game status to Discord Rich Presence.</summary>
|
/// <summary>Publish launcher/game status to Discord Rich Presence.</summary>
|
||||||
public bool DiscordRichPresence { get; set; } = true;
|
public bool DiscordRichPresence { get; set; } = true;
|
||||||
|
|
||||||
|
public bool CheckForUpdatesOnStartup { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
|
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
|
||||||
public List<string> EnvironmentToggles { get; set; } = new();
|
public List<string> EnvironmentToggles { get; set; } = new();
|
||||||
|
|
||||||
|
|||||||
@@ -144,5 +144,20 @@
|
|||||||
"About.Discord.Label": "Discord",
|
"About.Discord.Label": "Discord",
|
||||||
"About.Discord.Desc": "Join the community, get support and follow development.",
|
"About.Discord.Desc": "Join the community, get support and follow development.",
|
||||||
"About.GithubButton": "Contribute in GitHub!",
|
"About.GithubButton": "Contribute in GitHub!",
|
||||||
"About.DiscordButton": "Join our Discord!"
|
"About.DiscordButton": "Join our Discord!",
|
||||||
|
|
||||||
|
"Updater.Auto.Label": "Check for updates on startup",
|
||||||
|
"Updater.Auto.Desc": "Checks GitHub without delaying startup.",
|
||||||
|
"Updater.Label": "Updates",
|
||||||
|
"Updater.Check": "Check for updates",
|
||||||
|
"Updater.DownloadRestart": "Download and restart",
|
||||||
|
"Updater.Status.Ready": "Current build: {0}",
|
||||||
|
"Updater.Status.Checking": "Checking for updates…",
|
||||||
|
"Updater.Status.Current": "You are up to date ({0}).",
|
||||||
|
"Updater.Status.Available": "A new build is available: {0}",
|
||||||
|
"Updater.Status.Downloading": "Downloading update… {0}%",
|
||||||
|
"Updater.Status.Installing": "Installing update…",
|
||||||
|
"Updater.Status.Timeout": "Update check timed out after 10 seconds.",
|
||||||
|
"Updater.Status.Failed": "Could not check for updates.",
|
||||||
|
"Updater.Status.Unsupported": "Automatic updating requires a Windows, Linux or macOS x64 build."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,5 +125,20 @@
|
|||||||
"Dialog.PsExecutables": "PS çalıştırılabilirleri",
|
"Dialog.PsExecutables": "PS çalıştırılabilirleri",
|
||||||
"Dialog.SaveLogFile": "Günlük dosyasının kaydedileceği yeri seçin",
|
"Dialog.SaveLogFile": "Günlük dosyasının kaydedileceği yeri seçin",
|
||||||
"Dialog.PlainTextFiles": "Düz Metin Dosyaları",
|
"Dialog.PlainTextFiles": "Düz Metin Dosyaları",
|
||||||
"Dialog.LogFiles": "Günlük Dosyaları"
|
"Dialog.LogFiles": "Günlük Dosyaları",
|
||||||
|
|
||||||
|
"Updater.Auto.Label": "Açılışta güncellemeleri denetle",
|
||||||
|
"Updater.Auto.Desc": "Açılışı geciktirmeden GitHub'ı denetler.",
|
||||||
|
"Updater.Label": "Güncellemeler",
|
||||||
|
"Updater.Check": "Güncellemeleri denetle",
|
||||||
|
"Updater.DownloadRestart": "İndir ve yeniden başlat",
|
||||||
|
"Updater.Status.Ready": "Mevcut build: {0}",
|
||||||
|
"Updater.Status.Checking": "Güncellemeler denetleniyor…",
|
||||||
|
"Updater.Status.Current": "Güncelsiniz ({0}).",
|
||||||
|
"Updater.Status.Available": "Yeni build mevcut: {0}",
|
||||||
|
"Updater.Status.Downloading": "Güncelleme indiriliyor… %{0}",
|
||||||
|
"Updater.Status.Installing": "Güncelleme kuruluyor…",
|
||||||
|
"Updater.Status.Timeout": "Güncelleme denetimi 10 saniye sonra zaman aşımına uğradı.",
|
||||||
|
"Updater.Status.Failed": "Güncellemeler denetlenemedi.",
|
||||||
|
"Updater.Status.Unsupported": "Otomatik güncelleme Windows, Linux veya macOS x64 build'i gerektirir."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,6 +315,16 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<ToggleSwitch Grid.Column="1" x:Name="DiscordToggle" OnContent="On" OffContent="Off"
|
<ToggleSwitch Grid.Column="1" x:Name="DiscordToggle" OnContent="On" OffContent="Off"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||||
|
<TextBlock x:Name="AutoUpdateLabel" Text="Check for updates on startup" FontSize="13" />
|
||||||
|
<TextBlock x:Name="AutoUpdateDesc" Text="Checks GitHub without delaying startup."
|
||||||
|
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
<ToggleSwitch Grid.Column="1" x:Name="AutoUpdateToggle" OnContent="On" OffContent="Off"
|
||||||
|
IsChecked="True" VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
<Border Classes="card">
|
<Border Classes="card">
|
||||||
@@ -322,6 +332,16 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<TextBlock x:Name="AboutSectionTitle"
|
<TextBlock x:Name="AboutSectionTitle"
|
||||||
Classes="sectionTitle"
|
Classes="sectionTitle"
|
||||||
Text="ABOUT" />
|
Text="ABOUT" />
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
||||||
|
<TextBlock x:Name="UpdateLabel" Text="Updates" FontSize="13" />
|
||||||
|
<TextBlock x:Name="UpdateStatusText" Text="Current build: dev" FontSize="11"
|
||||||
|
Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" x:Name="UpdateButton" Classes="ghost"
|
||||||
|
Content="Check for updates" VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!--Github-->
|
<!--Github-->
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ public partial class MainWindow : Window
|
|||||||
private bool _isRunning;
|
private bool _isRunning;
|
||||||
private int _autoScrollTicks;
|
private int _autoScrollTicks;
|
||||||
private int _activePageIndex;
|
private int _activePageIndex;
|
||||||
|
private Updater.UpdateInfo? _availableUpdate;
|
||||||
|
private string _updateStatusKey = "Updater.Status.Ready";
|
||||||
|
private object?[] _updateStatusArgs = [BuildInfo.CommitSha ?? "dev"];
|
||||||
|
|
||||||
// Discord Rich Presence state.
|
// Discord Rich Presence state.
|
||||||
private readonly long _launcherStartUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
private readonly long _launcherStartUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
@@ -131,6 +134,9 @@ public partial class MainWindow : Window
|
|||||||
_settings.DiscordRichPresence = DiscordToggle.IsChecked == true;
|
_settings.DiscordRichPresence = DiscordToggle.IsChecked == true;
|
||||||
UpdateDiscordPresence();
|
UpdateDiscordPresence();
|
||||||
};
|
};
|
||||||
|
AutoUpdateToggle.IsCheckedChanged += (_, _) =>
|
||||||
|
_settings.CheckForUpdatesOnStartup = AutoUpdateToggle.IsChecked == true;
|
||||||
|
UpdateButton.Click += async (_, _) => await OnUpdateButtonAsync();
|
||||||
SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync();
|
SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync();
|
||||||
EnvBthidToggle.IsCheckedChanged += (_, _) =>
|
EnvBthidToggle.IsCheckedChanged += (_, _) =>
|
||||||
SetEnvironmentToggle("SHARPEMU_BTHID_UNAVAILABLE", EnvBthidToggle.IsChecked == true);
|
SetEnvironmentToggle("SHARPEMU_BTHID_UNAVAILABLE", EnvBthidToggle.IsChecked == true);
|
||||||
@@ -375,6 +381,10 @@ public partial class MainWindow : Window
|
|||||||
ApplySettingsToControls();
|
ApplySettingsToControls();
|
||||||
LocateEmulator();
|
LocateEmulator();
|
||||||
UpdateDiscordPresence();
|
UpdateDiscordPresence();
|
||||||
|
if (_settings.CheckForUpdatesOnStartup)
|
||||||
|
{
|
||||||
|
_ = CheckForUpdatesAsync();
|
||||||
|
}
|
||||||
await RescanLibraryAsync();
|
await RescanLibraryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,8 +488,10 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
DiscordLabel.Text = loc.Get("Options.Discord.Label");
|
DiscordLabel.Text = loc.Get("Options.Discord.Label");
|
||||||
DiscordDesc.Text = loc.Get("Options.Discord.Desc");
|
DiscordDesc.Text = loc.Get("Options.Discord.Desc");
|
||||||
|
AutoUpdateLabel.Text = loc.Get("Updater.Auto.Label");
|
||||||
|
AutoUpdateDesc.Text = loc.Get("Updater.Auto.Desc");
|
||||||
|
|
||||||
foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle })
|
foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle, AutoUpdateToggle })
|
||||||
{
|
{
|
||||||
toggle.OnContent = loc.Get("Common.On");
|
toggle.OnContent = loc.Get("Common.On");
|
||||||
toggle.OffContent = loc.Get("Common.Off");
|
toggle.OffContent = loc.Get("Common.Off");
|
||||||
@@ -503,6 +515,8 @@ public partial class MainWindow : Window
|
|||||||
DiscordServerDesc.Text = loc.Get("About.Discord.Desc");
|
DiscordServerDesc.Text = loc.Get("About.Discord.Desc");
|
||||||
GithubButton.Content = loc.Get("About.GithubButton");
|
GithubButton.Content = loc.Get("About.GithubButton");
|
||||||
DiscordButton.Content = loc.Get("About.DiscordButton");
|
DiscordButton.Content = loc.Get("About.DiscordButton");
|
||||||
|
UpdateLabel.Text = loc.Get("Updater.Label");
|
||||||
|
RefreshUpdateText();
|
||||||
|
|
||||||
UpdateEmptyStateTexts();
|
UpdateEmptyStateTexts();
|
||||||
UpdateSelectedGameTexts();
|
UpdateSelectedGameTexts();
|
||||||
@@ -618,6 +632,7 @@ public partial class MainWindow : Window
|
|||||||
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
|
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
|
||||||
TitleMusicToggle.IsChecked = _settings.PlayTitleMusic;
|
TitleMusicToggle.IsChecked = _settings.PlayTitleMusic;
|
||||||
DiscordToggle.IsChecked = _settings.DiscordRichPresence;
|
DiscordToggle.IsChecked = _settings.DiscordRichPresence;
|
||||||
|
AutoUpdateToggle.IsChecked = _settings.CheckForUpdatesOnStartup;
|
||||||
EnvBthidToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_BTHID_UNAVAILABLE");
|
EnvBthidToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_BTHID_UNAVAILABLE");
|
||||||
EnvLoopGuardToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_DISABLE_IMPORT_LOOP_GUARD");
|
EnvLoopGuardToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_DISABLE_IMPORT_LOOP_GUARD");
|
||||||
EnvWritableApp0Toggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_WRITABLE_APP0");
|
EnvWritableApp0Toggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_WRITABLE_APP0");
|
||||||
@@ -629,6 +644,75 @@ public partial class MainWindow : Window
|
|||||||
UpdateLogFilePathText();
|
UpdateLogFilePathText();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task OnUpdateButtonAsync()
|
||||||
|
{
|
||||||
|
if (_availableUpdate is null)
|
||||||
|
{
|
||||||
|
await CheckForUpdatesAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateButton.IsEnabled = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var progress = new Progress<int>(value =>
|
||||||
|
SetUpdateStatus("Updater.Status.Downloading", value));
|
||||||
|
await Updater.DownloadAndRestartAsync(_availableUpdate, progress);
|
||||||
|
SetUpdateStatus("Updater.Status.Installing");
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
SetUpdateStatus("Updater.Status.Failed");
|
||||||
|
UpdateButton.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CheckForUpdatesAsync()
|
||||||
|
{
|
||||||
|
_availableUpdate = null;
|
||||||
|
UpdateButton.IsEnabled = false;
|
||||||
|
SetUpdateStatus("Updater.Status.Checking");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_availableUpdate = await Updater.CheckAsync(BuildInfo.CommitSha);
|
||||||
|
SetUpdateStatus(
|
||||||
|
_availableUpdate is null ? "Updater.Status.Current" : "Updater.Status.Available",
|
||||||
|
_availableUpdate?.Sha ?? BuildInfo.CommitSha ?? "dev");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
SetUpdateStatus("Updater.Status.Timeout");
|
||||||
|
}
|
||||||
|
catch (PlatformNotSupportedException)
|
||||||
|
{
|
||||||
|
SetUpdateStatus("Updater.Status.Unsupported");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
SetUpdateStatus("Updater.Status.Failed");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UpdateButton.IsEnabled = true;
|
||||||
|
RefreshUpdateText();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetUpdateStatus(string key, params object?[] args)
|
||||||
|
{
|
||||||
|
_updateStatusKey = key;
|
||||||
|
_updateStatusArgs = args;
|
||||||
|
RefreshUpdateText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshUpdateText()
|
||||||
|
{
|
||||||
|
UpdateStatusText.Text = Localization.Instance.Format(_updateStatusKey, _updateStatusArgs);
|
||||||
|
UpdateButton.Content = Localization.Instance.Get(
|
||||||
|
_availableUpdate is null ? "Updater.Check" : "Updater.DownloadRestart");
|
||||||
|
}
|
||||||
|
|
||||||
// Environment variables set on this process at the previous launch; children
|
// Environment variables set on this process at the previous launch; children
|
||||||
// inherit the process environment, so stale names must be cleared explicitly.
|
// inherit the process environment, so stale names must be cleared explicitly.
|
||||||
private readonly HashSet<string> _appliedEnvironmentVariables = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _appliedEnvironmentVariables = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Formats.Tar;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
|
/// <summary>Self-contained Windows updater; the emulator layers do not depend on it.</summary>
|
||||||
|
public static class Updater
|
||||||
|
{
|
||||||
|
private const string ApplyArgument = "--sharpemu-apply-update";
|
||||||
|
private const string LatestReleaseUrl = "https://api.github.com/repos/sharpemu/sharpemu/releases/latest";
|
||||||
|
private static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
private static readonly HttpClient Http = CreateHttpClient();
|
||||||
|
|
||||||
|
public sealed record UpdateInfo(string Sha, string Name, string DownloadUrl, long Size);
|
||||||
|
|
||||||
|
public static async Task<UpdateInfo?> CheckAsync(string? currentSha, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var platform = CurrentPlatform();
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeout.CancelAfter(CheckTimeout);
|
||||||
|
|
||||||
|
using var response = await Http.GetAsync(LatestReleaseUrl, timeout.Token);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return ParseRelease(
|
||||||
|
await response.Content.ReadAsStringAsync(timeout.Token),
|
||||||
|
currentSha,
|
||||||
|
platform.Rid,
|
||||||
|
platform.Extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task DownloadAndRestartAsync(
|
||||||
|
UpdateInfo update,
|
||||||
|
IProgress<int>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), "SharpEmu.Update");
|
||||||
|
var payload = Path.Combine(root, "payload");
|
||||||
|
if (Directory.Exists(root))
|
||||||
|
{
|
||||||
|
Directory.Delete(root, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(root);
|
||||||
|
var archive = Path.Combine(root, update.Name);
|
||||||
|
using (var response = await Http.GetAsync(update.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
|
||||||
|
{
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
await using var input = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
await using var output = File.Create(archive);
|
||||||
|
var buffer = new byte[81920];
|
||||||
|
long written = 0;
|
||||||
|
int read;
|
||||||
|
while ((read = await input.ReadAsync(buffer, cancellationToken)) > 0)
|
||||||
|
{
|
||||||
|
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
|
||||||
|
written += read;
|
||||||
|
progress?.Report(update.Size == 0 ? 0 : (int)(written * 100 / update.Size));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (written != update.Size)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException($"Downloaded {written} bytes; expected {update.Size}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var platform = CurrentPlatform();
|
||||||
|
var stagedExe = ExtractArchive(archive, payload, platform.Extension, platform.ExecutableName);
|
||||||
|
|
||||||
|
var start = new ProcessStartInfo(stagedExe)
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = payload,
|
||||||
|
};
|
||||||
|
start.ArgumentList.Add(ApplyArgument);
|
||||||
|
start.ArgumentList.Add(Environment.ProcessId.ToString());
|
||||||
|
start.ArgumentList.Add(AppContext.BaseDirectory);
|
||||||
|
using var helper = Process.Start(start)
|
||||||
|
?? throw new InvalidOperationException("The update installer could not be started.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs from the downloaded executable after the old GUI exits.</summary>
|
||||||
|
public static bool TryApply(string[] args, out int exitCode)
|
||||||
|
{
|
||||||
|
exitCode = 0;
|
||||||
|
if (args.Length != 3 || args[0] != ApplyArgument)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (int.TryParse(args[1], out var oldPid))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Process.GetProcessById(oldPid).WaitForExit(30_000))
|
||||||
|
{
|
||||||
|
throw new TimeoutException("SharpEmu did not close within 30 seconds.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
// The old process has already exited.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var source = AppContext.BaseDirectory;
|
||||||
|
var target = Path.GetFullPath(args[2]);
|
||||||
|
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
var relative = Path.GetRelativePath(source, file);
|
||||||
|
if (relative.Equals("gui-settings.json", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
relative.StartsWith("user" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
relative.StartsWith("logs" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
relative.StartsWith("Languages" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var destination = Path.Combine(target, relative);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
||||||
|
File.Copy(file, destination, overwrite: true);
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
File.SetUnixFileMode(destination, File.GetUnixFileMode(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using var restarted = Process.Start(new ProcessStartInfo(
|
||||||
|
Path.Combine(target, CurrentPlatform().ExecutableName))
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = target,
|
||||||
|
}) ?? throw new InvalidOperationException("The updated SharpEmu could not be started.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
exitCode = 1;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(args[2], "update-error.log"), ex.ToString());
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort diagnostics only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UpdateInfo? ParseRelease(
|
||||||
|
string json,
|
||||||
|
string? currentSha,
|
||||||
|
string rid,
|
||||||
|
string extension)
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
var candidates = new List<(DateTimeOffset Created, UpdateInfo Update)>();
|
||||||
|
foreach (var asset in document.RootElement.GetProperty("assets").EnumerateArray())
|
||||||
|
{
|
||||||
|
var name = asset.GetProperty("name").GetString() ?? "";
|
||||||
|
var marker = $"-{rid}-";
|
||||||
|
var markerIndex = name.LastIndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (!name.EndsWith(extension, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
markerIndex < 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sha = name[(markerIndex + marker.Length)..^extension.Length];
|
||||||
|
if (sha.Length < 7 || !sha.All(Uri.IsHexDigit))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.Add((
|
||||||
|
asset.GetProperty("created_at").GetDateTimeOffset(),
|
||||||
|
new UpdateInfo(
|
||||||
|
sha,
|
||||||
|
name,
|
||||||
|
asset.GetProperty("browser_download_url").GetString()!,
|
||||||
|
asset.GetProperty("size").GetInt64())));
|
||||||
|
}
|
||||||
|
|
||||||
|
var latest = candidates.OrderByDescending(candidate => candidate.Created).FirstOrDefault().Update;
|
||||||
|
return latest is null || string.Equals(latest.Sha, currentSha, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? null
|
||||||
|
: latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractArchive(
|
||||||
|
string archive,
|
||||||
|
string payload,
|
||||||
|
string extension,
|
||||||
|
string executableName)
|
||||||
|
{
|
||||||
|
if (extension == ".zip")
|
||||||
|
{
|
||||||
|
ZipFile.ExtractToDirectory(archive, payload);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(payload);
|
||||||
|
using var compressed = File.OpenRead(archive);
|
||||||
|
using var gzip = new GZipStream(compressed, CompressionMode.Decompress);
|
||||||
|
TarFile.ExtractToDirectory(gzip, payload, overwriteFiles: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var executable = Path.Combine(payload, executableName);
|
||||||
|
if (!File.Exists(executable))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException($"The update archive does not contain {executableName}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
File.SetUnixFileMode(executable, File.GetUnixFileMode(executable) | UnixFileMode.UserExecute);
|
||||||
|
}
|
||||||
|
|
||||||
|
return executable;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient CreateHttpClient()
|
||||||
|
{
|
||||||
|
var client = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
|
||||||
|
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("SharpEmu", "0.0.1"));
|
||||||
|
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlatformInfo CurrentPlatform()
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.ProcessArchitecture != Architecture.X64)
|
||||||
|
{
|
||||||
|
throw new PlatformNotSupportedException("SharpEmu releases require an x64 process.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows()) return new("win-x64", ".zip", "SharpEmu.exe");
|
||||||
|
if (OperatingSystem.IsLinux()) return new("linux-x64", ".tar.gz", "SharpEmu");
|
||||||
|
if (OperatingSystem.IsMacOS()) return new("osx-x64", ".tar.gz", "SharpEmu");
|
||||||
|
throw new PlatformNotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PlatformInfo(string Rid, string Extension, string ExecutableName);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user