From f663a5cd38e0ac0191f5859ed5bc25f5a7a9a907 Mon Sep 17 00:00:00 2001 From: Mary Date: Sat, 25 Feb 2023 12:30:48 +0100 Subject: [PATCH] macos: Add updater support (#4464) This is a very basic updater but should be enough for now. --------- Co-authored-by: TSRBerry <20988865+TSRBerry@users.noreply.github.com> --- Ryujinx.Ava/Modules/Updater/Updater.cs | 257 ++++++++++++--------- distribution/macos/create_app_bundle.sh | 1 + distribution/macos/create_macos_release.sh | 2 +- distribution/macos/updater.sh | 39 ++++ 4 files changed, 191 insertions(+), 108 deletions(-) create mode 100755 distribution/macos/updater.sh diff --git a/Ryujinx.Ava/Modules/Updater/Updater.cs b/Ryujinx.Ava/Modules/Updater/Updater.cs index 511e273e..e89abd1d 100644 --- a/Ryujinx.Ava/Modules/Updater/Updater.cs +++ b/Ryujinx.Ava/Modules/Updater/Updater.cs @@ -21,6 +21,7 @@ using System.Net.Http; using System.Net.NetworkInformation; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Runtime.Versioning; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -57,7 +58,7 @@ namespace Ryujinx.Modules // Detect current platform if (OperatingSystem.IsMacOS()) { - _platformExt = "osx_x64.zip"; + _platformExt = "macos_universal.app.tar.gz"; } else if (OperatingSystem.IsWindows()) { @@ -286,22 +287,40 @@ namespace Ryujinx.Modules if (_updateSuccessful) { - var shouldRestart = await ContentDialogHelper.CreateChoiceDialog(LocaleManager.Instance[LocaleKeys.RyujinxUpdater], - LocaleManager.Instance[LocaleKeys.DialogUpdaterCompleteMessage], - LocaleManager.Instance[LocaleKeys.DialogUpdaterRestartMessage]); + bool shouldRestart = true; + + if (!OperatingSystem.IsMacOS()) + { + shouldRestart = await ContentDialogHelper.CreateChoiceDialog(LocaleManager.Instance[LocaleKeys.RyujinxUpdater], + LocaleManager.Instance[LocaleKeys.DialogUpdaterCompleteMessage], + LocaleManager.Instance[LocaleKeys.DialogUpdaterRestartMessage]); + } if (shouldRestart) { + List arguments = CommandLineState.Arguments.ToList(); string ryuName = Path.GetFileName(Environment.ProcessPath); - string ryuExe = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ryuName); + string executableDirectory = AppDomain.CurrentDomain.BaseDirectory; + string executablePath = Path.Combine(executableDirectory, ryuName); - if (!Path.Exists(ryuExe)) + if (!Path.Exists(executablePath)) { - ryuExe = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, OperatingSystem.IsWindows() ? "Ryujinx.exe" : "Ryujinx"); + executablePath = Path.Combine(executableDirectory, OperatingSystem.IsWindows() ? "Ryujinx.exe" : "Ryujinx"); } - Process.Start(ryuExe, CommandLineState.Arguments); + // On macOS we perform the update at relaunch. + if (OperatingSystem.IsMacOS()) + { + string baseBundlePath = Path.GetFullPath(Path.Combine(executableDirectory, "..", "..")); + string newBundlePath = Path.Combine(UpdateDir, "Ryujinx.app"); + string updaterScriptPath = Path.Combine(newBundlePath, "Contents", "Resources", "updater.sh"); + string currentPid = Process.GetCurrentProcess().Id.ToString(); + executablePath = "/bin/bash"; + arguments.InsertRange(0, new List { updaterScriptPath, baseBundlePath, newBundlePath, currentPid }); + } + + Process.Start(executablePath, arguments); Environment.Exit(0); } } @@ -381,6 +400,15 @@ namespace Ryujinx.Modules File.WriteAllBytes(updateFile, mergedFileBytes); + // On macOS, ensure that we remove the quarantine bit to prevent Gatekeeper from blocking execution. + if (OperatingSystem.IsMacOS()) + { + using (Process xattrProcess = Process.Start("xattr", new List { "-d", "com.apple.quarantine", updateFile })) + { + xattrProcess.WaitForExit(); + } + } + try { InstallUpdate(taskDialog, updateFile); @@ -470,87 +498,98 @@ namespace Ryujinx.Modules worker.Start(); } + [SupportedOSPlatform("linux")] + [SupportedOSPlatform("macos")] + private static void ExtractTarGzipFile(TaskDialog taskDialog, string archivePath, string outputDirectoryPath) + { + using Stream inStream = File.OpenRead(archivePath); + using GZipInputStream gzipStream = new(inStream); + using TarInputStream tarStream = new(gzipStream, Encoding.ASCII); + + TarEntry tarEntry; + + while ((tarEntry = tarStream.GetNextEntry()) is not null) + { + if (tarEntry.IsDirectory) + { + continue; + } + + string outPath = Path.Combine(outputDirectoryPath, tarEntry.Name); + + Directory.CreateDirectory(Path.GetDirectoryName(outPath)); + + using (FileStream outStream = File.OpenWrite(outPath)) + { + tarStream.CopyEntryContents(outStream); + } + + File.SetUnixFileMode(outPath, (UnixFileMode)tarEntry.TarHeader.Mode); + File.SetLastWriteTime(outPath, DateTime.SpecifyKind(tarEntry.ModTime, DateTimeKind.Utc)); + + Dispatcher.UIThread.Post(() => + { + if (tarEntry is null) + { + return; + } + + taskDialog.SetProgressBarState(GetPercentage(tarEntry.Size, inStream.Length), TaskDialogProgressState.Normal); + }); + } + } + + private static void ExtractZipFile(TaskDialog taskDialog, string archivePath, string outputDirectoryPath) + { + using Stream inStream = File.OpenRead(archivePath); + using ZipFile zipFile = new(inStream); + + double count = 0; + foreach (ZipEntry zipEntry in zipFile) + { + count++; + if (zipEntry.IsDirectory) continue; + + string outPath = Path.Combine(outputDirectoryPath, zipEntry.Name); + + Directory.CreateDirectory(Path.GetDirectoryName(outPath)); + + using (Stream zipStream = zipFile.GetInputStream(zipEntry)) + using (FileStream outStream = File.OpenWrite(outPath)) + { + zipStream.CopyTo(outStream); + } + + File.SetLastWriteTime(outPath, DateTime.SpecifyKind(zipEntry.DateTime, DateTimeKind.Utc)); + + Dispatcher.UIThread.Post(() => + { + taskDialog.SetProgressBarState(GetPercentage(count, zipFile.Count), TaskDialogProgressState.Normal); + }); + } + } + private static async void InstallUpdate(TaskDialog taskDialog, string updateFile) { // Extract Update taskDialog.SubHeader = LocaleManager.Instance[LocaleKeys.UpdaterExtracting]; taskDialog.SetProgressBarState(0, TaskDialogProgressState.Normal); - if (OperatingSystem.IsLinux()) + await Task.Run(() => { - using Stream inStream = File.OpenRead(updateFile); - using GZipInputStream gzipStream = new(inStream); - using TarInputStream tarStream = new(gzipStream, Encoding.ASCII); - - await Task.Run(() => + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { - TarEntry tarEntry; - - if (!OperatingSystem.IsWindows()) - { - while ((tarEntry = tarStream.GetNextEntry()) is not null) - { - if (tarEntry.IsDirectory) continue; - - string outPath = Path.Combine(UpdateDir, tarEntry.Name); - - Directory.CreateDirectory(Path.GetDirectoryName(outPath)); - - using (FileStream outStream = File.OpenWrite(outPath)) - { - tarStream.CopyEntryContents(outStream); - } - - File.SetUnixFileMode(outPath, (UnixFileMode)tarEntry.TarHeader.Mode); - File.SetLastWriteTime(outPath, DateTime.SpecifyKind(tarEntry.ModTime, DateTimeKind.Utc)); - - Dispatcher.UIThread.Post(() => - { - if (tarEntry is null) - { - return; - } - - taskDialog.SetProgressBarState(GetPercentage(tarEntry.Size, inStream.Length), TaskDialogProgressState.Normal); - }); - } - } - }); - - taskDialog.SetProgressBarState(100, TaskDialogProgressState.Normal); - } - else - { - using Stream inStream = File.OpenRead(updateFile); - using ZipFile zipFile = new(inStream); - - await Task.Run(() => + ExtractTarGzipFile(taskDialog, updateFile, UpdateDir); + } + else if (OperatingSystem.IsWindows()) { - double count = 0; - foreach (ZipEntry zipEntry in zipFile) - { - count++; - if (zipEntry.IsDirectory) continue; - - string outPath = Path.Combine(UpdateDir, zipEntry.Name); - - Directory.CreateDirectory(Path.GetDirectoryName(outPath)); - - using (Stream zipStream = zipFile.GetInputStream(zipEntry)) - using (FileStream outStream = File.OpenWrite(outPath)) - { - zipStream.CopyTo(outStream); - } - - File.SetLastWriteTime(outPath, DateTime.SpecifyKind(zipEntry.DateTime, DateTimeKind.Utc)); - - Dispatcher.UIThread.Post(() => - { - taskDialog.SetProgressBarState(GetPercentage(count, zipFile.Count), TaskDialogProgressState.Normal); - }); - } - }); - } + ExtractZipFile(taskDialog, updateFile, UpdateDir); + } + else + { + throw new NotSupportedException(); + } + }); // Delete downloaded zip File.Delete(updateFile); @@ -560,38 +599,42 @@ namespace Ryujinx.Modules taskDialog.SubHeader = LocaleManager.Instance[LocaleKeys.UpdaterRenaming]; taskDialog.SetProgressBarState(0, TaskDialogProgressState.Normal); - // Replace old files - await Task.Run(() => + // NOTE: On macOS, replacement is delayed to the restart phase. + if (!OperatingSystem.IsMacOS()) { - double count = 0; - foreach (string file in allFiles) + // Replace old files + await Task.Run(() => { - count++; - try + double count = 0; + foreach (string file in allFiles) { - File.Move(file, file + ".ryuold"); - - Dispatcher.UIThread.Post(() => + count++; + try { - taskDialog.SetProgressBarState(GetPercentage(count, allFiles.Count), TaskDialogProgressState.Normal); - }); - } - catch - { - Logger.Warning?.Print(LogClass.Application, LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.UpdaterRenameFailed, file)); - } - } + File.Move(file, file + ".ryuold"); - Dispatcher.UIThread.Post(() => - { - taskDialog.SubHeader = LocaleManager.Instance[LocaleKeys.UpdaterAddingFiles]; - taskDialog.SetProgressBarState(0, TaskDialogProgressState.Normal); + Dispatcher.UIThread.Post(() => + { + taskDialog.SetProgressBarState(GetPercentage(count, allFiles.Count), TaskDialogProgressState.Normal); + }); + } + catch + { + Logger.Warning?.Print(LogClass.Application, LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.UpdaterRenameFailed, file)); + } + } + + Dispatcher.UIThread.Post(() => + { + taskDialog.SubHeader = LocaleManager.Instance[LocaleKeys.UpdaterAddingFiles]; + taskDialog.SetProgressBarState(0, TaskDialogProgressState.Normal); + }); + + MoveAllFilesOver(UpdatePublishDir, HomeDir, taskDialog); }); - MoveAllFilesOver(UpdatePublishDir, HomeDir, taskDialog); - }); - - Directory.Delete(UpdateDir, true); + Directory.Delete(UpdateDir, true); + } _updateSuccessful = true; @@ -601,7 +644,7 @@ namespace Ryujinx.Modules public static bool CanUpdate(bool showWarnings) { #if !DISABLE_UPDATER - if (RuntimeInformation.OSArchitecture != Architecture.X64) + if (RuntimeInformation.OSArchitecture != Architecture.X64 && !OperatingSystem.IsMacOS()) { if (showWarnings) { @@ -674,7 +717,7 @@ namespace Ryujinx.Modules #endif } - // NOTE: This method should always reflect the latest build layout.s + // NOTE: This method should always reflect the latest build layout. private static IEnumerable EnumerateFilesToDelete() { var files = Directory.EnumerateFiles(HomeDir); // All files directly in base dir. diff --git a/distribution/macos/create_app_bundle.sh b/distribution/macos/create_app_bundle.sh index 8076303c..b62f3491 100755 --- a/distribution/macos/create_app_bundle.sh +++ b/distribution/macos/create_app_bundle.sh @@ -24,6 +24,7 @@ cp $PUBLISH_DIRECTORY/*.dylib $APP_BUNDLE_DIRECTORY/Contents/Frameworks # Then resources cp Info.plist $APP_BUNDLE_DIRECTORY/Contents cp Ryujinx.icns $APP_BUNDLE_DIRECTORY/Contents/Resources/Ryujinx.icns +cp updater.sh $APP_BUNDLE_DIRECTORY/Contents/Resources/updater.sh cp -r $PUBLISH_DIRECTORY/THIRDPARTY.md $APP_BUNDLE_DIRECTORY/Contents/Resources echo -n "APPL????" > $APP_BUNDLE_DIRECTORY/Contents/PkgInfo diff --git a/distribution/macos/create_macos_release.sh b/distribution/macos/create_macos_release.sh index 545baf20..d979ec8f 100755 --- a/distribution/macos/create_macos_release.sh +++ b/distribution/macos/create_macos_release.sh @@ -27,7 +27,7 @@ EXECUTABLE_SUB_PATH=Contents/MacOS/Ryujinx rm -rf $TEMP_DIRECTORY mkdir -p $TEMP_DIRECTORY -DOTNET_COMMON_ARGS="-p:DebugType=embedded -p:Version=$VERSION -p:SourceRevisionId=$SOURCE_REVISION_ID -p:ExtraDefineConstants=DISABLE_UPDATER --self-contained true" +DOTNET_COMMON_ARGS="-p:DebugType=embedded -p:Version=$VERSION -p:SourceRevisionId=$SOURCE_REVISION_ID --self-contained true" dotnet restore dotnet build -c Release Ryujinx.Ava diff --git a/distribution/macos/updater.sh b/distribution/macos/updater.sh new file mode 100755 index 00000000..b60ac34d --- /dev/null +++ b/distribution/macos/updater.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +set -e + +INSTALL_DIRECTORY=$1 +NEW_APP_DIRECTORY=$2 +APP_PID=$3 +APP_ARGUMENTS="${@:4}" + +error_handler() { + local lineno="$1" + + script=""" + set alertTitle to \"Ryujinx - Updater error\" + set alertMessage to \"An error occurred during Ryujinx update (updater.sh:$lineno)\n\nPlease download the update manually from our website if the problem persists.\" + display dialog alertMessage with icon caution with title alertTitle buttons {\"Open Download Page\", \"Exit\"} + set the button_pressed to the button returned of the result + + if the button_pressed is \"Open Download Page\" then + open location \"https://ryujinx.org/download\" + end if + """ + + osascript -e "$script" + exit 1 +} + +trap 'error_handler ${LINENO}' ERR + +# Wait for Ryujinx to exit +# NOTE: in case no fds are open, lsof could be returning with a process still living. +# We wait 1s and assume the process stopped after that +lsof -p $APP_PID +r 1 &>/dev/null +sleep 1 + +# Now replace and reopen. +rm -rf "$INSTALL_DIRECTORY" +mv "$NEW_APP_DIRECTORY" "$INSTALL_DIRECTORY" +open -a "$INSTALL_DIRECTORY" --args "$APP_ARGUMENTS"