From f663a5cd38e0ac0191f5859ed5bc25f5a7a9a907 Mon Sep 17 00:00:00 2001
From: Mary <mary@mary.zone>
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 511e273e59..e89abd1da7 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<string> 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<string> { 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<string> { "-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<string> 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 8076303cbe..b62f3491eb 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 545baf20ef..d979ec8f0c 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 0000000000..b60ac34dfa
--- /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"