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 }