diff --git a/Ryujinx.Ava/Assets/Locales/en_US.json b/Ryujinx.Ava/Assets/Locales/en_US.json index 932824190cea..c96a18986de8 100644 --- a/Ryujinx.Ava/Assets/Locales/en_US.json +++ b/Ryujinx.Ava/Assets/Locales/en_US.json @@ -315,8 +315,8 @@ "DialogUpdaterConvertFailedMessage": "Failed to convert the current Ryujinx version.", "DialogUpdaterCancelUpdateMessage": "Cancelling Update!", "DialogUpdaterAlreadyOnLatestVersionMessage": "You are already using the most updated version of Ryujinx!", - "DialogUpdaterFailedToGetVersionMessage": "An error has occurred when trying to get release information from AppVeyor.", - "DialogUpdaterConvertFailedAppveyorMessage": "Failed to convert the received Ryujinx version from AppVeyor.", + "DialogUpdaterFailedToGetVersionMessage": "An error has occurred when trying to get release information from Github Release. This can be caused if a new release is being compiled by GitHub Actions. Try again in a few minutes.", + "DialogUpdaterConvertFailedGithubMessage": "Failed to convert the received Ryujinx version from Github Release.", "DialogUpdaterDownloadingMessage": "Downloading Update...", "DialogUpdaterExtractionMessage": "Extracting Update...", "DialogUpdaterRenamingMessage": "Renaming Update...", diff --git a/Ryujinx.Ava/Assets/Locales/pt_BR.json b/Ryujinx.Ava/Assets/Locales/pt_BR.json index f7a475edf372..7123415c8480 100644 --- a/Ryujinx.Ava/Assets/Locales/pt_BR.json +++ b/Ryujinx.Ava/Assets/Locales/pt_BR.json @@ -316,7 +316,7 @@ "DialogUpdaterCancelUpdateMessage": "Cancelando atualização!", "DialogUpdaterAlreadyOnLatestVersionMessage": "Você já está usando a versão mais recente do Ryujinx!", "DialogUpdaterFailedToGetVersionMessage": "Ocorreu um erro ao tentar obter informações de atualização do AppVeyor.", - "DialogUpdaterConvertFailedAppveyorMessage": "Falha ao converter a versão do Ryujinx recebida do AppVeyor.", + "DialogUpdaterConvertFailedGithubMessage": "Falha ao converter a versão do Ryujinx recebida do AppVeyor.", "DialogUpdaterDownloadingMessage": "Baixando atualização...", "DialogUpdaterExtractionMessage": "Extraindo atualização...", "DialogUpdaterRenamingMessage": "Renomeando atualização...", diff --git a/Ryujinx.Ava/Modules/Updater/Updater.cs b/Ryujinx.Ava/Modules/Updater/Updater.cs index 0e553ec362ce..3e20408dae4b 100644 --- a/Ryujinx.Ava/Modules/Updater/Updater.cs +++ b/Ryujinx.Ava/Modules/Updater/Updater.cs @@ -1,4 +1,3 @@ -using Avalonia.Controls; using Avalonia.Threading; using ICSharpCode.SharpZipLib.GZip; using ICSharpCode.SharpZipLib.Tar; @@ -8,30 +7,38 @@ using Ryujinx.Ava.Common.Locale; using Ryujinx.Ava.Ui.Controls; using Ryujinx.Ava.Ui.Windows; +using Ryujinx.Common; using Ryujinx.Common.Logging; using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net; +using System.Net.Http; using System.Net.NetworkInformation; using System.Runtime.InteropServices; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace Ryujinx.Modules { public static class Updater { - private const string AppveyorApiUrl = "https://ci.appveyor.com/api"; + private const string GitHubApiURL = "https://api.github.com"; internal static bool Running; private static readonly string HomeDir = AppDomain.CurrentDomain.BaseDirectory; private static readonly string UpdateDir = Path.Combine(Path.GetTempPath(), "Ryujinx", "update"); private static readonly string UpdatePublishDir = Path.Combine(UpdateDir, "publish"); + private static readonly int ConnectionCount = 4; - private static string _jobId; private static string _buildVer; private static string _platformExt; private static string _buildUrl; + private static long _buildSize; + + private static readonly string[] WindowsDependencyDirs = new string[] { }; public static async Task BeginParse(MainWindow mainWindow, bool showVersionUpToDate) { @@ -72,23 +79,44 @@ public static async Task BeginParse(MainWindow mainWindow, bool showVersionUpToD return; } - // Get latest version number from Appveyor + // Get latest version number from GitHub API try { - using (WebClient jsonClient = new()) + using (HttpClient jsonClient = ConstructHttpClient()) { - string fetchedJson = - await jsonClient.DownloadStringTaskAsync( - $"{AppveyorApiUrl}/projects/gdkchan/ryujinx/branch/master"); - JObject jsonRoot = JObject.Parse(fetchedJson); - JToken buildToken = jsonRoot["build"]; + string buildInfoURL = $"{GitHubApiURL}/repos/{ReleaseInformations.ReleaseChannelOwner}/{ReleaseInformations.ReleaseChannelRepo}/releases/latest"; + + string fetchedJson = await jsonClient.GetStringAsync(buildInfoURL); + JObject jsonRoot = JObject.Parse(fetchedJson); + JToken assets = jsonRoot["assets"]; - _jobId = (string)buildToken["jobs"][0]["jobId"]; - _buildVer = (string)buildToken["version"]; - _buildUrl = $"{AppveyorApiUrl}/buildjobs/{_jobId}/artifacts/ryujinx-{_buildVer}-{_platformExt}"; + foreach (JToken asset in assets) + { + string assetName = (string)asset["name"]; + string assetState = (string)asset["state"]; + string downloadURL = (string)asset["browser_download_url"]; + + if (!assetName.StartsWith("ryujinx-headless-sdl2") && assetName.EndsWith(_platformExt)) + { + _buildUrl = downloadURL; + + if (assetState != "uploaded") + { + if (showVersionUpToDate) + { + ContentDialogHelper.CreateUpdaterInfoDialog(mainWindow, + LocaleManager.Instance["DialogUpdaterAlreadyOnLatestVersionMessage"], ""); + } + + return; + } + + break; + } + } // If build not done, assume no new update are availaible. - if ((string)buildToken["jobs"][0]["status"] != "success") + if (_buildUrl == null) { if (showVersionUpToDate) { @@ -115,7 +143,7 @@ await jsonClient.DownloadStringTaskAsync( } catch { - ContentDialogHelper.CreateWarningDialog(mainWindow, LocaleManager.Instance["DialogUpdaterConvertFailedAppveyorMessage"], LocaleManager.Instance["DialogUpdaterCancelUpdateMessage"]); + ContentDialogHelper.CreateWarningDialog(mainWindow, LocaleManager.Instance["DialogUpdaterConvertFailedGithubMessage"], LocaleManager.Instance["DialogUpdaterCancelUpdateMessage"]); Logger.Error?.Print(LogClass.Application, "Failed to convert the received Ryujinx version from AppVeyor!"); @@ -135,11 +163,40 @@ await jsonClient.DownloadStringTaskAsync( return; } + // Fetch build size information to learn chunk sizes. + using (HttpClient buildSizeClient = ConstructHttpClient()) + { + try + { + buildSizeClient.DefaultRequestHeaders.Add("Range", "bytes=0-0"); + + HttpResponseMessage message = await buildSizeClient.GetAsync(new Uri(_buildUrl), HttpCompletionOption.ResponseHeadersRead); + + _buildSize = message.Content.Headers.ContentRange.Length.Value; + } + catch (Exception ex) + { + Logger.Warning?.Print(LogClass.Application, ex.Message); + Logger.Warning?.Print(LogClass.Application, "Couldn't determine build size for update, using single-threaded updater"); + _buildSize = -1; + } + } + // Show a message asking the user if they want to update UpdaterWindow updateDialog = new(mainWindow, newVersion, _buildUrl); await updateDialog.ShowDialog(mainWindow); } + + private static HttpClient ConstructHttpClient() + { + HttpClient result = new HttpClient(); + + // Required by GitHub to interract with APIs. + result.DefaultRequestHeaders.Add("User-Agent", "Ryujinx-Updater/1.0.0"); + + return result; + } public static async Task UpdateRyujinx(UpdaterWindow updateDialog, string downloadUrl) { @@ -154,29 +211,199 @@ public static async Task UpdateRyujinx(UpdaterWindow updateDialog, string downlo string updateFile = Path.Combine(UpdateDir, "update.bin"); // Download the update .zip - updateDialog.MainText.Text = LocaleManager.Instance["DialogUpdaterDownloadingMessage"]; - updateDialog.ProgressBar.Value = 0; - updateDialog.ProgressBar.Maximum = 100; + updateDialog.MainText.Text = "Downloading Update..."; + updateDialog.ProgressBar.Value = 0; + updateDialog.ProgressBar.Maximum = 100; + + if (_buildSize >= 0) + { + DoUpdateWithMultipleThreads(updateDialog, downloadUrl, updateFile); + } + else + { + DoUpdateWithSingleThread(updateDialog, downloadUrl, updateFile); + } + } + + private static void DoUpdateWithMultipleThreads(UpdaterWindow updateDialog, string downloadUrl, string updateFile) + { + // Multi-Threaded Updater + long chunkSize = _buildSize / ConnectionCount; + long remainderChunk = _buildSize % ConnectionCount; + + int completedRequests = 0; + int totalProgressPercentage = 0; + int[] progressPercentage = new int[ConnectionCount]; + + List list = new List(ConnectionCount); + List webClients = new List(ConnectionCount); - using (WebClient client = new()) + for (int i = 0; i < ConnectionCount; i++) { - client.DownloadProgressChanged += (_, args) => + list.Add(new byte[0]); + } + + for (int i = 0; i < ConnectionCount; i++) + { +#pragma warning disable SYSLIB0014 + // TODO: WebClient is obsolete and need to be replaced with a more complex logic using HttpClient. + using (WebClient client = new WebClient()) +#pragma warning restore SYSLIB0014 { - updateDialog.ProgressBar.Value = args.ProgressPercentage; - }; + webClients.Add(client); - await client.DownloadFileTaskAsync(downloadUrl, updateFile); + if (i == ConnectionCount - 1) + { + client.Headers.Add("Range", $"bytes={chunkSize * i}-{(chunkSize * (i + 1) - 1) + remainderChunk}"); + } + else + { + client.Headers.Add("Range", $"bytes={chunkSize * i}-{chunkSize * (i + 1) - 1}"); + } + + client.DownloadProgressChanged += (_, args) => + { + int index = (int)args.UserState; + + Interlocked.Add(ref totalProgressPercentage, -1 * progressPercentage[index]); + Interlocked.Exchange(ref progressPercentage[index], args.ProgressPercentage); + Interlocked.Add(ref totalProgressPercentage, args.ProgressPercentage); + + updateDialog.ProgressBar.Value = totalProgressPercentage / ConnectionCount; + }; + + client.DownloadDataCompleted += (_, args) => + { + int index = (int)args.UserState; + + if (args.Cancelled) + { + webClients[index].Dispose(); + + return; + } + + list[index] = args.Result; + Interlocked.Increment(ref completedRequests); + + if (Interlocked.Equals(completedRequests, ConnectionCount)) + { + byte[] mergedFileBytes = new byte[_buildSize]; + for (int connectionIndex = 0, destinationOffset = 0; connectionIndex < ConnectionCount; connectionIndex++) + { + Array.Copy(list[connectionIndex], 0, mergedFileBytes, destinationOffset, list[connectionIndex].Length); + destinationOffset += list[connectionIndex].Length; + } + + File.WriteAllBytes(updateFile, mergedFileBytes); + + try + { + InstallUpdate(updateDialog, updateFile); + } + catch (Exception e) + { + Logger.Warning?.Print(LogClass.Application, e.Message); + Logger.Warning?.Print(LogClass.Application, $"Multi-Threaded update failed, falling back to single-threaded updater."); + + DoUpdateWithSingleThread(updateDialog, downloadUrl, updateFile); + + return; + } + } + }; + + try + { + client.DownloadDataAsync(new Uri(downloadUrl), i); + } + catch (WebException ex) + { + Logger.Warning?.Print(LogClass.Application, ex.Message); + Logger.Warning?.Print(LogClass.Application, $"Multi-Threaded update failed, falling back to single-threaded updater."); + + for (int j = 0; j < webClients.Count; j++) + { + webClients[j].CancelAsync(); + } + + DoUpdateWithSingleThread(updateDialog, downloadUrl, updateFile); + + return; + } + } } + } + + private static void DoUpdateWithSingleThreadWorker(UpdaterWindow updateDialog, string downloadUrl, string updateFile) + { + using (HttpClient client = new HttpClient()) + { + // We do not want to timeout while downloading + client.Timeout = TimeSpan.FromDays(1); + + using (HttpResponseMessage response = client.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead).Result) + using (Stream remoteFileStream = response.Content.ReadAsStreamAsync().Result) + { + using (Stream updateFileStream = File.Open(updateFile, FileMode.Create)) + { + long totalBytes = response.Content.Headers.ContentLength.Value; + long byteWritten = 0; + + byte[] buffer = new byte[32 * 1024]; + + while (true) + { + int readSize = remoteFileStream.Read(buffer); + + if (readSize == 0) + { + break; + } + + byteWritten += readSize; + + updateDialog.ProgressBar.Value = ((double)byteWritten / totalBytes) * 100; + updateFileStream.Write(buffer, 0, readSize); + } + } + } + + InstallUpdate(updateDialog, updateFile); + } + } + + private static void DoUpdateWithSingleThread(UpdaterWindow updateDialog, string downloadUrl, string updateFile) + { + Thread worker = new Thread(() => DoUpdateWithSingleThreadWorker(updateDialog, downloadUrl, updateFile)); + worker.Name = "Updater.SingleThreadWorker"; + worker.Start(); + } + + [DllImport("libc", SetLastError = true)] + private static extern int chmod(string path, uint mode); + private static void SetUnixPermissions() + { + string ryuBin = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Ryujinx"); + + if (!OperatingSystem.IsWindows()) + { + chmod(ryuBin, 493); + } + } + + private static async void InstallUpdate(UpdaterWindow updateDialog, string updateFile) + { // Extract Update - updateDialog.MainText.Text = LocaleManager.Instance["DialogUpdaterExtractionMessage"]; + updateDialog.MainText.Text = "Extracting Update..."; updateDialog.ProgressBar.Value = 0; if (OperatingSystem.IsLinux()) { - using (Stream inStream = File.OpenRead(updateFile)) - using (Stream gzipStream = new GZipInputStream(inStream)) - using (TarInputStream tarStream = new(gzipStream, Encoding.ASCII)) + using (Stream inStream = File.OpenRead(updateFile)) + using (Stream gzipStream = new GZipInputStream(inStream)) + using (TarInputStream tarStream = new TarInputStream(gzipStream, Encoding.ASCII)) { updateDialog.ProgressBar.Maximum = inStream.Length; @@ -185,10 +412,7 @@ await Task.Run(() => TarEntry tarEntry; while ((tarEntry = tarStream.GetNextEntry()) != null) { - if (tarEntry.IsDirectory) - { - continue; - } + if (tarEntry.IsDirectory) continue; string outPath = Path.Combine(UpdateDir, tarEntry.Name); @@ -203,7 +427,7 @@ await Task.Run(() => TarEntry entry = tarEntry; - Dispatcher.UIThread.InvokeAsync(() => + Dispatcher.UIThread.Post(()=> { updateDialog.ProgressBar.Value += entry.Size; }); @@ -215,8 +439,8 @@ await Task.Run(() => } else { - using (Stream inStream = File.OpenRead(updateFile)) - using (ZipFile zipFile = new(inStream)) + using (Stream inStream = File.OpenRead(updateFile)) + using (ZipFile zipFile = new ZipFile(inStream)) { updateDialog.ProgressBar.Maximum = zipFile.Count; @@ -224,16 +448,13 @@ await Task.Run(() => { foreach (ZipEntry zipEntry in zipFile) { - if (zipEntry.IsDirectory) - { - continue; - } + if (zipEntry.IsDirectory) continue; string outPath = Path.Combine(UpdateDir, zipEntry.Name); Directory.CreateDirectory(Path.GetDirectoryName(outPath)); - using (Stream zipStream = zipFile.GetInputStream(zipEntry)) + using (Stream zipStream = zipFile.GetInputStream(zipEntry)) using (FileStream outStream = File.OpenWrite(outPath)) { zipStream.CopyTo(outStream); @@ -241,7 +462,7 @@ await Task.Run(() => File.SetLastWriteTime(outPath, DateTime.SpecifyKind(zipEntry.DateTime, DateTimeKind.Utc)); - Dispatcher.UIThread.InvokeAsync(() => + Dispatcher.UIThread.Post(()=> { updateDialog.ProgressBar.Value++; }); @@ -253,41 +474,37 @@ await Task.Run(() => // Delete downloaded zip File.Delete(updateFile); - string[] allFiles = Directory.GetFiles(HomeDir, "*", SearchOption.AllDirectories); + List allFiles = EnumerateFilesToDelete().ToList(); - updateDialog.MainText.Text = LocaleManager.Instance["DialogUpdaterRenamingMessage"]; - updateDialog.ProgressBar.Value = 0; - updateDialog.ProgressBar.Maximum = allFiles.Length; + updateDialog.MainText.Text = "Renaming Old Files..."; + updateDialog.ProgressBar.Value = 0; + updateDialog.ProgressBar.Maximum = allFiles.Count; // Replace old files await Task.Run(() => { foreach (string file in allFiles) { - if (!Path.GetExtension(file).Equals(".log")) + try { - try - { - File.Move(file, file + ".ryuold"); + File.Move(file, file + ".ryuold"); - Dispatcher.UIThread.InvokeAsync(() => - { - updateDialog.ProgressBar.Value++; - }); - } - catch + Dispatcher.UIThread.Post(()=> { - Logger.Warning?.Print(LogClass.Application, "Updater wasn't able to rename file: " + file); - } + updateDialog.ProgressBar.Value++; + }); + } + catch + { + Logger.Warning?.Print(LogClass.Application, "Updater was unable to rename file: " + file); } } - Dispatcher.UIThread.InvokeAsync(() => + Dispatcher.UIThread.Post(()=> { - updateDialog.MainText.Text = LocaleManager.Instance["DialogUpdaterAddingFilesMessage"]; - updateDialog.ProgressBar.Value = 0; - updateDialog.ProgressBar.Maximum = - Directory.GetFiles(UpdatePublishDir, "*", SearchOption.AllDirectories).Length; + updateDialog.MainText.Text = "Adding New Files..."; + updateDialog.ProgressBar.Value = 0; + updateDialog.ProgressBar.Maximum = Directory.GetFiles(UpdatePublishDir, "*", SearchOption.AllDirectories).Length; }); MoveAllFilesOver(UpdatePublishDir, HomeDir, updateDialog); @@ -295,13 +512,15 @@ await Task.Run(() => Directory.Delete(UpdateDir, true); + SetUnixPermissions(); + updateDialog.MainText.Text = LocaleManager.Instance["DialogUpdaterCompleteMessage"]; updateDialog.SecondaryText.Text = LocaleManager.Instance["DialogUpdaterRestartMessage"]; updateDialog.ProgressBar.IsVisible = false; updateDialog.ButtonBox.IsVisible = true; } - + public static bool CanUpdate(bool showWarnings, StyleableWindow parent) { if (RuntimeInformation.OSArchitecture != Architecture.X64) @@ -326,7 +545,7 @@ public static bool CanUpdate(bool showWarnings, StyleableWindow parent) return false; } - if (Program.Version.Contains("dirty")) + if (Program.Version.Contains("dirty") || !ReleaseInformations.IsValid()) { if (showWarnings) { @@ -339,6 +558,26 @@ public static bool CanUpdate(bool showWarnings, StyleableWindow parent) return true; } + + // NOTE: This method should always reflect the latest build layout.s + private static IEnumerable EnumerateFilesToDelete() + { + var files = Directory.EnumerateFiles(HomeDir); // All files directly in base dir. + + if (OperatingSystem.IsWindows()) + { + foreach (string dir in WindowsDependencyDirs) + { + string dirPath = Path.Combine(HomeDir, dir); + if (Directory.Exists(dirPath)) + { + files = files.Concat(Directory.EnumerateFiles(dirPath, "*", SearchOption.AllDirectories)); + } + } + } + + return files; + } private static void MoveAllFilesOver(string root, string dest, UpdaterWindow dialog) { diff --git a/Ryujinx.Ava/Program.cs b/Ryujinx.Ava/Program.cs index 03310cc9ea4e..303b40510996 100644 --- a/Ryujinx.Ava/Program.cs +++ b/Ryujinx.Ava/Program.cs @@ -10,6 +10,7 @@ using Ryujinx.Ava.Common.Locale; using Ryujinx.Ava.Ui.Controls; using Ryujinx.Ava.Ui.Windows; +using Ryujinx.Common; using Ryujinx.Common.Configuration; using Ryujinx.Common.GraphicsDriver; using Ryujinx.Common.Logging; @@ -126,7 +127,7 @@ private static void Initialize(string[] args) // Delete backup files after updating. Task.Run(Updater.CleanupUpdate); - Version = Assembly.GetEntryAssembly().GetCustomAttribute().InformationalVersion; + Version = ReleaseInformations.GetVersion();; Console.Title = $"Ryujinx Console {Version}";