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"> - + @@ -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"/> -