From 7ba869899cb840391f61f9843d5c54e21fc10cd7 Mon Sep 17 00:00:00 2001 From: Marc-Andre Moreau Date: Thu, 16 Jul 2026 16:38:02 -0400 Subject: [PATCH 1/2] Fix macOS notification activation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- scripts/macos/UniGetUIMacNotificationBridge.m | 91 +++++++++++++++++++ .../Infrastructure/MacOsNotificationBridge.cs | 73 +++++++++++---- .../UniGetUI.Avalonia.csproj | 22 +++++ 3 files changed, 168 insertions(+), 18 deletions(-) create mode 100644 scripts/macos/UniGetUIMacNotificationBridge.m diff --git a/scripts/macos/UniGetUIMacNotificationBridge.m b/scripts/macos/UniGetUIMacNotificationBridge.m new file mode 100644 index 0000000000..5bc7fd636d --- /dev/null +++ b/scripts/macos/UniGetUIMacNotificationBridge.m @@ -0,0 +1,91 @@ +#import +#import + +typedef void (*UniGetUINotificationActivatedCallback)(void); + +@interface UniGetUINotificationDelegate : NSObject +@property(nonatomic, assign) UniGetUINotificationActivatedCallback activationCallback; +@end + +@implementation UniGetUINotificationDelegate + +- (void)userNotificationCenter:(UNUserNotificationCenter *)center +didReceiveNotificationResponse:(UNNotificationResponse *)response + withCompletionHandler:(void (^)(void))completionHandler +{ + if (self.activationCallback != NULL) { + self.activationCallback(); + } + + completionHandler(); +} + +- (void)userNotificationCenter:(UNUserNotificationCenter *)center + willPresentNotification:(UNNotification *)notification + withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler +{ + completionHandler(UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionList); +} + +@end + +static UniGetUINotificationDelegate *notificationDelegate; +static UNUserNotificationCenter *notificationCenter; + +int UniGetUIInitializeMacNotifications(UniGetUINotificationActivatedCallback activationCallback) +{ + @autoreleasepool { + notificationCenter = [UNUserNotificationCenter currentNotificationCenter]; + if (notificationCenter == nil) { + return 0; + } + + if (notificationDelegate == nil) { + notificationDelegate = [[UniGetUINotificationDelegate alloc] init]; + } + + notificationDelegate.activationCallback = activationCallback; + notificationCenter.delegate = notificationDelegate; + + [notificationCenter requestAuthorizationWithOptions: + UNAuthorizationOptionAlert | UNAuthorizationOptionSound + completionHandler:^(BOOL granted, NSError *error) { + if (!granted || error != nil) { + NSLog(@"UniGetUI notification authorization was not granted: %@", error); + } + }]; + return 1; + } +} + +int UniGetUIShowMacNotification(const char *title, const char *message) +{ + @autoreleasepool { + if (notificationCenter == nil || title == NULL || message == NULL) { + return 0; + } + + NSString *notificationTitle = [NSString stringWithUTF8String:title]; + NSString *notificationMessage = [NSString stringWithUTF8String:message]; + if (notificationTitle == nil || notificationMessage == nil) { + return 0; + } + + UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init]; + content.title = notificationTitle; + content.body = notificationMessage; + + UNNotificationRequest *request = [UNNotificationRequest + requestWithIdentifier:[[NSUUID UUID] UUIDString] + content:content + trigger:nil]; + + [notificationCenter addNotificationRequest:request + withCompletionHandler:^(NSError *error) { + if (error != nil) { + NSLog(@"UniGetUI notification delivery failed: %@", error); + } + }]; + return 1; + } +} diff --git a/src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs b/src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs index 3648cc501f..e3e853f030 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs @@ -1,4 +1,7 @@ -using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Avalonia.Threading; +using UniGetUI.Avalonia.Views; using UniGetUI.Core.Data; using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; @@ -9,13 +12,16 @@ namespace UniGetUI.Avalonia.Infrastructure; /// -/// macOS system notification delivery via osascript (works on all macOS versions). -/// NSUserNotificationCenter was removed in macOS 14; UNUserNotificationCenter requires -/// ObjC blocks that are impractical via pure P/Invoke. osascript is always available. -/// Callers are responsible for the OperatingSystem.IsMacOS() guard before invoking. +/// macOS system notification delivery through the app-bundled UserNotifications bridge. +/// The bridge owns the Objective-C delegate and completion blocks so this NativeAOT assembly +/// can use a fixed P/Invoke boundary and receive default notification activations. /// -internal static class MacOsNotificationBridge +internal static partial class MacOsNotificationBridge { + private const string NativeBridgePath = "@executable_path/UniGetUIMacNotificationBridge.dylib"; + private static readonly Lock InitializationLock = new(); + private static bool _isInitialized; + // ── Operation notifications ──────────────────────────────────────────── public static bool ShowProgress(AbstractOperation operation) @@ -186,18 +192,49 @@ public static void ShowNewShortcutsNotification(IReadOnlyList shortcuts) private static void DeliverNotification(string title, string message) { - // NSUserNotificationCenter was removed in macOS 14; osascript works on all versions. - string script = "display notification " + AppleScriptString(message) - + " with title " + AppleScriptString(title); - Process.Start(new ProcessStartInfo - { - FileName = "/usr/bin/osascript", - ArgumentList = { "-e", script }, - UseShellExecute = false, - CreateNoWindow = true, - }); + if (!EnsureInitialized()) + throw new InvalidOperationException("The macOS notification bridge is unavailable."); + + if (ShowNativeNotification(title, message) == 0) + { + throw new InvalidOperationException("The macOS notification bridge rejected the notification."); + } + } + + private static unsafe bool EnsureInitialized() + { + if (_isInitialized) + return true; + + lock (InitializationLock) + { + if (_isInitialized) + return true; + + try + { + _isInitialized = InitializeNativeNotifications( + (nint)(delegate* unmanaged[Cdecl])&OnNotificationActivated) != 0; + return _isInitialized; + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + Logger.Warn("The macOS notification bridge is unavailable."); + Logger.Warn(ex); + return false; + } + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void OnNotificationActivated() + { + Dispatcher.UIThread.Post(() => MainWindow.Instance?.ShowFromTray()); } - private static string AppleScriptString(string s) => - "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + [LibraryImport(NativeBridgePath, EntryPoint = "UniGetUIInitializeMacNotifications")] + private static partial int InitializeNativeNotifications(nint activationCallback); + + [LibraryImport(NativeBridgePath, EntryPoint = "UniGetUIShowMacNotification", StringMarshalling = StringMarshalling.Utf8)] + private static partial int ShowNativeNotification(string title, string message); } diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj index 2bb37579d3..ce1121ac36 100644 --- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj +++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -92,6 +92,28 @@ ContinueOnError="true" /> + + + <_MacNotificationBridgeSource>$(MSBuildProjectDirectory)/../../scripts/macos/UniGetUIMacNotificationBridge.m + <_MacNotificationBridgeOutput>$(TargetDir)UniGetUIMacNotificationBridge.dylib + + + + + + + <_MacNotificationBridgeSource>$(MSBuildProjectDirectory)/../../scripts/macos/UniGetUIMacNotificationBridge.m + <_MacNotificationBridgeOutput>$(PublishDir)UniGetUIMacNotificationBridge.dylib + <_MacNotificationBridgeArchitecture Condition="'$(RuntimeIdentifier)' == 'osx-arm64'">arm64 + <_MacNotificationBridgeArchitecture Condition="'$(RuntimeIdentifier)' == 'osx-x64'">x86_64 + + + + + $(DefineConstants);AVALONIA_DIAGNOSTICS_ENABLED From df6cf3475b84674037becd7303303e2ce3794fcc Mon Sep 17 00:00:00 2001 From: Gabriel Dufresne Date: Fri, 17 Jul 2026 09:33:35 -0400 Subject: [PATCH 2/2] Fix macOS notification click launching a second instance --- .../Infrastructure/AvaloniaAppHost.cs | 43 ++++++++++++++++--- .../SingleInstanceRedirector.cs | 16 ++++++- .../UniGetUI.Avalonia.csproj | 12 ++++++ 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs index 57a7722b87..80ab02c03a 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Runtime.InteropServices; using Avalonia; #if WINDOWS @@ -14,6 +15,7 @@ namespace UniGetUI.Avalonia.Infrastructure; public static class AvaloniaAppHost { private static Mutex? _singleInstanceMutex; + private static FileStream? _singleInstanceLock; public static event Action? SecondaryInstanceArgsReceived; @@ -110,9 +112,19 @@ private static bool ShouldPrepareCliConsole(IReadOnlyList args) private static bool TryRegisterSingleInstance(string[] args) { - if (!OperatingSystem.IsWindows()) - return true; + // macOS uses a file lock instead of a Mutex: named Mutexes are not shared across processes + // under NativeAOT (what ships), so they can't detect the first instance. + if (OperatingSystem.IsWindows()) + return TryRegisterWithMutex(args); + + if (OperatingSystem.IsMacOS()) + return TryRegisterWithFileLock(args); + + return true; + } + private static bool TryRegisterWithMutex(string[] args) + { _singleInstanceMutex = new Mutex( initiallyOwned: true, name: CoreData.MainWindowIdentifier, @@ -121,9 +133,7 @@ private static bool TryRegisterSingleInstance(string[] args) if (createdNew) { - SingleInstanceRedirector.StartListener(args => - SecondaryInstanceArgsReceived?.Invoke(args) - ); + SingleInstanceRedirector.StartListener(a => SecondaryInstanceArgsReceived?.Invoke(a)); return true; } @@ -137,4 +147,27 @@ private static bool TryRegisterSingleInstance(string[] args) Logger.Warn("Could not redirect to the existing Avalonia instance; starting a new one"); return true; } + + private static bool TryRegisterWithFileLock(string[] args) + { + // FileShare.None is an flock() advisory lock the OS releases on exit (even on crash), so the + // lock file never goes stale. The static FileStream holds the lock for the process lifetime. + string lockPath = Path.Combine(Path.GetTempPath(), $"UniGetUI_{Environment.UserName}.lock"); + try + { + _singleInstanceLock = new FileStream( + lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } + catch (IOException) + { + if (SingleInstanceRedirector.TryForwardToFirstInstance(args)) + return false; + + Logger.Warn("Could not redirect to the existing Avalonia instance; starting a new one"); + return true; + } + + SingleInstanceRedirector.StartListener(a => SecondaryInstanceArgsReceived?.Invoke(a)); + return true; + } } diff --git a/src/UniGetUI.Avalonia/Infrastructure/SingleInstanceRedirector.cs b/src/UniGetUI.Avalonia/Infrastructure/SingleInstanceRedirector.cs index add5a1d3e9..a7f753be5c 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/SingleInstanceRedirector.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/SingleInstanceRedirector.cs @@ -1,4 +1,5 @@ using System.IO.Pipes; +using System.Security.Cryptography; using System.Text; using Avalonia.Threading; using UniGetUI.Core.Data; @@ -17,8 +18,19 @@ namespace UniGetUI.Avalonia.Infrastructure; internal static class SingleInstanceRedirector { // One pipe name per user session (the MainWindowIdentifier is a stable constant). - private static readonly string PipeName = - $"UniGetUI_Pipe_{CoreData.MainWindowIdentifier}_{Environment.UserName}"; + private static readonly string PipeName = BuildPipeName(); + + // On Unix the pipe is a domain socket at $TMPDIR/CoreFxPipe_, capped at 104 chars (macOS + // sun_path). The descriptive Windows name overflows that, so non-Windows uses a short stable hash. + private static string BuildPipeName() + { + string name = $"UniGetUI_Pipe_{CoreData.MainWindowIdentifier}_{Environment.UserName}"; + if (OperatingSystem.IsWindows()) + return name; + + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(name)); + return "UniGetUI_" + Convert.ToHexString(hash, 0, 6); + } private static Thread? _listener; diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj index ce1121ac36..6f20dba70c 100644 --- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj +++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -101,6 +101,18 @@ + + + + <_MacScriptsDir>$(MSBuildProjectDirectory)/../../scripts/macos + + + +