diff --git a/StabilityMatrix.Avalonia/Program.cs b/StabilityMatrix.Avalonia/Program.cs index 35777167..40d8531c 100644 --- a/StabilityMatrix.Avalonia/Program.cs +++ b/StabilityMatrix.Avalonia/Program.cs @@ -25,6 +25,7 @@ using StabilityMatrix.Avalonia.ViewModels.Dialogs; using StabilityMatrix.Avalonia.Views.Dialogs; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Updater; namespace StabilityMatrix.Avalonia; @@ -55,8 +56,7 @@ public static class Program SetDebugBuild(); var parseResult = Parser - .Default - .ParseArguments(args) + .Default.ParseArguments(args) .WithNotParsed(errors => { foreach (var error in errors) @@ -147,7 +147,10 @@ public static class Program if (Args.UseOpenGlRendering) { app = app.With( - new Win32PlatformOptions { RenderingMode = [Win32RenderingMode.Wgl, Win32RenderingMode.Software] } + new Win32PlatformOptions + { + RenderingMode = [Win32RenderingMode.Wgl, Win32RenderingMode.Software] + } ); } @@ -156,7 +159,10 @@ public static class Program app = app.With(new Win32PlatformOptions { RenderingMode = new[] { Win32RenderingMode.Software } }) .With(new X11PlatformOptions { RenderingMode = new[] { X11RenderingMode.Software } }) .With( - new AvaloniaNativePlatformOptions { RenderingMode = new[] { AvaloniaNativeRenderingMode.Software } } + new AvaloniaNativePlatformOptions + { + RenderingMode = new[] { AvaloniaNativeRenderingMode.Software } + } ); } @@ -173,8 +179,6 @@ public static class Program return; // Copy our current file to the parent directory, overwriting the old app file - var currentExe = Compat.AppCurrentDir.JoinFile(Compat.GetExecutableName()); - var targetExe = parentDir.JoinFile(Compat.GetExecutableName()); var isCopied = false; @@ -188,7 +192,27 @@ public static class Program { try { - currentExe.CopyTo(targetExe, true); + if (Compat.IsMacOS) + { + var currentApp = Compat.AppBundleCurrentPath!; + var targetApp = parentDir.JoinDir(Compat.GetAppName()); + + // Since macOS has issues with signature caching, delete the target app first + if (targetApp.Exists) + { + targetApp.Delete(true); + } + + currentApp.CopyTo(targetApp); + } + else + { + var currentExe = Compat.AppCurrentPath; + var targetExe = parentDir.JoinFile(Compat.GetExecutableName()); + + currentExe.CopyTo(targetExe, true); + } + isCopied = true; break; } @@ -204,11 +228,13 @@ public static class Program Environment.Exit(1); } + var targetAppOrBundle = Path.Combine(parentDir, Compat.GetAppName()); + // Ensure permissions are set for unix if (Compat.IsUnix) { File.SetUnixFileMode( - targetExe, // 0755 + targetAppOrBundle, // 0755 UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute @@ -220,7 +246,10 @@ public static class Program } // Start the new app while passing our own PID to wait for exit - Process.Start(targetExe, $"--wait-for-exit-pid {Environment.ProcessId}"); + ProcessRunner.StartApp( + targetAppOrBundle, + new[] { "--wait-for-exit-pid", $"{Environment.ProcessId}" } + ); // Shutdown the current app Environment.Exit(0); @@ -274,7 +303,8 @@ public static class Program { SentrySdk.Init(o => { - o.Dsn = "https://eac7a5ea065d44cf9a8565e0f1817da2@o4505314753380352.ingest.sentry.io/4505314756067328"; + o.Dsn = + "https://eac7a5ea065d44cf9a8565e0f1817da2@o4505314753380352.ingest.sentry.io/4505314756067328"; o.StackTraceMode = StackTraceMode.Enhanced; o.TracesSampleRate = 1.0; o.IsGlobalModeEnabled = true; @@ -301,7 +331,10 @@ public static class Program }); } - private static void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + private static void TaskScheduler_UnobservedTaskException( + object? sender, + UnobservedTaskExceptionEventArgs e + ) { if (e.Exception is Exception ex) { diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs index 8a5dae66..2c67e9f5 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs @@ -18,6 +18,7 @@ using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Models.Update; +using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Services; using StabilityMatrix.Core.Updater; @@ -156,7 +157,7 @@ public partial class UpdateViewModel : ContentDialogViewModelBase if (Compat.IsUnix) { File.SetUnixFileMode( - UpdateHelper.ExecutablePath, // 0755 + UpdateHelper.ExecutablePath.FullPath, // 0755 UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute @@ -170,7 +171,13 @@ public partial class UpdateViewModel : ContentDialogViewModelBase UpdateText = "Getting a few things ready..."; await using (new MinimumDelay(500, 1000)) { - Process.Start(UpdateHelper.ExecutablePath, $"--wait-for-exit-pid {Environment.ProcessId}"); + await Task.Run(() => + { + ProcessRunner.StartApp( + UpdateHelper.ExecutablePath.FullPath, + new[] { "--wait-for-exit-pid", $"{Environment.ProcessId}" } + ); + }); } UpdateText = "Update complete. Restarting Stability Matrix in 3 seconds..."; @@ -259,7 +266,10 @@ public partial class UpdateViewModel : ContentDialogViewModelBase var currentVersionBlock = results.FindIndex(x => x.Version == currentVersion.WithoutMetadata()); // For mismatching build metadata, add one - if (currentVersionBlock != -1 && results[currentVersionBlock].Version?.Metadata != currentVersion.Metadata) + if ( + currentVersionBlock != -1 + && results[currentVersionBlock].Version?.Metadata != currentVersion.Metadata + ) { currentVersionBlock++; } @@ -267,7 +277,9 @@ public partial class UpdateViewModel : ContentDialogViewModelBase // Support for previous pre-release without changelogs if (currentVersionBlock == -1) { - currentVersionBlock = results.FindIndex(x => x.Version == currentVersion.WithoutPrereleaseOrMetadata()); + currentVersionBlock = results.FindIndex( + x => x.Version == currentVersion.WithoutPrereleaseOrMetadata() + ); // Add 1 if found to include the current release if (currentVersionBlock != -1) diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs index cfc781db..97cad1f0 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs @@ -51,6 +51,7 @@ using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models.Database; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Settings; +using StabilityMatrix.Core.Processes; using StabilityMatrix.Core.Python; using StabilityMatrix.Core.Services; using Symbol = FluentIcons.Common.Symbol; @@ -302,7 +303,10 @@ public partial class MainSettingsViewModel : PageViewModelBase { if (await dialog.ShowAsync() == ContentDialogResult.Primary) { - Process.Start(Compat.AppCurrentPath); + // Start the new app while passing our own PID to wait for exit + Process.Start(Compat.AppCurrentPath, $"--wait-for-exit-pid {Environment.ProcessId}"); + + // Shutdown the current app App.Shutdown(); } }); diff --git a/StabilityMatrix.Core/Helper/Compat.cs b/StabilityMatrix.Core/Helper/Compat.cs index 9a014424..9e4b352f 100644 --- a/StabilityMatrix.Core/Helper/Compat.cs +++ b/StabilityMatrix.Core/Helper/Compat.cs @@ -4,6 +4,7 @@ using System.Reflection; using System.Runtime.InteropServices; using System.Runtime.Versioning; using Semver; +using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Models.FileInterfaces; namespace StabilityMatrix.Core.Helper; @@ -62,10 +63,21 @@ public static class Compat public static DirectoryPath AppCurrentDir { get; } /// - /// Current path to the app. + /// Current path to the app binary. /// public static FilePath AppCurrentPath => AppCurrentDir.JoinFile(GetExecutableName()); + /// + /// Path to the .app bundle on macOS. + /// + [SupportedOSPlatform("macos")] + public static DirectoryPath? AppBundleCurrentPath { get; } + + /// + /// Either the File or directory on macOS. + /// + public static FileSystemPath AppOrBundleCurrentPath => IsMacOS ? AppBundleCurrentPath! : AppCurrentPath; + // File extensions /// /// Platform-specific executable extension. @@ -103,7 +115,14 @@ public static class Compat else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { Platform = PlatformKind.MacOS | PlatformKind.Unix; - AppCurrentDir = AppContext.BaseDirectory; // TODO: check this + + // This is ./.app/Contents/MacOS + var macDir = new DirectoryPath(AppContext.BaseDirectory); + // We need to go up two directories to get the .app directory + AppBundleCurrentPath = macDir.Parent?.Parent; + // Then CurrentDir is the next parent + AppCurrentDir = AppBundleCurrentPath!.Parent!; + ExeExtension = ""; DllExtension = ".dylib"; } @@ -112,11 +131,9 @@ public static class Compat Platform = PlatformKind.Linux | PlatformKind.Unix; // For AppImage builds, the path is in `$APPIMAGE` - var appPath = - Environment.GetEnvironmentVariable("APPIMAGE") ?? AppContext.BaseDirectory; + var appPath = Environment.GetEnvironmentVariable("APPIMAGE") ?? AppContext.BaseDirectory; AppCurrentDir = - Path.GetDirectoryName(appPath) - ?? throw new Exception("Could not find application directory"); + Path.GetDirectoryName(appPath) ?? throw new Exception("Could not find application directory"); ExeExtension = ""; DllExtension = ".so"; } @@ -186,12 +203,24 @@ public static class Compat return Path.GetFileName(fullPath); } + /// + /// Get the current application executable or bundle name. + /// + public static string GetAppName() + { + // For other platforms, this is the same as the executable name + if (!IsMacOS) + { + return GetExecutableName(); + } + + // On macOS, get name of current bundle + return Path.GetFileName(AppBundleCurrentPath.Unwrap()); + } + public static string GetEnvPathWithExtensions(params string[] paths) { - var currentPath = Environment.GetEnvironmentVariable( - "PATH", - EnvironmentVariableTarget.Process - ); + var currentPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Process); var newPath = string.Join(PathDelimiter, paths); if (string.IsNullOrEmpty(currentPath)) diff --git a/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs b/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs index d07f18dc..fdca61d4 100644 --- a/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs +++ b/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs @@ -249,6 +249,19 @@ public class DirectoryPath : FileSystemPath, IPathObject, IEnumerable new DirectoryPath(directory)); + /// + /// Return a new with the given file name. + /// + public DirectoryPath WithName(string directoryName) + { + if (Path.GetDirectoryName(FullPath) is { } directory && !string.IsNullOrWhiteSpace(directory)) + { + return new DirectoryPath(directory, directoryName); + } + + return new DirectoryPath(directoryName); + } + public override string ToString() => FullPath; /// diff --git a/StabilityMatrix.Core/Processes/ProcessRunner.cs b/StabilityMatrix.Core/Processes/ProcessRunner.cs index 868eb247..ed092b93 100644 --- a/StabilityMatrix.Core/Processes/ProcessRunner.cs +++ b/StabilityMatrix.Core/Processes/ProcessRunner.cs @@ -29,6 +29,27 @@ public static class ProcessRunner OpenUrl(url.AbsoluteUri); } + /// + /// Start an executable or .app on macOS. + /// + public static Process StartApp(string path, ProcessArgs args) + { + if (Compat.IsMacOS) + { + var startInfo = new ProcessStartInfo + { + FileName = "open", + Arguments = args.Prepend([path, "--args"]), + UseShellExecute = true + }; + + return Process.Start(startInfo) + ?? throw new NullReferenceException("Process.Start returned null"); + } + + return Process.Start(args.Prepend(path)); + } + /// /// Opens the given folder in the system file explorer. /// @@ -372,7 +393,10 @@ public static class ProcessRunner }; } - public static Task RunBashCommand(IEnumerable commands, string workingDirectory = "") + public static Task RunBashCommand( + IEnumerable commands, + string workingDirectory = "" + ) { // Quote arguments containing spaces var args = string.Join(" ", commands.Select(Quote)); @@ -421,7 +445,9 @@ public static class ProcessRunner catch (SystemException) { } throw new ProcessException( - "Process " + (processName == null ? "" : processName + " ") + $"failed with exit-code {process.ExitCode}." + "Process " + + (processName == null ? "" : processName + " ") + + $"failed with exit-code {process.ExitCode}." ); } } diff --git a/StabilityMatrix.Core/Updater/UpdateHelper.cs b/StabilityMatrix.Core/Updater/UpdateHelper.cs index eff50ce3..d1429182 100644 --- a/StabilityMatrix.Core/Updater/UpdateHelper.cs +++ b/StabilityMatrix.Core/Updater/UpdateHelper.cs @@ -32,7 +32,10 @@ public class UpdateHelper : IUpdateHelper public const string UpdateFolderName = ".StabilityMatrixUpdate"; public static DirectoryPath UpdateFolder => Compat.AppCurrentDir.JoinDir(UpdateFolderName); - public static FilePath ExecutablePath => UpdateFolder.JoinFile(Compat.GetExecutableName()); + public static IPathObject ExecutablePath => + Compat.IsMacOS + ? UpdateFolder.JoinDir(Compat.GetAppName()) + : UpdateFolder.JoinFile(Compat.GetAppName()); /// public event EventHandler? UpdateStatusChanged; @@ -123,7 +126,7 @@ public class UpdateHelper : IUpdateHelper .EnumerateFiles("*.*", SearchOption.AllDirectories) .First(f => f.Extension.ToLowerInvariant() is ".exe" or ".appimage"); - await binaryFile.MoveToAsync(ExecutablePath).ConfigureAwait(false); + await binaryFile.MoveToAsync((FilePath)ExecutablePath).ConfigureAwait(false); } else if (downloadFile.Extension == ".dmg") { @@ -139,9 +142,10 @@ public class UpdateHelper : IUpdateHelper // Extract dmg contents await ArchiveHelper.ExtractDmg(downloadFile, extractDir).ConfigureAwait(false); - // Find app and move it up to the root - var appFile = extractDir.EnumerateFiles("*.app").First(); - await appFile.MoveToAsync(ExecutablePath).ConfigureAwait(false); + // Find app dir and move it up to the root + var appBundle = extractDir.EnumerateDirectories("*.app").First(); + + await appBundle.MoveToAsync((DirectoryPath)ExecutablePath).ConfigureAwait(false); } // Otherwise just rename else @@ -210,10 +214,10 @@ public class UpdateHelper : IUpdateHelper new UpdateStatusChangedEventArgs { LatestUpdate = update, - UpdateChannels = updateManifest.Updates.ToDictionary( - kv => kv.Key, - kv => kv.Value.GetInfoForCurrentPlatform() - )! + UpdateChannels = updateManifest + .Updates.Select(kv => (kv.Key, kv.Value.GetInfoForCurrentPlatform())) + .Where(kv => kv.Item2 is not null) + .ToDictionary(kv => kv.Item1, kv => kv.Item2)! } ); return; @@ -222,15 +226,14 @@ public class UpdateHelper : IUpdateHelper logger.LogInformation("No update available"); - OnUpdateStatusChanged( - new UpdateStatusChangedEventArgs - { - UpdateChannels = updateManifest.Updates.ToDictionary( - kv => kv.Key, - kv => kv.Value.GetInfoForCurrentPlatform() - )! - } - ); + var args = new UpdateStatusChangedEventArgs + { + UpdateChannels = updateManifest + .Updates.Select(kv => (kv.Key, kv.Value.GetInfoForCurrentPlatform())) + .Where(kv => kv.Item2 is not null) + .ToDictionary(kv => kv.Item1, kv => kv.Item2)! + }; + OnUpdateStatusChanged(args); } catch (Exception e) {