Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions scripts/macos/UniGetUIMacNotificationBridge.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#import <Foundation/Foundation.h>
#import <UserNotifications/UserNotifications.h>

typedef void (*UniGetUINotificationActivatedCallback)(void);

@interface UniGetUINotificationDelegate : NSObject <UNUserNotificationCenterDelegate>
@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;
}
}
43 changes: 38 additions & 5 deletions src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.IO;
using System.Runtime.InteropServices;
using Avalonia;
#if WINDOWS
Expand All @@ -14,6 +15,7 @@ namespace UniGetUI.Avalonia.Infrastructure;
public static class AvaloniaAppHost
{
private static Mutex? _singleInstanceMutex;
private static FileStream? _singleInstanceLock;

public static event Action<string[]>? SecondaryInstanceArgsReceived;

Expand Down Expand Up @@ -110,9 +112,19 @@ private static bool ShouldPrepareCliConsole(IReadOnlyList<string> 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,
Expand All @@ -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;
}

Expand All @@ -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;
}
}
73 changes: 55 additions & 18 deletions src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,13 +12,16 @@
namespace UniGetUI.Avalonia.Infrastructure;

/// <summary>
/// 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.
/// </summary>
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)
Expand Down Expand Up @@ -186,18 +192,49 @@ public static void ShowNewShortcutsNotification(IReadOnlyList<string> 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]<void>)&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);
}
16 changes: 14 additions & 2 deletions src/UniGetUI.Avalonia/Infrastructure/SingleInstanceRedirector.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.IO.Pipes;
using System.Security.Cryptography;
using System.Text;
using Avalonia.Threading;
using UniGetUI.Core.Data;
Expand All @@ -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_<name>, 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;

Expand Down
34 changes: 34 additions & 0 deletions src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,40 @@
ContinueOnError="true" />
</Target>

<Target Name="BuildMacNotificationBridgeForDebug" AfterTargets="Build"
Condition="$([MSBuild]::IsOSPlatform('OSX')) And '$(Configuration)' == 'Debug'">
<PropertyGroup>
<_MacNotificationBridgeSource>$(MSBuildProjectDirectory)/../../scripts/macos/UniGetUIMacNotificationBridge.m</_MacNotificationBridgeSource>
<_MacNotificationBridgeOutput>$(TargetDir)UniGetUIMacNotificationBridge.dylib</_MacNotificationBridgeOutput>
</PropertyGroup>
<Exec Command="xcrun clang -dynamiclib -fobjc-arc -mmacosx-version-min=12.0 -framework Foundation -framework UserNotifications -install_name @executable_path/UniGetUIMacNotificationBridge.dylib -o '$(_MacNotificationBridgeOutput)' '$(_MacNotificationBridgeSource)'" />
</Target>

<!-- Ad-hoc codesign the debug .app so macOS binds it to CFBundleIdentifier and seals Info.plist;
without a signature UNUserNotificationCenter has no bundle identity and notifications fail.
"deep" also signs the dylib; entitlements disable library validation so it can be loaded. -->
<Target Name="CodesignMacDebugAppBundle"
AfterTargets="AssembleMacDebugAppBundle;BuildMacNotificationBridgeForDebug"
Condition="$([MSBuild]::IsOSPlatform('OSX')) And '$(Configuration)' == 'Debug'">
<PropertyGroup>
<_MacScriptsDir>$(MSBuildProjectDirectory)/../../scripts/macos</_MacScriptsDir>
</PropertyGroup>
<Exec Command="codesign --force --deep --entitlements '$(_MacScriptsDir)/Entitlements.plist' --sign - '$(MacAppBundleDir)'" />
</Target>

<Target Name="BuildMacNotificationBridgeForPublish" AfterTargets="Publish"
Condition="$([MSBuild]::IsOSPlatform('OSX')) And '$(Configuration)' != 'Debug'">
<PropertyGroup>
<_MacNotificationBridgeSource>$(MSBuildProjectDirectory)/../../scripts/macos/UniGetUIMacNotificationBridge.m</_MacNotificationBridgeSource>
<_MacNotificationBridgeOutput>$(PublishDir)UniGetUIMacNotificationBridge.dylib</_MacNotificationBridgeOutput>
<_MacNotificationBridgeArchitecture Condition="'$(RuntimeIdentifier)' == 'osx-arm64'">arm64</_MacNotificationBridgeArchitecture>
<_MacNotificationBridgeArchitecture Condition="'$(RuntimeIdentifier)' == 'osx-x64'">x86_64</_MacNotificationBridgeArchitecture>
</PropertyGroup>
<Error Condition="'$(_MacNotificationBridgeArchitecture)' == ''"
Text="A macOS RuntimeIdentifier is required to build the notification bridge." />
<Exec Command="xcrun clang -dynamiclib -fobjc-arc -arch $(_MacNotificationBridgeArchitecture) -mmacosx-version-min=12.0 -framework Foundation -framework UserNotifications -install_name @executable_path/UniGetUIMacNotificationBridge.dylib -o '$(_MacNotificationBridgeOutput)' '$(_MacNotificationBridgeSource)'" />
</Target>

<PropertyGroup Condition="'$(EnableAvaloniaDiagnostics)' == 'true'">
<DefineConstants>$(DefineConstants);AVALONIA_DIAGNOSTICS_ENABLED</DefineConstants>
</PropertyGroup>
Expand Down
Loading