v1.1.5 — self-hosted auto-updater + flicker-free startup update prompt
- Updater now queries the project's own update server instead of GitLab/GitHub - Update install hardened: retry on transient file lock (antivirus), install on the UI thread, and a guarded single-thread fallback that can't hard-crash - Startup update prompt renders inline on the main window when no game is running, removing the cold-start dialog flicker (the separate overlay window is only used while a game is running, where it is required above the native render surface) - Drop the startup wait band-aid; keep a one-shot guard so the prompt shows once - Changelog links point at the Beast Roofer Gitea releases
This commit is contained in:
@@ -28,9 +28,7 @@ namespace Ryujinx.Common
|
||||
public static string Version => IsValid ? BuildVersion : Assembly.GetEntryAssembly()!.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||||
|
||||
public static string GetChangelogUrl(Version currentVersion, Version newVersion) =>
|
||||
IsCanaryBuild
|
||||
? $"https://git.ryujinx.app/projects/Ryubing/compare/Canary-{currentVersion}...Canary-{newVersion}"
|
||||
: $"https://git.ryujinx.app/projects/Ryubing/releases/tag/{newVersion}";
|
||||
$"https://thebeastroofer-edition.com/rooferdev/the-beast-roofer-edition/releases/tag/v{newVersion}";
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,58 +1,35 @@
|
||||
using Gommon;
|
||||
// The Beast Roofer Edition -- update check.
|
||||
// Original Ryubing flow talked to their UpdateServer via a client library.
|
||||
// This fork talks to our OWN update server (server.py) with a simple JSON
|
||||
// response: { "version": "...", "url": "...", "notes": "..." }.
|
||||
|
||||
using Gommon;
|
||||
using Ryujinx.Ava.Common.Locale;
|
||||
using Ryujinx.Ava.UI.Helpers;
|
||||
using Ryujinx.Common;
|
||||
using Ryujinx.Common.Helper;
|
||||
using Ryujinx.Common.Logging;
|
||||
using Ryujinx.Systems.Update.Client;
|
||||
using Ryujinx.Systems.Update.Common;
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Ryujinx.Ava.Systems
|
||||
{
|
||||
internal static partial class Updater
|
||||
{
|
||||
private static VersionResponse _versionResponse;
|
||||
private static UpdateClient _updateClient;
|
||||
// Our own update server (fronted by Caddy on the Beast Roofer host).
|
||||
private const string UpdateEndpoint = "https://thebeastroofer-edition.com/updates/latest";
|
||||
|
||||
private static async Task<Return<VersionResponse>> QueryLatestVersionAsync()
|
||||
// The download URL of the latest build artifact, set by CheckVersionAsync.
|
||||
private static string _artifactUrl;
|
||||
|
||||
// Minimal shape returned by server.py /latest.
|
||||
private sealed class BeastUpdateInfo
|
||||
{
|
||||
_updateClient ??= UpdateClient.Builder()
|
||||
.WithServerEndpoint("https://update.ryujinx.app") // This is the default, and doesn't need to be provided; it's here for transparency.
|
||||
.WithLogger((format, args, caller) =>
|
||||
Logger.Info?.Print(
|
||||
LogClass.Application,
|
||||
args.Length is 0 ? format : format.Format(args),
|
||||
caller: caller)
|
||||
);
|
||||
|
||||
try
|
||||
{
|
||||
return await _updateClient.QueryLatestAsync(ReleaseInformation.IsCanaryBuild
|
||||
? ReleaseChannel.Canary
|
||||
: ReleaseChannel.Stable);
|
||||
}
|
||||
catch (HttpRequestException hre)
|
||||
when (hre.HttpRequestError is HttpRequestError.ConnectionError)
|
||||
{
|
||||
return Return<VersionResponse>.Failure(
|
||||
new MessageError("Connection error occurred. Is your internet down?"));
|
||||
}
|
||||
catch (HttpRequestException hre)
|
||||
when (hre.HttpRequestError is HttpRequestError.NameResolutionError)
|
||||
{
|
||||
return Return<VersionResponse>.Failure(
|
||||
new MessageError("DNS resolution error occurred. Is your internet down?"));
|
||||
}
|
||||
catch (HttpRequestException hre)
|
||||
when (hre.StatusCode is HttpStatusCode.BadGateway)
|
||||
{
|
||||
return Return<VersionResponse>.Failure(
|
||||
new MessageError("Could not connect to the update server, but it appears like you have internet. It seems like the update server is offline, try again later."));
|
||||
}
|
||||
[JsonPropertyName("version")] public string Version { get; set; }
|
||||
[JsonPropertyName("url")] public string Url { get; set; }
|
||||
[JsonPropertyName("notes")] public string Notes { get; set; }
|
||||
}
|
||||
|
||||
public static async Task<Optional<(Version Current, Version Incoming)>> CheckVersionAsync(bool showVersionUpToDate = false)
|
||||
@@ -71,34 +48,33 @@ namespace Ryujinx.Ava.Systems
|
||||
return default;
|
||||
}
|
||||
|
||||
BeastUpdateInfo info;
|
||||
|
||||
try
|
||||
{
|
||||
_versionResponse = await QueryLatestVersionAsync().Then(x => x.Unwrap());
|
||||
using HttpClient client = ConstructHttpClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
info = await client.GetFromJsonAsync<BeastUpdateInfo>(UpdateEndpoint);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.Error?.Print(LogClass.Application, $"{e.GetType().AsPrettyString()} thrown when requesting updates: {e.Message}");
|
||||
Logger.Error?.Print(LogClass.Application,
|
||||
$"{e.GetType().AsPrettyString()} thrown when requesting updates: {e.Message}");
|
||||
|
||||
_running = false;
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
if (_versionResponse == null)
|
||||
{
|
||||
// logging is done via the UpdateClient library
|
||||
_running = false;
|
||||
return default;
|
||||
}
|
||||
|
||||
// If build URL not found, assume no new update is available.
|
||||
if (string.IsNullOrEmpty(_versionResponse.ArtifactUrl))
|
||||
// If no artifact URL was returned, assume no update is available.
|
||||
if (info == null || string.IsNullOrEmpty(info.Url))
|
||||
{
|
||||
if (showVersionUpToDate)
|
||||
{
|
||||
await ContentDialogHelper.CreateUpdaterUpToDateInfoDialog(
|
||||
LocaleManager.Instance[LocaleKeys.DialogUpdaterAlreadyOnLatestVersionMessage],
|
||||
string.Empty,
|
||||
_versionResponse.ReleaseUrlFormat.Format(currentVersion));
|
||||
string.Empty,
|
||||
ReleaseInformation.GetChangelogUrl(currentVersion, currentVersion));
|
||||
}
|
||||
|
||||
Logger.Info?.Print(LogClass.Application, "Up to date.");
|
||||
@@ -108,8 +84,7 @@ namespace Ryujinx.Ava.Systems
|
||||
return default;
|
||||
}
|
||||
|
||||
|
||||
if (!Version.TryParse(_versionResponse.Version, out Version newVersion))
|
||||
if (!Version.TryParse(info.Version, out Version newVersion))
|
||||
{
|
||||
Logger.Error?.Print(LogClass.Application,
|
||||
$"Failed to convert the received {RyujinxApp.FullAppName} version from the update server!");
|
||||
@@ -123,7 +98,10 @@ namespace Ryujinx.Ava.Systems
|
||||
return default;
|
||||
}
|
||||
|
||||
_connectionCount = (int)_versionResponse.MaxConcurrency;
|
||||
_artifactUrl = info.Url;
|
||||
|
||||
// Single download stream -- simplest and most reliable.
|
||||
_connectionCount = 1;
|
||||
|
||||
return (currentVersion, newVersion);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ namespace Ryujinx.Ava.Systems
|
||||
await ContentDialogHelper.CreateUpdaterUpToDateInfoDialog(
|
||||
LocaleManager.Instance[LocaleKeys.DialogUpdaterAlreadyOnLatestVersionMessage],
|
||||
string.Empty,
|
||||
changelogUrl: _versionResponse.ReleaseUrlFormat.Format(currentVersion));
|
||||
changelogUrl: ReleaseInformation.GetChangelogUrl(currentVersion, currentVersion));
|
||||
}
|
||||
|
||||
Logger.Info?.Print(LogClass.Application, "Up to date.");
|
||||
@@ -75,6 +75,10 @@ namespace Ryujinx.Ava.Systems
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
// Surface a persistent "update available" indicator in the status bar, so the
|
||||
// user can still grab this update later even if they dismiss this prompt.
|
||||
RyujinxApp.MainWindow.ViewModel.UpdateAvailable = true;
|
||||
|
||||
string newVersionString = ReleaseInformation.IsCanaryBuild
|
||||
? $"Canary {currentVersion} → Canary {newVersion}"
|
||||
: $"{currentVersion} → {newVersion}";
|
||||
@@ -91,7 +95,7 @@ namespace Ryujinx.Ava.Systems
|
||||
switch (shouldUpdate)
|
||||
{
|
||||
case UserResult.Yes:
|
||||
await UpdateRyujinx(_versionResponse.ArtifactUrl);
|
||||
await UpdateRyujinx(_artifactUrl);
|
||||
break;
|
||||
default:
|
||||
_running = false;
|
||||
@@ -150,7 +154,7 @@ namespace Ryujinx.Ava.Systems
|
||||
// Forgejo instance is located in Ukraine. Connection times will vary across the world.
|
||||
buildSizeClient.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
HttpResponseMessage message = await buildSizeClient.GetAsync(new Uri(_versionResponse.ArtifactUrl), HttpCompletionOption.ResponseHeadersRead);
|
||||
HttpResponseMessage message = await buildSizeClient.GetAsync(new Uri(_artifactUrl), HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
_buildSize = message.Content.Headers.ContentRange.Length.Value;
|
||||
}
|
||||
@@ -365,7 +369,7 @@ namespace Ryujinx.Ava.Systems
|
||||
|
||||
using HttpResponseMessage response = client.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead).Result;
|
||||
using Stream remoteFileStream = response.Content.ReadAsStreamAsync().Result;
|
||||
using FileStream updateFileStream = File.Open(updateFile, FileMode.Create);
|
||||
using FileStream updateFileStream = RetryOnTransientFileLock(() => File.Open(updateFile, FileMode.Create));
|
||||
|
||||
long totalBytes = response.Content.Headers.ContentLength.Value;
|
||||
long bytesWritten = 0;
|
||||
@@ -389,7 +393,11 @@ namespace Ryujinx.Ava.Systems
|
||||
updateFileStream.Write(buffer, 0, readSize);
|
||||
}
|
||||
|
||||
InstallUpdate(taskDialog, updateFile);
|
||||
// Install on the UI thread: InstallUpdate touches the task dialog, and
|
||||
// Avalonia UI calls must run on the UI thread. (The multi-threaded path
|
||||
// already runs InstallUpdate on the UI thread via its WebClient callback;
|
||||
// this keeps the single-threaded fallback consistent and crash-free.)
|
||||
Dispatcher.UIThread.Post(() => InstallUpdate(taskDialog, updateFile));
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
@@ -398,9 +406,57 @@ namespace Ryujinx.Ava.Systems
|
||||
return max == 0 ? 0 : value / max * 100;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a file operation that can transiently fail because the freshly-downloaded
|
||||
/// update archive is momentarily locked by another process. On Windows this is almost
|
||||
/// always the antivirus (e.g. Defender) scanning the just-written file; the lock clears
|
||||
/// within a second or two. Retries briefly before giving up.
|
||||
/// </summary>
|
||||
private static T RetryOnTransientFileLock<T>(Func<T> operation)
|
||||
{
|
||||
const int maxAttempts = 20;
|
||||
const int delayMs = 250;
|
||||
|
||||
for (int attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return operation();
|
||||
}
|
||||
catch (IOException ex) when (attempt < maxAttempts)
|
||||
{
|
||||
Logger.Warning?.Print(LogClass.Application,
|
||||
$"Update file is locked (likely antivirus), retry {attempt}/{maxAttempts}: {ex.Message}");
|
||||
|
||||
Thread.Sleep(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DoUpdateWithSingleThread(TaskDialog taskDialog, string downloadUrl, string updateFile)
|
||||
{
|
||||
Thread worker = new(() => DoUpdateWithSingleThreadWorker(taskDialog, downloadUrl, updateFile))
|
||||
Thread worker = new(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
DoUpdateWithSingleThreadWorker(taskDialog, downloadUrl, updateFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// An unhandled exception on this background thread would otherwise hard-crash
|
||||
// the whole application. Surface it as a normal error dialog instead.
|
||||
Logger.Error?.Print(LogClass.Application, $"Update failed: {ex}");
|
||||
|
||||
_running = false;
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
taskDialog.Hide();
|
||||
_ = ContentDialogHelper.CreateErrorDialog(
|
||||
$"{LocaleManager.Instance[LocaleKeys.RyujinxUpdater]}: {ex.Message}");
|
||||
});
|
||||
}
|
||||
})
|
||||
{
|
||||
Name = "Updater.SingleThreadWorker",
|
||||
};
|
||||
@@ -435,7 +491,10 @@ namespace Ryujinx.Ava.Systems
|
||||
|
||||
private static void Extract7ZipFile(string archivePath, string outputDirectoryPath)
|
||||
{
|
||||
IArchive archive = ArchiveFactory.OpenArchive(archivePath);
|
||||
// Retry the open: a freshly-downloaded archive is often momentarily locked by
|
||||
// antivirus (e.g. Windows Defender) scanning it. `using` releases the handle so
|
||||
// the file can be deleted afterwards (the previous code leaked it).
|
||||
using IArchive archive = RetryOnTransientFileLock(() => ArchiveFactory.OpenArchive(archivePath));
|
||||
archive.WriteToDirectory(outputDirectoryPath);
|
||||
}
|
||||
|
||||
|
||||
@@ -485,6 +485,19 @@ namespace Ryujinx.Ava.UI.Helpers
|
||||
isTopDialog = false;
|
||||
}
|
||||
|
||||
// When no game is running, host the dialog INLINE on the main window's OverlayLayer
|
||||
// (FluentAvalonia's native hosting -- the same ShowAsync(visual) API used below). This
|
||||
// avoids spawning a separate transparent top-level window per dialog, which is what
|
||||
// produces the brief compositor flash at cold startup (the empty first frame of a fresh
|
||||
// transparent OS window). The dedicated overlay window is only required WHILE A GAME IS
|
||||
// RUNNING, because the native Vulkan/OpenGL render surface draws on top of any inline
|
||||
// Avalonia overlay and would hide the dialog.
|
||||
if (isTopDialog && parent is MainWindow inlineHost && !inlineHost.ViewModel.IsGameRunning)
|
||||
{
|
||||
inlineHost.Activate();
|
||||
return await contentDialog.ShowAsync(inlineHost);
|
||||
}
|
||||
|
||||
if (parent is MainWindow window)
|
||||
{
|
||||
parent.Activate();
|
||||
|
||||
@@ -49,6 +49,7 @@ namespace Ryujinx.Ava.UI.Windows
|
||||
|
||||
private bool _isLoading;
|
||||
private bool _applicationsLoadedOnce;
|
||||
private static bool _startupUpdatePromptShown;
|
||||
private double _windowStartupWidthDelta;
|
||||
private double _windowStartupHeightDelta;
|
||||
|
||||
@@ -417,6 +418,15 @@ namespace Ryujinx.Ava.UI.Windows
|
||||
switch (ConfigurationState.Instance.UpdateCheckerType.Value)
|
||||
{
|
||||
case UpdaterType.PromptAtStartup:
|
||||
// Only auto-prompt once per launch. The persistent status-bar indicator is the
|
||||
// fallback if the user dismisses the prompt.
|
||||
if (_startupUpdatePromptShown)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_startupUpdatePromptShown = true;
|
||||
|
||||
await Updater.BeginUpdateAsync()
|
||||
.Catch(task => Logger.Error?.Print(LogClass.Application, $"Updater Error: {task.Exception}"));
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user