/ src / Ryujinx.Gtk3 / Modules / Updater / Updater.cs
Updater.cs
  1  using Gtk;
  2  using ICSharpCode.SharpZipLib.GZip;
  3  using ICSharpCode.SharpZipLib.Tar;
  4  using ICSharpCode.SharpZipLib.Zip;
  5  using Ryujinx.Common;
  6  using Ryujinx.Common.Logging;
  7  using Ryujinx.Common.Utilities;
  8  using Ryujinx.UI;
  9  using Ryujinx.UI.Common.Models.Github;
 10  using Ryujinx.UI.Widgets;
 11  using System;
 12  using System.Collections.Generic;
 13  using System.IO;
 14  using System.Linq;
 15  using System.Net;
 16  using System.Net.Http;
 17  using System.Net.NetworkInformation;
 18  using System.Runtime.InteropServices;
 19  using System.Text;
 20  using System.Threading;
 21  using System.Threading.Tasks;
 22  
 23  namespace Ryujinx.Modules
 24  {
 25      public static class Updater
 26      {
 27          private const string GitHubApiUrl = "https://api.github.com";
 28          private const int ConnectionCount = 4;
 29  
 30          internal static bool Running;
 31  
 32          private static readonly string _homeDir = AppDomain.CurrentDomain.BaseDirectory;
 33          private static readonly string _updateDir = Path.Combine(Path.GetTempPath(), "Ryujinx", "update");
 34          private static readonly string _updatePublishDir = Path.Combine(_updateDir, "publish");
 35  
 36          private static string _buildVer;
 37          private static string _platformExt;
 38          private static string _buildUrl;
 39          private static long _buildSize;
 40  
 41          private static readonly GithubReleasesJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions());
 42  
 43          // On Windows, GtkSharp.Dependencies adds these extra dirs that must be cleaned during updates.
 44          private static readonly string[] _windowsDependencyDirs = { "bin", "etc", "lib", "share" };
 45  
 46          private static HttpClient ConstructHttpClient()
 47          {
 48              HttpClient result = new();
 49  
 50              // Required by GitHub to interact with APIs.
 51              result.DefaultRequestHeaders.Add("User-Agent", "Ryujinx-Updater/1.0.0");
 52  
 53              return result;
 54          }
 55  
 56          public static async Task BeginParse(MainWindow mainWindow, bool showVersionUpToDate)
 57          {
 58              if (Running)
 59              {
 60                  return;
 61              }
 62  
 63              Running = true;
 64              mainWindow.UpdateMenuItem.Sensitive = false;
 65  
 66              int artifactIndex = -1;
 67  
 68              // Detect current platform
 69              if (OperatingSystem.IsMacOS())
 70              {
 71                  _platformExt = "osx_x64.zip";
 72                  artifactIndex = 1;
 73              }
 74              else if (OperatingSystem.IsWindows())
 75              {
 76                  _platformExt = "win_x64.zip";
 77                  artifactIndex = 2;
 78              }
 79              else if (OperatingSystem.IsLinux())
 80              {
 81                  var arch = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x64";
 82                  _platformExt = $"linux_{arch}.tar.gz";
 83                  artifactIndex = 0;
 84              }
 85  
 86              if (artifactIndex == -1)
 87              {
 88                  GtkDialog.CreateErrorDialog("Your platform is not supported!");
 89  
 90                  return;
 91              }
 92  
 93              Version newVersion;
 94              Version currentVersion;
 95  
 96              try
 97              {
 98                  currentVersion = Version.Parse(Program.Version);
 99              }
100              catch
101              {
102                  GtkDialog.CreateWarningDialog("Failed to convert the current Ryujinx version.", "Cancelling Update!");
103                  Logger.Error?.Print(LogClass.Application, "Failed to convert the current Ryujinx version!");
104  
105                  return;
106              }
107  
108              // Get latest version number from GitHub API
109              try
110              {
111                  using HttpClient jsonClient = ConstructHttpClient();
112                  string buildInfoUrl = $"{GitHubApiUrl}/repos/{ReleaseInformation.ReleaseChannelOwner}/{ReleaseInformation.ReleaseChannelRepo}/releases/latest";
113  
114                  // Fetch latest build information
115                  string fetchedJson = await jsonClient.GetStringAsync(buildInfoUrl);
116                  var fetched = JsonHelper.Deserialize(fetchedJson, _serializerContext.GithubReleasesJsonResponse);
117                  _buildVer = fetched.Name;
118  
119                  foreach (var asset in fetched.Assets)
120                  {
121                      if (asset.Name.StartsWith("gtk-ryujinx") && asset.Name.EndsWith(_platformExt))
122                      {
123                          _buildUrl = asset.BrowserDownloadUrl;
124  
125                          if (asset.State != "uploaded")
126                          {
127                              if (showVersionUpToDate)
128                              {
129                                  GtkDialog.CreateUpdaterInfoDialog("You are already using the latest version of Ryujinx!", "");
130                              }
131  
132                              return;
133                          }
134  
135                          break;
136                      }
137                  }
138  
139                  if (_buildUrl == null)
140                  {
141                      if (showVersionUpToDate)
142                      {
143                          GtkDialog.CreateUpdaterInfoDialog("You are already using the latest version of Ryujinx!", "");
144                      }
145  
146                      return;
147                  }
148              }
149              catch (Exception exception)
150              {
151                  Logger.Error?.Print(LogClass.Application, exception.Message);
152                  GtkDialog.CreateErrorDialog("An error 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.");
153  
154                  return;
155              }
156  
157              try
158              {
159                  newVersion = Version.Parse(_buildVer);
160              }
161              catch
162              {
163                  GtkDialog.CreateWarningDialog("Failed to convert the received Ryujinx version from GitHub Release.", "Cancelling Update!");
164                  Logger.Error?.Print(LogClass.Application, "Failed to convert the received Ryujinx version from GitHub Release!");
165  
166                  return;
167              }
168  
169              if (newVersion <= currentVersion)
170              {
171                  if (showVersionUpToDate)
172                  {
173                      GtkDialog.CreateUpdaterInfoDialog("You are already using the latest version of Ryujinx!", "");
174                  }
175  
176                  Running = false;
177                  mainWindow.UpdateMenuItem.Sensitive = true;
178  
179                  return;
180              }
181  
182              // Fetch build size information to learn chunk sizes.
183              using HttpClient buildSizeClient = ConstructHttpClient();
184              try
185              {
186                  buildSizeClient.DefaultRequestHeaders.Add("Range", "bytes=0-0");
187  
188                  HttpResponseMessage message = await buildSizeClient.GetAsync(new Uri(_buildUrl), HttpCompletionOption.ResponseHeadersRead);
189  
190                  _buildSize = message.Content.Headers.ContentRange.Length.Value;
191              }
192              catch (Exception ex)
193              {
194                  Logger.Warning?.Print(LogClass.Application, ex.Message);
195                  Logger.Warning?.Print(LogClass.Application, "Couldn't determine build size for update, using single-threaded updater");
196  
197                  _buildSize = -1;
198              }
199  
200              // Show a message asking the user if they want to update
201              UpdateDialog updateDialog = new(mainWindow, newVersion, _buildUrl);
202              updateDialog.Show();
203          }
204  
205          public static void UpdateRyujinx(UpdateDialog updateDialog, string downloadUrl)
206          {
207              // Empty update dir, although it shouldn't ever have anything inside it
208              if (Directory.Exists(_updateDir))
209              {
210                  Directory.Delete(_updateDir, true);
211              }
212  
213              Directory.CreateDirectory(_updateDir);
214  
215              string updateFile = Path.Combine(_updateDir, "update.bin");
216  
217              // Download the update .zip
218              updateDialog.MainText.Text = "Downloading Update...";
219              updateDialog.ProgressBar.Value = 0;
220              updateDialog.ProgressBar.MaxValue = 100;
221  
222              if (_buildSize >= 0)
223              {
224                  DoUpdateWithMultipleThreads(updateDialog, downloadUrl, updateFile);
225              }
226              else
227              {
228                  DoUpdateWithSingleThread(updateDialog, downloadUrl, updateFile);
229              }
230          }
231  
232          private static void DoUpdateWithMultipleThreads(UpdateDialog updateDialog, string downloadUrl, string updateFile)
233          {
234              // Multi-Threaded Updater
235              long chunkSize = _buildSize / ConnectionCount;
236              long remainderChunk = _buildSize % ConnectionCount;
237  
238              int completedRequests = 0;
239              int totalProgressPercentage = 0;
240              int[] progressPercentage = new int[ConnectionCount];
241  
242              List<byte[]> list = new(ConnectionCount);
243              List<WebClient> webClients = new(ConnectionCount);
244  
245              for (int i = 0; i < ConnectionCount; i++)
246              {
247                  list.Add(Array.Empty<byte>());
248              }
249  
250              for (int i = 0; i < ConnectionCount; i++)
251              {
252  #pragma warning disable SYSLIB0014
253                  // TODO: WebClient is obsolete and need to be replaced with a more complex logic using HttpClient.
254                  using WebClient client = new();
255  #pragma warning restore SYSLIB0014
256                  webClients.Add(client);
257  
258                  if (i == ConnectionCount - 1)
259                  {
260                      client.Headers.Add("Range", $"bytes={chunkSize * i}-{(chunkSize * (i + 1) - 1) + remainderChunk}");
261                  }
262                  else
263                  {
264                      client.Headers.Add("Range", $"bytes={chunkSize * i}-{chunkSize * (i + 1) - 1}");
265                  }
266  
267                  client.DownloadProgressChanged += (_, args) =>
268                  {
269                      int index = (int)args.UserState;
270  
271                      Interlocked.Add(ref totalProgressPercentage, -1 * progressPercentage[index]);
272                      Interlocked.Exchange(ref progressPercentage[index], args.ProgressPercentage);
273                      Interlocked.Add(ref totalProgressPercentage, args.ProgressPercentage);
274  
275                      updateDialog.ProgressBar.Value = totalProgressPercentage / ConnectionCount;
276                  };
277  
278                  client.DownloadDataCompleted += (_, args) =>
279                  {
280                      int index = (int)args.UserState;
281  
282                      if (args.Cancelled)
283                      {
284                          webClients[index].Dispose();
285  
286                          return;
287                      }
288  
289                      list[index] = args.Result;
290                      Interlocked.Increment(ref completedRequests);
291  
292                      if (Equals(completedRequests, ConnectionCount))
293                      {
294                          byte[] mergedFileBytes = new byte[_buildSize];
295                          for (int connectionIndex = 0, destinationOffset = 0; connectionIndex < ConnectionCount; connectionIndex++)
296                          {
297                              Array.Copy(list[connectionIndex], 0, mergedFileBytes, destinationOffset, list[connectionIndex].Length);
298                              destinationOffset += list[connectionIndex].Length;
299                          }
300  
301                          File.WriteAllBytes(updateFile, mergedFileBytes);
302  
303                          try
304                          {
305                              InstallUpdate(updateDialog, updateFile);
306                          }
307                          catch (Exception e)
308                          {
309                              Logger.Warning?.Print(LogClass.Application, e.Message);
310                              Logger.Warning?.Print(LogClass.Application, "Multi-Threaded update failed, falling back to single-threaded updater.");
311  
312                              DoUpdateWithSingleThread(updateDialog, downloadUrl, updateFile);
313  
314                              return;
315                          }
316                      }
317                  };
318  
319                  try
320                  {
321                      client.DownloadDataAsync(new Uri(downloadUrl), i);
322                  }
323                  catch (WebException ex)
324                  {
325                      Logger.Warning?.Print(LogClass.Application, ex.Message);
326                      Logger.Warning?.Print(LogClass.Application, "Multi-Threaded update failed, falling back to single-threaded updater.");
327  
328                      foreach (WebClient webClient in webClients)
329                      {
330                          webClient.CancelAsync();
331                      }
332  
333                      DoUpdateWithSingleThread(updateDialog, downloadUrl, updateFile);
334  
335                      return;
336                  }
337              }
338          }
339  
340          private static void DoUpdateWithSingleThreadWorker(UpdateDialog updateDialog, string downloadUrl, string updateFile)
341          {
342              using HttpClient client = new();
343              // We do not want to timeout while downloading
344              client.Timeout = TimeSpan.FromDays(1);
345  
346              using HttpResponseMessage response = client.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead).Result;
347              using Stream remoteFileStream = response.Content.ReadAsStreamAsync().Result;
348              using Stream updateFileStream = File.Open(updateFile, FileMode.Create);
349  
350              long totalBytes = response.Content.Headers.ContentLength.Value;
351              long byteWritten = 0;
352  
353              byte[] buffer = new byte[32 * 1024];
354  
355              while (true)
356              {
357                  int readSize = remoteFileStream.Read(buffer);
358  
359                  if (readSize == 0)
360                  {
361                      break;
362                  }
363  
364                  byteWritten += readSize;
365  
366                  updateDialog.ProgressBar.Value = ((double)byteWritten / totalBytes) * 100;
367                  updateFileStream.Write(buffer, 0, readSize);
368              }
369  
370              InstallUpdate(updateDialog, updateFile);
371          }
372  
373          private static void DoUpdateWithSingleThread(UpdateDialog updateDialog, string downloadUrl, string updateFile)
374          {
375              Thread worker = new(() => DoUpdateWithSingleThreadWorker(updateDialog, downloadUrl, updateFile))
376              {
377                  Name = "Updater.SingleThreadWorker",
378              };
379              worker.Start();
380          }
381  
382          private static async void InstallUpdate(UpdateDialog updateDialog, string updateFile)
383          {
384              // Extract Update
385              updateDialog.MainText.Text = "Extracting Update...";
386              updateDialog.ProgressBar.Value = 0;
387  
388              if (OperatingSystem.IsLinux())
389              {
390                  using Stream inStream = File.OpenRead(updateFile);
391                  using Stream gzipStream = new GZipInputStream(inStream);
392                  using TarInputStream tarStream = new(gzipStream, Encoding.ASCII);
393                  updateDialog.ProgressBar.MaxValue = inStream.Length;
394  
395                  await Task.Run(() =>
396                  {
397                      TarEntry tarEntry;
398  
399                      if (!OperatingSystem.IsWindows())
400                      {
401                          while ((tarEntry = tarStream.GetNextEntry()) != null)
402                          {
403                              if (tarEntry.IsDirectory)
404                              {
405                                  continue;
406                              }
407  
408                              string outPath = Path.Combine(_updateDir, tarEntry.Name);
409  
410                              Directory.CreateDirectory(Path.GetDirectoryName(outPath));
411  
412                              using FileStream outStream = File.OpenWrite(outPath);
413                              tarStream.CopyEntryContents(outStream);
414  
415                              File.SetUnixFileMode(outPath, (UnixFileMode)tarEntry.TarHeader.Mode);
416                              File.SetLastWriteTime(outPath, DateTime.SpecifyKind(tarEntry.ModTime, DateTimeKind.Utc));
417  
418                              TarEntry entry = tarEntry;
419  
420                              Application.Invoke(delegate
421                              {
422                                  updateDialog.ProgressBar.Value += entry.Size;
423                              });
424                          }
425                      }
426                  });
427  
428                  updateDialog.ProgressBar.Value = inStream.Length;
429              }
430              else
431              {
432                  using Stream inStream = File.OpenRead(updateFile);
433                  using ZipFile zipFile = new(inStream);
434                  updateDialog.ProgressBar.MaxValue = zipFile.Count;
435  
436                  await Task.Run(() =>
437                  {
438                      foreach (ZipEntry zipEntry in zipFile)
439                      {
440                          if (zipEntry.IsDirectory)
441                          {
442                              continue;
443                          }
444  
445                          string outPath = Path.Combine(_updateDir, zipEntry.Name);
446  
447                          Directory.CreateDirectory(Path.GetDirectoryName(outPath));
448  
449                          using Stream zipStream = zipFile.GetInputStream(zipEntry);
450                          using FileStream outStream = File.OpenWrite(outPath);
451                          zipStream.CopyTo(outStream);
452  
453                          File.SetLastWriteTime(outPath, DateTime.SpecifyKind(zipEntry.DateTime, DateTimeKind.Utc));
454  
455                          Application.Invoke(delegate
456                          {
457                              updateDialog.ProgressBar.Value++;
458                          });
459                      }
460                  });
461              }
462  
463              // Delete downloaded zip
464              File.Delete(updateFile);
465  
466              List<string> allFiles = EnumerateFilesToDelete().ToList();
467  
468              updateDialog.MainText.Text = "Renaming Old Files...";
469              updateDialog.ProgressBar.Value = 0;
470              updateDialog.ProgressBar.MaxValue = allFiles.Count;
471  
472              // Replace old files
473              await Task.Run(() =>
474              {
475                  foreach (string file in allFiles)
476                  {
477                      try
478                      {
479                          File.Move(file, file + ".ryuold");
480  
481                          Application.Invoke(delegate
482                          {
483                              updateDialog.ProgressBar.Value++;
484                          });
485                      }
486                      catch
487                      {
488                          Logger.Warning?.Print(LogClass.Application, "Updater was unable to rename file: " + file);
489                      }
490                  }
491  
492                  Application.Invoke(delegate
493                  {
494                      updateDialog.MainText.Text = "Adding New Files...";
495                      updateDialog.ProgressBar.Value = 0;
496                      updateDialog.ProgressBar.MaxValue = Directory.GetFiles(_updatePublishDir, "*", SearchOption.AllDirectories).Length;
497                  });
498  
499                  MoveAllFilesOver(_updatePublishDir, _homeDir, updateDialog);
500              });
501  
502              Directory.Delete(_updateDir, true);
503  
504              updateDialog.MainText.Text = "Update Complete!";
505              updateDialog.SecondaryText.Text = "Do you want to restart Ryujinx now?";
506              updateDialog.Modal = true;
507  
508              updateDialog.ProgressBar.Hide();
509              updateDialog.YesButton.Show();
510              updateDialog.NoButton.Show();
511          }
512  
513          public static bool CanUpdate(bool showWarnings)
514          {
515  #if !DISABLE_UPDATER
516              if (!NetworkInterface.GetIsNetworkAvailable())
517              {
518                  if (showWarnings)
519                  {
520                      GtkDialog.CreateWarningDialog("You are not connected to the Internet!", "Please verify that you have a working Internet connection!");
521                  }
522  
523                  return false;
524              }
525  
526              if (Program.Version.Contains("dirty") || !ReleaseInformation.IsValid)
527              {
528                  if (showWarnings)
529                  {
530                      GtkDialog.CreateWarningDialog("You cannot update a Dirty build of Ryujinx!", "Please download Ryujinx at https://ryujinx.org/ if you are looking for a supported version.");
531                  }
532  
533                  return false;
534              }
535  
536              return true;
537  #else
538              if (showWarnings)
539              {
540                  if (ReleaseInformation.IsFlatHubBuild)
541                  {
542                      GtkDialog.CreateWarningDialog("Updater Disabled!", "Please update Ryujinx via FlatHub.");
543                  }
544                  else
545                  {
546                      GtkDialog.CreateWarningDialog("Updater Disabled!", "Please download Ryujinx at https://ryujinx.org/ if you are looking for a supported version.");
547                  }
548              }
549  
550              return false;
551  #endif
552          }
553  
554          // NOTE: This method should always reflect the latest build layout.
555          private static IEnumerable<string> EnumerateFilesToDelete()
556          {
557              var files = Directory.EnumerateFiles(_homeDir); // All files directly in base dir.
558  
559              // Determine and exclude user files only when the updater is running, not when cleaning old files
560              if (Running)
561              {
562                  // Compare the loose files in base directory against the loose files from the incoming update, and store foreign ones in a user list.
563                  var oldFiles = Directory.EnumerateFiles(_homeDir, "*", SearchOption.TopDirectoryOnly).Select(Path.GetFileName);
564                  var newFiles = Directory.EnumerateFiles(_updatePublishDir, "*", SearchOption.TopDirectoryOnly).Select(Path.GetFileName);
565                  var userFiles = oldFiles.Except(newFiles).Select(filename => Path.Combine(_homeDir, filename));
566  
567                  // Remove user files from the paths in files.
568                  files = files.Except(userFiles);
569              }
570  
571              if (OperatingSystem.IsWindows())
572              {
573                  foreach (string dir in _windowsDependencyDirs)
574                  {
575                      string dirPath = Path.Combine(_homeDir, dir);
576                      if (Directory.Exists(dirPath))
577                      {
578                          files = files.Concat(Directory.EnumerateFiles(dirPath, "*", SearchOption.AllDirectories));
579                      }
580                  }
581              }
582  
583              return files.Where(f => !new FileInfo(f).Attributes.HasFlag(FileAttributes.Hidden | FileAttributes.System));
584          }
585  
586          private static void MoveAllFilesOver(string root, string dest, UpdateDialog dialog)
587          {
588              foreach (string directory in Directory.GetDirectories(root))
589              {
590                  string dirName = Path.GetFileName(directory);
591  
592                  if (!Directory.Exists(Path.Combine(dest, dirName)))
593                  {
594                      Directory.CreateDirectory(Path.Combine(dest, dirName));
595                  }
596  
597                  MoveAllFilesOver(directory, Path.Combine(dest, dirName), dialog);
598              }
599  
600              foreach (string file in Directory.GetFiles(root))
601              {
602                  File.Move(file, Path.Combine(dest, Path.GetFileName(file)), true);
603  
604                  Application.Invoke(delegate
605                  {
606                      dialog.ProgressBar.Value++;
607                  });
608              }
609          }
610  
611          public static void CleanupUpdate()
612          {
613              foreach (string file in EnumerateFilesToDelete())
614              {
615                  if (Path.GetExtension(file).EndsWith(".ryuold"))
616                  {
617                      File.Delete(file);
618                  }
619              }
620          }
621      }
622  }