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
5 changes: 4 additions & 1 deletion src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,10 @@ private static async Task<bool> PrepareAndLaunchAsync(

// Notify UI (update banner + toast)
Dispatcher.UIThread.Post(() => UpdateAvailable?.Invoke(versionName));
WindowsAppNotificationBridge.ShowSelfUpdateAvailableNotification(versionName);
if (OperatingSystem.IsWindows())
WindowsAppNotificationBridge.ShowSelfUpdateAvailableNotification(versionName);
else if (OperatingSystem.IsMacOS())
MacOsNotificationBridge.ShowSelfUpdateAvailableNotification(versionName);

if (autoLaunch)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,10 @@ private static void ShowOperationProgressNotification(AbstractOperation op)
$"{title}. {message}",
AutomationLiveSetting.Polite);

if (WindowsAppNotificationBridge.ShowProgress(op))
if (OperatingSystem.IsWindows() && WindowsAppNotificationBridge.ShowProgress(op))
return;

if (MacOsNotificationBridge.ShowProgress(op))
if (OperatingSystem.IsMacOS() && MacOsNotificationBridge.ShowProgress(op))
return;

if (TryGetMainWindow() is not { } mainWindow)
Expand Down Expand Up @@ -160,10 +160,10 @@ private static void ShowOperationSuccessNotification(AbstractOperation op)

WindowsAppNotificationBridge.RemoveProgress(op);

if (WindowsAppNotificationBridge.ShowSuccess(op))
if (OperatingSystem.IsWindows() && WindowsAppNotificationBridge.ShowSuccess(op))
return;

if (MacOsNotificationBridge.ShowSuccess(op))
if (OperatingSystem.IsMacOS() && MacOsNotificationBridge.ShowSuccess(op))
return;

if (TryGetMainWindow() is not { } mainWindow)
Expand Down Expand Up @@ -194,10 +194,10 @@ private static void ShowOperationFailureNotification(AbstractOperation op)

WindowsAppNotificationBridge.RemoveProgress(op);

if (WindowsAppNotificationBridge.ShowError(op))
if (OperatingSystem.IsWindows() && WindowsAppNotificationBridge.ShowError(op))
return;

if (MacOsNotificationBridge.ShowError(op))
if (OperatingSystem.IsMacOS() && MacOsNotificationBridge.ShowError(op))
return;

if (TryGetMainWindow() is not { } mainWindow)
Expand Down
104 changes: 50 additions & 54 deletions src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using UniGetUI.Core.Data;
using UniGetUI.Core.Logging;
Expand All @@ -9,37 +10,18 @@
namespace UniGetUI.Avalonia.Infrastructure;

/// <summary>
/// macOS system notification delivery via NSUserNotificationCenter (ObjC runtime P/Invoke).
/// Mirrors the pattern of WindowsAppNotificationBridge: guards on OS check, silent fallback on failure.
/// 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.
/// </summary>
internal static class MacOsNotificationBridge
{
private static bool? _available;
private static readonly object _lock = new();

private static bool IsAvailable()
{
if (!OperatingSystem.IsMacOS()) return false;
lock (_lock)
{
if (_available.HasValue) return _available.Value;
try
{
_available = objc_getClass("NSUserNotificationCenter") != IntPtr.Zero;
}
catch
{
_available = false;
}
return _available.Value;
}
}

// ── Operation notifications ────────────────────────────────────────────

public static bool ShowProgress(AbstractOperation operation)
{
if (!IsAvailable() || Settings.AreProgressNotificationsDisabled()) return false;
if (Settings.AreProgressNotificationsDisabled()) return false;
try
{
string title = operation.Metadata.Title.Length > 0
Expand All @@ -61,7 +43,7 @@ public static bool ShowProgress(AbstractOperation operation)

public static bool ShowSuccess(AbstractOperation operation)
{
if (!IsAvailable() || Settings.AreSuccessNotificationsDisabled()) return false;
if (Settings.AreSuccessNotificationsDisabled()) return false;
try
{
string title = operation.Metadata.SuccessTitle.Length > 0
Expand All @@ -83,7 +65,7 @@ public static bool ShowSuccess(AbstractOperation operation)

public static bool ShowError(AbstractOperation operation)
{
if (!IsAvailable() || Settings.AreErrorNotificationsDisabled()) return false;
if (Settings.AreErrorNotificationsDisabled()) return false;
try
{
string title = operation.Metadata.FailureTitle.Length > 0
Expand All @@ -107,7 +89,7 @@ public static bool ShowError(AbstractOperation operation)

public static void ShowUpdatesAvailableNotification(IReadOnlyList<IPackage> upgradable)
{
if (!IsAvailable() || Settings.AreUpdatesNotificationsDisabled()) return;
if (Settings.AreUpdatesNotificationsDisabled()) return;
try
{
string title, message;
Expand All @@ -131,9 +113,34 @@ public static void ShowUpdatesAvailableNotification(IReadOnlyList<IPackage> upgr
}
}

public static void ShowUpgradingPackagesNotification(IReadOnlyList<IPackage> upgradable)
{
if (Settings.AreUpdatesNotificationsDisabled()) return;
try
{
string title, message;
if (upgradable.Count == 1)
{
title = CoreTools.Translate("An update was found!");
message = CoreTools.Translate("{0} is being updated to version {1}",
upgradable[0].Name, upgradable[0].NewVersionString);
}
else
{
title = CoreTools.Translate("{0} packages are being updated", upgradable.Count);
message = string.Join(", ", upgradable.Select(p => p.Name));
}
DeliverNotification(title, message);
}
catch (Exception ex)
{
Logger.Warn("macOS upgrading-packages notification failed");
Logger.Warn(ex);
}
}

public static void ShowSelfUpdateAvailableNotification(string newVersion)
{
if (!IsAvailable()) return;
try
{
DeliverNotification(
Expand All @@ -149,7 +156,7 @@ public static void ShowSelfUpdateAvailableNotification(string newVersion)

public static void ShowNewShortcutsNotification(IReadOnlyList<string> shortcuts)
{
if (!IsAvailable() || Settings.AreNotificationsDisabled()) return;
if (Settings.AreNotificationsDisabled()) return;
try
{
string title, message;
Expand Down Expand Up @@ -180,38 +187,25 @@ public static void ShowNewShortcutsNotification(IReadOnlyList<string> shortcuts)

private static void DeliverNotification(string title, string message)
{
var centerClass = objc_getClass("NSUserNotificationCenter");
var center = MsgSend(centerClass, Sel("defaultUserNotificationCenter"));

var notifClass = objc_getClass("NSUserNotification");
var notif = MsgSend(MsgSend(notifClass, Sel("alloc")), Sel("init"));

MsgSend(notif, Sel("setTitle:"), ToNSString(title));
MsgSend(notif, Sel("setInformativeText:"), ToNSString(message));
MsgSend(center, Sel("deliverNotification:"), notif);
MsgSend(notif, Sel("autorelease"));
}

private static IntPtr ToNSString(string s)
{
IntPtr ptr = Marshal.StringToCoTaskMemUTF8(s);
try
{
return MsgSend(objc_getClass("NSString"), Sel("stringWithUTF8String:"), ptr);
}
finally
// 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
{
Marshal.FreeCoTaskMem(ptr);
}
FileName = "/usr/bin/osascript",
ArgumentList = { "-e", script },
UseShellExecute = false,
CreateNoWindow = true,
});
}

private static IntPtr Sel(string name) => sel_registerName(name);
private static string AppleScriptString(string s) =>
"\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";

// ── Dock icon ──────────────────────────────────────────────────────────

public static void SetDockIcon(byte[] pngBytes)
{
if (!OperatingSystem.IsMacOS()) return;
try
{
var handle = GCHandle.Alloc(pngBytes, GCHandleType.Pinned);
Expand Down Expand Up @@ -241,7 +235,9 @@ public static void SetDockIcon(byte[] pngBytes)
}
}

// ── ObjC runtime P/Invoke ──────────────────────────────────────────────
private static IntPtr Sel(string name) => sel_registerName(name);

// ── ObjC runtime P/Invoke (used by SetDockIcon) ────────────────────────

[DllImport("/usr/lib/libobjc.A.dylib")]
private static extern IntPtr objc_getClass(string name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,58 @@ public static void ShowUpdatesAvailableNotification(IReadOnlyList<IPackage> upgr
}
}

/// <summary>
/// Shows a Windows toast notification when packages are actively being updated (auto-update triggered).
/// </summary>
public static void ShowUpgradingPackagesNotification(IReadOnlyList<IPackage> upgradable)
{
if (!EnsureRegistered()) return;
if (Settings.AreUpdatesNotificationsDisabled()) return;

bool sendNotification = upgradable.Any(p =>
!Settings.GetDictionaryItem<string, bool>(
Settings.K.DisabledPackageManagerNotifications, p.Manager.Name));
if (!sendNotification) return;

try
{
_removeByTagAsyncMethod?.Invoke(_managerDefault,
[CoreData.UpdatesAvailableNotificationTag.ToString()]);

if (upgradable.Count == 1)
{
ShowTextToast(
tag: CoreData.UpdatesAvailableNotificationTag.ToString(),
scenario: ScenarioDefault,
title: CoreTools.Translate("An update was found!"),
message: CoreTools.Translate("{0} is being updated to version {1}",
upgradable[0].Name, upgradable[0].NewVersionString),
suppressDisplay: false,
defaultAction: NotificationArguments.ShowOnUpdatesTab);
}
else
{
string attribution = string.Join(", ", upgradable
.Where(p => !Settings.GetDictionaryItem<string, bool>(
Settings.K.DisabledPackageManagerNotifications, p.Manager.Name))
.Select(p => p.Name));

ShowTextToast(
tag: CoreData.UpdatesAvailableNotificationTag.ToString(),
scenario: ScenarioDefault,
title: CoreTools.Translate("{0} packages are being updated", upgradable.Count),
message: attribution,
suppressDisplay: false,
defaultAction: NotificationArguments.ShowOnUpdatesTab);
}
}
catch (Exception ex)
{
Logger.Warn("Could not show upgrading-packages notification");
Logger.Warn(ex);
}
}

/// <summary>
/// Shows a Windows toast offering a UniGetUI self-update with an "Update now" button.
/// </summary>
Expand Down
35 changes: 27 additions & 8 deletions src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
using UniGetUI.Core.Data;
using UniGetUI.Core.SettingsEngine;
using UniGetUI.Core.Tools;
using UniGetUI.Interface.Enums;
using UniGetUI.PackageEngine;
using UniGetUI.PackageEngine.Enums;
using UniGetUI.PackageEngine.Interfaces;
Expand Down Expand Up @@ -150,16 +151,12 @@ public MainWindowViewModel()
Dispatcher.UIThread.Post(() =>
Sidebar.UpdatesBadgeCount = upgLoader.Count());
Sidebar.UpdatesBadgeCount = upgLoader.Count();

upgLoader.FinishedLoading += (_, _) =>
{
var upgradable = upgLoader.Packages.ToList();
if (upgradable.Count == 0) return;
WindowsAppNotificationBridge.ShowUpdatesAvailableNotification(upgradable);
MacOsNotificationBridge.ShowUpdatesAvailableNotification(upgradable);
};
// Notifications and auto-update logic are handled by SoftwareUpdatesPage.WhenPackagesLoaded
}

WindowsAppNotificationBridge.NotificationActivated += action =>
Dispatcher.UIThread.Post(() => HandleNotificationActivation(action));

BundlesPage.UnsavedChangesStateChanged += (_, _) =>
Dispatcher.UIThread.Post(() =>
Sidebar.BundlesBadgeVisible = BundlesPage.HasUnsavedChanges);
Expand Down Expand Up @@ -405,6 +402,28 @@ private async Task ShowAboutDialog()
Sidebar.SelectNavButtonForPage(_currentPage);
}

// ─── Notification activation ─────────────────────────────────────────────
private void HandleNotificationActivation(string action)
{
if (action == NotificationArguments.UpdateAllPackages)
{
_ = AvaloniaPackageOperationHelper.UpdateAllAsync();
}
else if (action == NotificationArguments.ShowOnUpdatesTab)
{
NavigateTo(PageType.Updates);
MainWindow.Instance?.ShowFromTray();
}
else if (action == NotificationArguments.Show)
{
MainWindow.Instance?.ShowFromTray();
}
else if (action == NotificationArguments.ReleaseSelfUpdateLock)
{
AvaloniaAutoUpdater.ReleaseLockForAutoupdate_Notification = true;
}
}

// ─── Search box ──────────────────────────────────────────────────────────
[RelayCommand]
public void SubmitGlobalSearch()
Expand Down
Loading
Loading