diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index 2522f1ef6d..775b1201c1 100644
--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -405,6 +405,17 @@ jobs:
sed "s|@VERSION@|${VERSION}|g" \
scripts/macos/Info.plist \
> "${APP_BUNDLE}/Contents/Info.plist"
+ # Compile the appearance-aware app icon (Default/Dark/Tinted/Clear) from the Icon Composer
+ # .icon into the bundle: produces Assets.car + AppIcon.icns. Info.plist already declares
+ # CFBundleIconName=AppIcon, so macOS renders the appearance styles itself (incl. when closed).
+ xcrun actool scripts/macos/AppIcon.icon \
+ --compile "${APP_BUNDLE}/Contents/Resources" \
+ --app-icon AppIcon \
+ --output-partial-info-plist "$(mktemp).plist" \
+ --platform macosx \
+ --minimum-deployment-target 12.0 \
+ --errors --warnings
+ # Static .icns fallback for macOS < 26 (CFBundleIconFile) and the dmg volume icon.
cp scripts/macos/UniGetUI.icns "${APP_BUNDLE}/Contents/Resources/"
if [ "$DRY_RUN" = "false" ]; then
diff --git a/scripts/macos/AppIcon.icon/Assets/devolutions-unigetui-icon.svg b/scripts/macos/AppIcon.icon/Assets/devolutions-unigetui-icon.svg
new file mode 100644
index 0000000000..7f994d9c13
--- /dev/null
+++ b/scripts/macos/AppIcon.icon/Assets/devolutions-unigetui-icon.svg
@@ -0,0 +1,3 @@
+
diff --git a/scripts/macos/AppIcon.icon/icon.json b/scripts/macos/AppIcon.icon/icon.json
new file mode 100644
index 0000000000..72819041b8
--- /dev/null
+++ b/scripts/macos/AppIcon.icon/icon.json
@@ -0,0 +1,52 @@
+{
+ "fill" : {
+ "linear-gradient" : [
+ "display-p3:1.00000,0.99646,0.99994,1.00000",
+ "display-p3:0.96783,0.93622,0.94438,1.00000"
+ ],
+ "orientation" : {
+ "start" : {
+ "x" : 0.5,
+ "y" : 0
+ },
+ "stop" : {
+ "x" : 0.5,
+ "y" : 0.7
+ }
+ }
+ },
+ "groups" : [
+ {
+ "layers" : [
+ {
+ "blend-mode" : "normal",
+ "glass" : false,
+ "hidden" : false,
+ "image-name" : "devolutions-unigetui-icon.svg",
+ "name" : "devolutions-unigetui-icon",
+ "position" : {
+ "scale" : 16,
+ "translation-in-points" : [
+ 18.7109375,
+ -22.06000000000006
+ ]
+ }
+ }
+ ],
+ "shadow" : {
+ "kind" : "neutral",
+ "opacity" : 0.5
+ },
+ "translucency" : {
+ "enabled" : true,
+ "value" : 0.5
+ }
+ }
+ ],
+ "supported-platforms" : {
+ "circles" : [
+ "watchOS"
+ ],
+ "squares" : "shared"
+ }
+}
\ No newline at end of file
diff --git a/scripts/macos/Info.plist b/scripts/macos/Info.plist
index 5b3a87f7a5..978935131f 100644
--- a/scripts/macos/Info.plist
+++ b/scripts/macos/Info.plist
@@ -10,6 +10,10 @@
UniGetUI
CFBundleExecutable
UniGetUI.Avalonia
+
+ CFBundleIconName
+ AppIcon
CFBundleIconFile
UniGetUI
CFBundlePackageType
diff --git a/scripts/macos/UniGetUI.icns b/scripts/macos/UniGetUI.icns
index 768441c4b9..fd25c1e0af 100644
Binary files a/scripts/macos/UniGetUI.icns and b/scripts/macos/UniGetUI.icns differ
diff --git a/src/UniGetUI.Avalonia/App.axaml.cs b/src/UniGetUI.Avalonia/App.axaml.cs
index 67ec82f6f1..1d75b8aa72 100644
--- a/src/UniGetUI.Avalonia/App.axaml.cs
+++ b/src/UniGetUI.Avalonia/App.axaml.cs
@@ -1,9 +1,11 @@
+using System;
using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
using System.IO;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
-using Avalonia.Platform;
+using Avalonia.Markup.Xaml.Styling;
using Avalonia.Styling;
using Avalonia.Threading;
#if AVALONIA_DIAGNOSTICS_ENABLED
@@ -24,11 +26,34 @@ public partial class App : Application
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
+
+ // Windows 11 Mica look is opt-in per environment: only merge the translucent
+ // surface overrides when Mica is actually usable (Win11 + transparency on).
+ // macOS, Linux, Windows 10, and transparency-off all keep the solid Styles.Common look.
+ if (MicaWindowHelper.IsMicaEnabled())
+ ApplyWindowsMicaStyling();
#if AVALONIA_DIAGNOSTICS_ENABLED
this.AttachDeveloperTools();
#endif
}
+ // ResourceInclude is flagged with RequiresUnreferencedCode because, in general, it can load
+ // resources from other assemblies that trimming might remove. Styles.WindowsMica.axaml is an
+ // avares resource embedded in THIS assembly, so it is never trimmed — the warning is safe to
+ // suppress here. (It can't be declared in XAML because the merge is conditional at runtime.)
+ [UnconditionalSuppressMessage("Trimming", "IL2026",
+ Justification = "Styles.WindowsMica.axaml is an avares resource in this assembly and is not trimmed.")]
+ private void ApplyWindowsMicaStyling()
+ {
+ Resources.MergedDictionaries.Add(new ResourceInclude((Uri?)null)
+ {
+ Source = new Uri("avares://UniGetUI.Avalonia/Assets/Styles/Styles.WindowsMica.axaml")
+ });
+ // Give flyouts/menus/tooltips a native acrylic backdrop (DWM) so they blur + tint
+ // from behind and adapt to the theme.
+ MicaWindowHelper.EnableAcrylicPopups();
+ }
+
public override void OnFrameworkInitializationCompleted()
{
if (OperatingSystem.IsWindows())
@@ -81,11 +106,11 @@ private static void StartMainWindow(IClassicDesktopStyleApplicationLifetime desk
{
if (OperatingSystem.IsMacOS())
{
+ // The Dock icon (incl. Default/Dark/Tinted/Clear styling) is provided by the .app bundle's
+ // AppIcon (scripts/macos/AppIcon.icon → Assets.car, via CFBundleIconName) and rendered by
+ // the system — for packaged releases and for Debug builds, which also build into a .app
+ // (see UniGetUI.Avalonia.csproj). There is nothing to do at runtime.
ProcessEnvironmentConfigurator.PrepareForCurrentPlatform();
- using var stream = AssetLoader.Open(new Uri("avares://UniGetUI.Avalonia/Assets/icon.png"));
- using var ms = new MemoryStream();
- stream.CopyTo(ms);
- MacOsNotificationBridge.SetDockIcon(ms.ToArray());
}
else
{
diff --git a/src/UniGetUI.Avalonia/Assets/Styles/Styles.Common.axaml b/src/UniGetUI.Avalonia/Assets/Styles/Styles.Common.axaml
index 807dbea918..c3973aeb46 100644
--- a/src/UniGetUI.Avalonia/Assets/Styles/Styles.Common.axaml
+++ b/src/UniGetUI.Avalonia/Assets/Styles/Styles.Common.axaml
@@ -36,6 +36,8 @@
+
+
@@ -97,6 +99,8 @@
+
+
diff --git a/src/UniGetUI.Avalonia/Assets/Styles/Styles.WindowsMica.axaml b/src/UniGetUI.Avalonia/Assets/Styles/Styles.WindowsMica.axaml
new file mode 100644
index 0000000000..6d44525f10
--- /dev/null
+++ b/src/UniGetUI.Avalonia/Assets/Styles/Styles.WindowsMica.axaml
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/UniGetUI.Avalonia/Infrastructure/DirectionalSlideTransition.cs b/src/UniGetUI.Avalonia/Infrastructure/DirectionalSlideTransition.cs
new file mode 100644
index 0000000000..db92102df0
--- /dev/null
+++ b/src/UniGetUI.Avalonia/Infrastructure/DirectionalSlideTransition.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Media;
+using Avalonia.Styling;
+using Avalonia.VisualTree;
+
+namespace UniGetUI.Avalonia.Infrastructure;
+
+///
+/// Horizontal page slide whose direction is set explicitly via .
+/// (TransitioningContentControl always reports forward navigation, so the caller toggles this
+/// before changing content.) Reverse=false slides the incoming page in from the right
+/// (drill-in); Reverse=true slides it in from the left (back navigation).
+/// Scrollbars are hidden for the duration so they don't drag across the view.
+///
+public sealed class DirectionalSlideTransition : IPageTransition
+{
+ public TimeSpan Duration { get; set; } = TimeSpan.FromMilliseconds(220);
+
+ public bool Reverse { get; set; }
+
+ public async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken)
+ {
+ double sign = Reverse ? -1d : 1d;
+ double width = (to ?? from)?.GetVisualParent()?.Bounds.Width
+ ?? (to ?? from)?.Bounds.Width ?? 0d;
+
+ var hidden = new List();
+ HideScrollBars(from, hidden);
+ HideScrollBars(to, hidden);
+
+ try
+ {
+ var tasks = new List();
+ if (from is not null)
+ tasks.Add(Slide(from, 0d, -sign * width, cancellationToken));
+ if (to is not null)
+ tasks.Add(Slide(to, sign * width, 0d, cancellationToken));
+ await Task.WhenAll(tasks);
+ }
+ finally
+ {
+ foreach (var sv in hidden)
+ sv.VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
+ }
+
+ if (cancellationToken.IsCancellationRequested)
+ return;
+
+ // Hide before clearing the transform so the outgoing page never snaps back on-screen.
+ if (from is not null)
+ {
+ from.IsVisible = false;
+ from.RenderTransform = null;
+ }
+ to?.RenderTransform = null;
+ }
+
+ private static void HideScrollBars(Visual? root, List hidden)
+ {
+ if (root is null)
+ return;
+
+ foreach (var sv in root.GetVisualDescendants().OfType())
+ {
+ if (sv.VerticalScrollBarVisibility == ScrollBarVisibility.Auto)
+ {
+ sv.VerticalScrollBarVisibility = ScrollBarVisibility.Hidden;
+ hidden.Add(sv);
+ }
+ }
+ }
+
+ private Task Slide(Visual target, double fromX, double toX, CancellationToken cancellationToken)
+ {
+ var anim = new Animation
+ {
+ Duration = Duration,
+ Easing = new CubicEaseInOut(),
+ FillMode = FillMode.Forward,
+ Children =
+ {
+ new KeyFrame { Cue = new Cue(0d), Setters = { new Setter(TranslateTransform.XProperty, fromX) } },
+ new KeyFrame { Cue = new Cue(1d), Setters = { new Setter(TranslateTransform.XProperty, toX) } },
+ },
+ };
+ return anim.RunAsync(target, cancellationToken);
+ }
+}
diff --git a/src/UniGetUI.Avalonia/Infrastructure/EntrancePageTransition.cs b/src/UniGetUI.Avalonia/Infrastructure/EntrancePageTransition.cs
new file mode 100644
index 0000000000..625de2387f
--- /dev/null
+++ b/src/UniGetUI.Avalonia/Infrastructure/EntrancePageTransition.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Media;
+using Avalonia.Styling;
+
+namespace UniGetUI.Avalonia.Infrastructure;
+
+///
+/// WinUI-style page entrance: the incoming page fades in while sliding up a few pixels
+/// (mimics the Frame NavigationThemeTransition); the outgoing page fades out.
+///
+public sealed class EntrancePageTransition : IPageTransition
+{
+ public TimeSpan Duration { get; set; } = TimeSpan.FromMilliseconds(220);
+
+ /// How far (px) the incoming page slides up as it fades in.
+ public double VerticalOffset { get; set; } = 28;
+
+ public async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken)
+ {
+ // Drop the outgoing page immediately so only the incoming page animates in.
+ from?.Opacity = 0;
+
+ if (to is null)
+ return;
+
+ var enter = new Animation
+ {
+ Duration = Duration,
+ Easing = new CubicEaseOut(),
+ FillMode = FillMode.Forward,
+ Children =
+ {
+ new KeyFrame
+ {
+ Cue = new Cue(0d),
+ Setters =
+ {
+ new Setter(Visual.OpacityProperty, 0d),
+ new Setter(TranslateTransform.YProperty, VerticalOffset),
+ },
+ },
+ new KeyFrame
+ {
+ Cue = new Cue(1d),
+ Setters =
+ {
+ new Setter(Visual.OpacityProperty, 1d),
+ new Setter(TranslateTransform.YProperty, 0d),
+ },
+ },
+ },
+ };
+
+ try
+ {
+ await enter.RunAsync(to, cancellationToken);
+ }
+ finally
+ {
+ // Restore even if cancelled, so the presenter is never reused while stranded invisible.
+ from?.Opacity = 1;
+ }
+ }
+}
diff --git a/src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs b/src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs
index a8748f3a39..3648cc501f 100644
--- a/src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs
+++ b/src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs
@@ -1,5 +1,4 @@
using System.Diagnostics;
-using System.Runtime.InteropServices;
using UniGetUI.Core.Data;
using UniGetUI.Core.Logging;
using UniGetUI.Core.SettingsEngine;
@@ -201,56 +200,4 @@ private static void DeliverNotification(string title, string message)
private static string AppleScriptString(string s) =>
"\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
-
- // ── Dock icon ──────────────────────────────────────────────────────────
-
- public static void SetDockIcon(byte[] pngBytes)
- {
- try
- {
- var handle = GCHandle.Alloc(pngBytes, GCHandleType.Pinned);
- try
- {
- IntPtr nsData = MsgSendBytes(
- objc_getClass("NSData"), Sel("dataWithBytes:length:"),
- handle.AddrOfPinnedObject(), pngBytes.Length);
-
- IntPtr nsImage = MsgSend(
- MsgSend(objc_getClass("NSImage"), Sel("alloc")),
- Sel("initWithData:"), nsData);
-
- IntPtr nsApp = MsgSend(objc_getClass("NSApplication"), Sel("sharedApplication"));
- MsgSend(nsApp, Sel("setApplicationIconImage:"), nsImage);
- MsgSend(nsImage, Sel("autorelease"));
- }
- finally
- {
- handle.Free();
- }
- }
- catch (Exception ex)
- {
- Logger.Warn("Failed to set macOS dock icon");
- Logger.Warn(ex);
- }
- }
-
- 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);
-
- [DllImport("/usr/lib/libobjc.A.dylib")]
- private static extern IntPtr sel_registerName(string name);
-
- [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
- private static extern IntPtr MsgSend(IntPtr receiver, IntPtr sel);
-
- [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
- private static extern IntPtr MsgSend(IntPtr receiver, IntPtr sel, IntPtr arg);
-
- [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
- private static extern IntPtr MsgSendBytes(IntPtr receiver, IntPtr sel, IntPtr bytes, nint length);
}
diff --git a/src/UniGetUI.Avalonia/Infrastructure/MicaWindowHelper.cs b/src/UniGetUI.Avalonia/Infrastructure/MicaWindowHelper.cs
new file mode 100644
index 0000000000..0f85fd8ebe
--- /dev/null
+++ b/src/UniGetUI.Avalonia/Infrastructure/MicaWindowHelper.cs
@@ -0,0 +1,154 @@
+using System;
+using System.Runtime.InteropServices;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Media;
+using Avalonia.Platform;
+
+namespace UniGetUI.Avalonia.Infrastructure;
+
+///
+/// Gives a standard Avalonia the native Windows 11 look:
+/// the Mica backdrop (whole-window), rounded corners, and an accent-colored border
+/// that follows focus. Windows-only and gated on Windows 11; a no-op elsewhere, so
+/// the window keeps its opaque look when Mica isn't available.
+/// Used by the secondary windows/dialogs; MainWindow has its own (custom-frame) variant.
+///
+internal static class MicaWindowHelper
+{
+ private const int DWMWA_WINDOW_CORNER_PREFERENCE = 33;
+ private const int DWMWA_BORDER_COLOR = 34;
+ private const int DWMWA_SYSTEMBACKDROP_TYPE = 38;
+ private const int DWMWCP_ROUND = 2;
+ private const int DWMSBT_MAINWINDOW = 2; // Mica — same backdrop as the rest of the app, so menus match
+ private const int DWMWA_COLOR_DEFAULT = unchecked((int)0xFFFFFFFF);
+
+ private static bool _acrylicPopupsHooked;
+
+ public static void Apply(Window window)
+ {
+ if (!IsMicaEnabled())
+ return;
+
+ // Avalonia paints the Mica backdrop from this hint; the transparent window lets it
+ // show. The dialog's content panels keep their (translucent) surfaces from the
+ // merged Styles.WindowsMica dictionary.
+ window.TransparencyLevelHint = new[] { WindowTransparencyLevel.Mica };
+
+ window.Opened += (_, _) =>
+ {
+ if (window.TryGetPlatformHandle()?.Handle is { } handle && handle != 0)
+ {
+ int corner = DWMWCP_ROUND;
+ NativeMethods.DwmSetWindowAttribute(handle, DWMWA_WINDOW_CORNER_PREFERENCE, ref corner, sizeof(int));
+ }
+ window.Background = Brushes.Transparent;
+ ApplyBorderAccent(window, window.IsActive);
+ };
+
+ window.GetObservable(WindowBase.IsActiveProperty).Subscribe(active => ApplyBorderAccent(window, active));
+
+ if (Application.Current?.PlatformSettings is { } settings)
+ {
+ void Handler(object? s, PlatformColorValues e)
+ {
+ if (window.IsActive) ApplyBorderAccent(window, true);
+ }
+ settings.ColorValuesChanged += Handler;
+ window.Closed += (_, _) => settings.ColorValuesChanged -= Handler;
+ }
+ }
+
+ // Gives flyouts / menus / combo dropdowns / tooltips a native Win11 acrylic backdrop:
+ // the popup window is made transparent and DWM paints the (blurred, theme-adaptive)
+ // acrylic material behind it. Registered once at startup when Mica is enabled.
+ public static void EnableAcrylicPopups()
+ {
+ if (!IsMicaEnabled() || _acrylicPopupsHooked)
+ return;
+ _acrylicPopupsHooked = true;
+
+ // Every flyout/menu/tooltip/combo popup is hosted in a PopupRoot; style it as it loads.
+ Control.LoadedEvent.AddClassHandler((root, _) => ApplyAcrylicToPopup(root));
+ }
+
+ private static void ApplyAcrylicToPopup(PopupRoot root)
+ {
+ // Transparent surface so the DWM acrylic shows; the presenter backgrounds are also
+ // transparent (Styles.WindowsMica) so only the acrylic + menu items are painted.
+ root.TransparencyLevelHint = new[] { WindowTransparencyLevel.Transparent };
+ root.Background = Brushes.Transparent;
+
+ if (root.TryGetPlatformHandle()?.Handle is not { } handle || handle == 0)
+ return;
+
+ int corner = DWMWCP_ROUND;
+ NativeMethods.DwmSetWindowAttribute(handle, DWMWA_WINDOW_CORNER_PREFERENCE, ref corner, sizeof(int));
+ int backdrop = DWMSBT_MAINWINDOW;
+ NativeMethods.DwmSetWindowAttribute(handle, DWMWA_SYSTEMBACKDROP_TYPE, ref backdrop, sizeof(int));
+ }
+
+ // Accent-coloured window border that follows focus (kept on the dialogs).
+ private static void ApplyBorderAccent(Window window, bool active)
+ {
+ if (window.TryGetPlatformHandle()?.Handle is not { } handle || handle == 0)
+ return;
+
+ int colorRef = DWMWA_COLOR_DEFAULT;
+ if (active)
+ {
+ Color accent = Application.Current?.PlatformSettings?.GetColorValues().AccentColor1
+ ?? Colors.Transparent;
+ colorRef = accent.R | (accent.G << 8) | (accent.B << 16); // Color -> COLORREF (0x00BBGGRR)
+ }
+ NativeMethods.DwmSetWindowAttribute(handle, DWMWA_BORDER_COLOR, ref colorRef, sizeof(int));
+ }
+
+ ///
+ /// True when the native Mica look should be used: Windows 11+ AND the user has
+ /// "Transparency effects" enabled. When transparency is off we keep the solid look,
+ /// since the Mica backdrop otherwise lingers (DWM keeps painting it).
+ ///
+ public static bool IsMicaEnabled()
+ => OperatingSystem.IsWindows()
+ && Environment.OSVersion.Version.Build >= 22000
+ && IsOsTransparencyEnabled();
+
+ private static bool IsOsTransparencyEnabled()
+ {
+ if (!OperatingSystem.IsWindows())
+ return false;
+
+ try
+ {
+ var data = new byte[4];
+ int size = data.Length;
+ int result = NativeMethods.RegGetValueW(
+ NativeMethods.HKEY_CURRENT_USER,
+ @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
+ "EnableTransparency",
+ NativeMethods.RRF_RT_REG_DWORD,
+ out _, data, ref size);
+ if (result == 0) // ERROR_SUCCESS
+ return BitConverter.ToInt32(data, 0) != 0;
+ }
+ catch { /* fall through to the default */ }
+
+ return true; // value missing/unreadable -> assume transparency is on
+ }
+
+ private static class NativeMethods
+ {
+ // winreg.h: HKEY_CURRENT_USER = (HKEY)(ULONG_PTR)((LONG)0x80000001) — sign-extended on x64.
+ public static readonly nint HKEY_CURRENT_USER = new(unchecked((int)0x80000001));
+ public const int RRF_RT_REG_DWORD = 0x00000010;
+
+ [DllImport("dwmapi.dll")]
+ public static extern int DwmSetWindowAttribute(nint hwnd, int attr, ref int value, int size);
+
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode)]
+ public static extern int RegGetValueW(nint hkey, string lpSubKey, string lpValue, int dwFlags,
+ out int pdwType, byte[] pvData, ref int pcbData);
+ }
+}
diff --git a/src/UniGetUI.Avalonia/Infrastructure/TrayService.cs b/src/UniGetUI.Avalonia/Infrastructure/TrayService.cs
index 0bca843e66..8b13c993e7 100644
--- a/src/UniGetUI.Avalonia/Infrastructure/TrayService.cs
+++ b/src/UniGetUI.Avalonia/Infrastructure/TrayService.cs
@@ -40,7 +40,7 @@ public void UpdateStatus()
{
_trayIcon.IsVisible = !Settings.Get(Settings.K.DisableSystemTray);
- string modifier;
+ string status;
string tooltip;
bool anyRunning = AvaloniaOperationRegistry.Operations.Any(
@@ -50,38 +50,47 @@ public void UpdateStatus()
if (anyRunning)
{
- modifier = "_blue";
+ status = "blue";
tooltip = CoreTools.Translate("Operation in progress");
}
else if (AvaloniaOperationRegistry.ErrorsOccurred > 0)
{
- modifier = "_orange";
+ status = "orange";
tooltip = CoreTools.Translate("Attention required");
}
else if (AvaloniaOperationRegistry.RestartRequired)
{
- modifier = "_turquoise";
+ status = "turquoise";
tooltip = CoreTools.Translate("Restart required");
}
else if (updatesCount > 0)
{
- modifier = "_green";
+ status = "green";
tooltip = updatesCount == 1
? CoreTools.Translate("1 update is available")
: CoreTools.Translate("{0} updates are available", updatesCount);
}
else
{
- modifier = "_empty";
+ status = "empty";
tooltip = CoreTools.Translate("Everything is up to date");
}
_trayIcon.ToolTipText = tooltip + " - UniGetUI";
- modifier += IsTaskbarLight() ? "_black" : "_white";
- string suffix = Settings.Get(Settings.K.UseLegacyTrayIcon) ? "_legacy" : "";
+ bool light = IsTaskbarLight();
+ string tone = light ? "_black" : "_white";
+
+ // monochrome icons can't be tinted by parts, so for those we ship pre-composited
+ // assets: a monochrome base glyph matching the taskbar appearance with the
+ // colour-coded status dot kept intact (drawn non-template on macOS).
+ string uri = ResolveStyle() switch
+ {
+ "monochrome" => $"avares://UniGetUI.Avalonia/Assets/tray_monochrome_{status}_{(light ? "light" : "dark")}.ico",
+ "legacy" => $"avares://UniGetUI.Avalonia/Assets/tray_{status}{tone}_legacy.ico",
+ _ => $"avares://UniGetUI.Avalonia/Assets/tray_{status}{tone}.ico",
+ };
- string uri = $"avares://UniGetUI.Avalonia/Assets/tray{modifier}{suffix}.ico";
if (_lastIconUri == uri) return;
_lastIconUri = uri;
@@ -95,6 +104,20 @@ public void UpdateStatus()
}
}
+ // Tray icon style chosen by the user (Interface preferences). macOS menu-bar icons are always
+ // monochrome (HIG), so the style is fixed there and the selector is hidden; on Windows/Linux it
+ // is user-selectable and defaults to monochrome.
+ private static string ResolveStyle()
+ {
+ if (OperatingSystem.IsMacOS())
+ return "monochrome";
+
+ string style = Settings.GetValue(Settings.K.TrayIconStyle);
+ if (style.Length == 0)
+ style = "monochrome";
+ return style;
+ }
+
private static bool IsTaskbarLight()
{
#if WINDOWS
diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj
index e5a47fc98e..627aa0d4fa 100644
--- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj
+++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj
@@ -37,6 +37,33 @@
$(PingetCliPackageNativePath)\pinget.exe
+
+
+ $(MSBuildProjectDirectory)/bin/$(Configuration)/$(TargetFramework)/UniGetUI.app
+ false
+ false
+ $(MacAppBundleDir)/Contents/MacOS/
+
+
+
+
+ <_MacBundleContents>$(MacAppBundleDir)/Contents
+ <_MacScriptsDir>$(MSBuildProjectDirectory)/../../scripts/macos
+
+
+
+
+
+
+
+
+
+
$(DefineConstants);AVALONIA_DIAGNOSTICS_ENABLED
@@ -82,10 +109,10 @@
-
+
-
-
+
+
@@ -136,7 +163,7 @@
-
+
@@ -147,6 +174,19 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -163,6 +203,9 @@
+
+
+
diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/Interface_PViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/Interface_PViewModel.cs
index 242ba5c62c..e9364f7fb4 100644
--- a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/Interface_PViewModel.cs
+++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/Interface_PViewModel.cs
@@ -15,6 +15,12 @@ public partial class Interface_PViewModel : ViewModelBase
{
public bool IsWindows { get; } = OperatingSystem.IsWindows();
+ ///
+ /// The tray icon style is user-selectable on Windows and Linux only; macOS menu-bar icons are
+ /// always monochrome, so the selector is hidden there.
+ ///
+ public bool ShowTrayIconStyleSelector { get; } = !OperatingSystem.IsMacOS();
+
///
/// True when the user is enrolled in the beta program. In that case the modern UI is forced
/// and the classic-mode toggle should be disabled.
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/AboutWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/AboutWindow.axaml.cs
index a2b8b8b2e9..267b4388e5 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/AboutWindow.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/AboutWindow.axaml.cs
@@ -8,6 +8,7 @@ public partial class AboutWindow : Window
public AboutWindow()
{
InitializeComponent();
+ UniGetUI.Avalonia.Infrastructure.MicaWindowHelper.Apply(this);
}
protected override void OnOpened(EventArgs e)
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/BundleSecurityReportDialog.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/BundleSecurityReportDialog.axaml.cs
index 77c9a86bc4..f04a91f32c 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/BundleSecurityReportDialog.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/BundleSecurityReportDialog.axaml.cs
@@ -9,6 +9,7 @@ public partial class BundleSecurityReportDialog : Window
public BundleSecurityReportDialog(BundleReport report)
{
InitializeComponent();
+ UniGetUI.Avalonia.Infrastructure.MicaWindowHelper.Apply(this);
var sb = new System.Text.StringBuilder();
foreach (var (pkgId, entries) in report.Contents)
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/CrashReportWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/CrashReportWindow.axaml.cs
index 53c1d06331..0be7336d52 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/CrashReportWindow.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/CrashReportWindow.axaml.cs
@@ -16,6 +16,7 @@ public CrashReportWindow(string crashReport)
{
_crashReport = crashReport;
InitializeComponent();
+ UniGetUI.Avalonia.Infrastructure.MicaWindowHelper.Apply(this);
CrashReportText.Text = crashReport;
DontSendButton.Content = CoreTools.Translate("Don't Send");
SendButton.Content = CoreTools.Translate("Send Report");
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/DiscardBundleChangesDialog.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/DiscardBundleChangesDialog.axaml.cs
index 6e5dbc0146..d156fdca58 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/DiscardBundleChangesDialog.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/DiscardBundleChangesDialog.axaml.cs
@@ -10,6 +10,7 @@ public partial class DiscardBundleChangesDialog : Window
public DiscardBundleChangesDialog()
{
InitializeComponent();
+ UniGetUI.Avalonia.Infrastructure.MicaWindowHelper.Apply(this);
CancelButton.Click += (_, _) => Close();
DiscardButton.Click += (_, _) => { Confirmed = true; Close(); };
}
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsWindow.axaml.cs
index c7f2407ac6..fb55d63a76 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsWindow.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsWindow.axaml.cs
@@ -17,6 +17,7 @@ public InstallOptionsWindow(IPackage package, OperationType operation, InstallOp
var vm = new InstallOptionsViewModel(package, operation, options);
DataContext = vm;
InitializeComponent();
+ UniGetUI.Avalonia.Infrastructure.MicaWindowHelper.Apply(this);
vm.CloseRequested += (_, _) => Close();
}
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/IntegrityViolationDialog.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/IntegrityViolationDialog.axaml.cs
index 1517137ea5..2a7c309af3 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/IntegrityViolationDialog.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/IntegrityViolationDialog.axaml.cs
@@ -8,6 +8,7 @@ public partial class IntegrityViolationDialog : Window
public IntegrityViolationDialog()
{
InitializeComponent();
+ UniGetUI.Avalonia.Infrastructure.MicaWindowHelper.Apply(this);
CloseButton.Click += (_, _) => Close();
}
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/ManageDesktopShortcutsWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/ManageDesktopShortcutsWindow.axaml.cs
index a347181cfd..c8b7a68b22 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/ManageDesktopShortcutsWindow.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/ManageDesktopShortcutsWindow.axaml.cs
@@ -12,6 +12,7 @@ public ManageDesktopShortcutsWindow(System.Collections.Generic.IReadOnlyList Close();
}
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/ManageIgnoredUpdatesWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/ManageIgnoredUpdatesWindow.axaml.cs
index dffda0ed6e..3897c61742 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/ManageIgnoredUpdatesWindow.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/ManageIgnoredUpdatesWindow.axaml.cs
@@ -12,6 +12,7 @@ public ManageIgnoredUpdatesWindow()
var vm = new ManageIgnoredUpdatesViewModel();
DataContext = vm;
InitializeComponent();
+ UniGetUI.Avalonia.Infrastructure.MicaWindowHelper.Apply(this);
vm.CloseRequested += (_, _) => Close();
}
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/MissingDependencyDialog.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/MissingDependencyDialog.axaml.cs
index dfdd76ea12..0e36090543 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/MissingDependencyDialog.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/MissingDependencyDialog.axaml.cs
@@ -26,6 +26,7 @@ public MissingDependencyDialog(ManagerDependency dep, int current, int total)
_total = total;
InitializeComponent();
+ UniGetUI.Avalonia.Infrastructure.MicaWindowHelper.Apply(this);
bool notFirstTime =
Settings.GetDictionaryItem(Settings.K.DependencyManagement, dep.Name)
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/OperationFailedDialog.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/OperationFailedDialog.axaml.cs
index 7ba469a438..3c9620e15c 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/OperationFailedDialog.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/OperationFailedDialog.axaml.cs
@@ -16,6 +16,7 @@ public partial class OperationFailedDialog : Window
public OperationFailedDialog(AbstractOperation operation)
{
InitializeComponent();
+ UniGetUI.Avalonia.Infrastructure.MicaWindowHelper.Apply(this);
Title = operation.Metadata.FailureMessage;
HeaderContent.Text =
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/OperationOutputWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/OperationOutputWindow.axaml.cs
index 007c437320..92751bbcdd 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/OperationOutputWindow.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/OperationOutputWindow.axaml.cs
@@ -15,6 +15,7 @@ public OperationOutputWindow(AbstractOperation operation)
var vm = new OperationOutputViewModel(operation);
DataContext = vm;
InitializeComponent();
+ UniGetUI.Avalonia.Infrastructure.MicaWindowHelper.Apply(this);
foreach (var line in vm.OutputLines)
AppendLine(line);
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/PackageDetailsWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/PackageDetailsWindow.axaml.cs
index 353a8fabe1..417949454f 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/PackageDetailsWindow.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/PackageDetailsWindow.axaml.cs
@@ -43,6 +43,7 @@ public PackageDetailsWindow(IPackage package, OperationType operation)
_vm = new PackageDetailsViewModel(package, operation);
DataContext = _vm;
InitializeComponent();
+ UniGetUI.Avalonia.Infrastructure.MicaWindowHelper.Apply(this);
_vm.CloseRequested += (_, _) => Close();
_vm.DetailsLoaded += (_, _) =>
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/SimpleErrorDialog.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/SimpleErrorDialog.axaml.cs
index 6b977cb29a..9ff1e79adc 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/SimpleErrorDialog.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/SimpleErrorDialog.axaml.cs
@@ -8,6 +8,7 @@ public partial class SimpleErrorDialog : Window
public SimpleErrorDialog(string title, string message)
{
InitializeComponent();
+ UniGetUI.Avalonia.Infrastructure.MicaWindowHelper.Apply(this);
Title = title;
TitleBlock.Text = title;
MessageBlock.Text = message;
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/TelemetryDialog.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/TelemetryDialog.axaml.cs
index 64f838d882..0aa027331e 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/TelemetryDialog.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/TelemetryDialog.axaml.cs
@@ -11,6 +11,7 @@ public partial class TelemetryDialog : Window
public TelemetryDialog()
{
InitializeComponent();
+ UniGetUI.Avalonia.Infrastructure.MicaWindowHelper.Apply(this);
Body2.Text = CoreTools.Translate("No personal information is collected nor sent, and the collected data is anonimized, so it can't be back-tracked to you.");
diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml b/src/UniGetUI.Avalonia/Views/MainWindow.axaml
index 1c30bde762..fa60f3d359 100644
--- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml
+++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml
@@ -6,6 +6,7 @@
xmlns:vm="using:UniGetUI.Avalonia.ViewModels"
xmlns:controls="using:UniGetUI.Avalonia.Views.Controls"
xmlns:t="using:UniGetUI.Avalonia.MarkupExtensions"
+ xmlns:infra="using:UniGetUI.Avalonia.Infrastructure"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="750"
@@ -20,7 +21,7 @@
@@ -54,12 +55,17 @@
-
+ VerticalContentAlignment="Stretch">
+
+
+
+
+
@@ -244,24 +250,32 @@
CornerRadius="6"
ClipToBounds="True"
VerticalAlignment="Center">
-
+
+ FontSize="12"
+ VerticalContentAlignment="Center"
+ HorizontalContentAlignment="Center"/>
@@ -298,7 +312,7 @@
diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs
index 80d8068bb4..7703d417db 100644
--- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs
@@ -63,6 +63,13 @@ public partial class MainWindow : Window
private const uint SWP_NOACTIVATE = 0x0010;
private const uint SWP_FRAMECHANGED = 0x0020;
+ // DWM attributes for the native Windows 11 Mica look: rounded corners and an
+ // accent-colored window border that tracks focus.
+ private const int DWMWA_WINDOW_CORNER_PREFERENCE = 33;
+ private const int DWMWA_BORDER_COLOR = 34;
+ private const int DWMWCP_ROUND = 2;
+ private const int DWMWA_COLOR_NONE = unchecked((int)0xFFFFFFFE);
+
private bool _focusSidebarSelectionOnNextPageChange;
private TrayService? _trayService;
private bool _allowClose;
@@ -123,6 +130,8 @@ protected override void OnOpened(EventArgs e)
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
}
}
+
+ SetupMicaAndAccentBorder();
}
protected override void OnClosing(WindowClosingEventArgs e)
@@ -250,6 +259,12 @@ private void SetupTitleBar()
WindowDecorations = WindowDecorations.BorderOnly;
ExtendClientAreaToDecorationsHint = true;
ExtendClientAreaTitleBarHeightHint = -1;
+ // Request the Win11 Mica backdrop only when it should actually be used
+ // (Windows 11 + "Transparency effects" enabled). Otherwise leave the default
+ // so the window stays solid. The transparent-background switch happens in
+ // SetupMicaAndAccentBorder() once the native handle exists.
+ if (MicaWindowHelper.IsMicaEnabled())
+ TransparencyLevelHint = new[] { WindowTransparencyLevel.Mica };
TitleBarGrid.ClearValue(HeightProperty);
TitleBarGrid.Height = 44;
HamburgerPanel.Margin = new Thickness(10, 0, 8, 0);
@@ -618,6 +633,41 @@ private void UpdateMaximizeButtonState(bool isMaximized)
CoreTools.Translate(isMaximized ? "Restore" : "Maximize"));
}
+ // Applies the Windows 11 Mica look when it's actually usable (Win11 + transparency on):
+ // a transparent window so the backdrop shows, native rounded corners, and no accent
+ // border (it reads as out of place on the large main window). Otherwise the window keeps
+ // its solid background. Must run after the native handle exists (OnOpened); Windows-only.
+ private void SetupMicaAndAccentBorder()
+ {
+ if (!OperatingSystem.IsWindows())
+ return;
+
+ if (TryGetPlatformHandle()?.Handle is not { } handle || handle == 0)
+ return;
+
+ if (!MicaWindowHelper.IsMicaEnabled())
+ {
+ // No Mica (Windows 10, transparency off, etc.): keep the solid window background.
+ // Styles.WindowsMica is not merged in this case, so the surfaces stay opaque too.
+ if (this.TryFindResource("AppWindowBackground", ActualThemeVariant, out var bg) && bg is IBrush brush)
+ Background = brush;
+ return;
+ }
+
+ // The custom NCCALCSIZE frame keeps WS_THICKFRAME, so DWM still has a frame to round.
+ int corner = DWMWCP_ROUND;
+ NativeMethods.DwmSetWindowAttribute(handle, DWMWA_WINDOW_CORNER_PREFERENCE, ref corner, sizeof(int));
+
+ // Transparent window + transparent MicaPageBackground (from Styles.WindowsMica) let
+ // the backdrop show through the chrome and page area.
+ Background = Brushes.Transparent;
+
+ // Suppress the window border colour so the main window doesn't get the accent edge
+ // (the dialogs keep it via MicaWindowHelper).
+ int noBorder = DWMWA_COLOR_NONE;
+ NativeMethods.DwmSetWindowAttribute(handle, DWMWA_BORDER_COLOR, ref noBorder, sizeof(int));
+ }
+
private static nint OnWindowsWndProc(nint hWnd, uint msg, nint wParam, nint lParam, ref bool handled)
{
// Force client = full window rect. Avalonia's ExtendClientArea handler only overrides
@@ -729,6 +779,9 @@ private static class NativeMethods
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetMonitorInfo(nint hMonitor, ref MONITORINFO lpmi);
+ [DllImport("dwmapi.dll")]
+ public static extern int DwmSetWindowAttribute(nint hwnd, int attr, ref int value, int size);
+
[StructLayout(LayoutKind.Sequential)]
public struct STYLESTRUCT
{
@@ -859,7 +912,7 @@ public void QuitApplication()
_ = QuitApplicationAsync();
}
- private async Task QuitApplicationAsync()
+ private static async Task QuitApplicationAsync()
{
try
{
diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml
index c28e3b3ff9..077553c4a0 100644
--- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml
+++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml
@@ -47,9 +47,9 @@
StateChangedCommand="{Binding RefreshSystemTrayCommand}"
CornerRadius="8,8,0,0"/>
-
+
+
+
+
@@ -55,7 +68,10 @@
Opacity="0.85" TextWrapping="Wrap"/>