|
3 | 3 | using System.Net; |
4 | 4 | using System.Net.Http.Headers; |
5 | 5 | using System.Reflection; |
| 6 | +using System.Runtime.Versioning; |
6 | 7 | using System.Text.Json; |
7 | 8 | using System.Text.Json.Serialization; |
8 | 9 | using Apizr; |
|
12 | 13 | using Avalonia.Controls; |
13 | 14 | using Avalonia.Controls.ApplicationLifetimes; |
14 | 15 | using Avalonia.Data.Core.Plugins; |
| 16 | +using Avalonia.Input; |
15 | 17 | using Avalonia.Input.Platform; |
16 | 18 | using Avalonia.Markup.Xaml; |
17 | 19 | using Avalonia.Media; |
@@ -100,6 +102,18 @@ public sealed class App : Application |
100 | 102 |
|
101 | 103 | private bool isOnExitComplete; |
102 | 104 |
|
| 105 | + /// <summary> |
| 106 | + /// True once the user has confirmed exiting while packages are running, so |
| 107 | + /// <see cref="OnShutdownRequested"/> doesn't prompt again on the follow-up shutdown. |
| 108 | + /// </summary> |
| 109 | + private bool isExitConfirmed; |
| 110 | + |
| 111 | + /// <summary> |
| 112 | + /// True while the exit confirmation dialog is open, to avoid stacking dialogs if more |
| 113 | + /// shutdown requests arrive (e.g. ⌘Q pressed repeatedly). |
| 114 | + /// </summary> |
| 115 | + private bool isConfirmingExit; |
| 116 | + |
103 | 117 | private ServiceProvider? serviceProvider; |
104 | 118 |
|
105 | 119 | [NotNull] |
@@ -140,6 +154,13 @@ public override void Initialize() |
140 | 154 |
|
141 | 155 | SetFontFamily(GetPlatformDefaultFontFamily()); |
142 | 156 |
|
| 157 | + // macOS app menu must be set here (before AppBuilder's AfterSetup creates the menu |
| 158 | + // exporter) so it becomes the app menu and Avalonia appends the standard Services/Hide/Quit |
| 159 | + if (Compat.IsMacOS && !Design.IsDesignMode) |
| 160 | + { |
| 161 | + SetupMacOsApplicationMenu(); |
| 162 | + } |
| 163 | + |
143 | 164 | // Set design theme |
144 | 165 | if (Design.IsDesignMode) |
145 | 166 | { |
@@ -220,6 +241,47 @@ public override void OnFrameworkInitializationCompleted() |
220 | 241 | } |
221 | 242 | } |
222 | 243 |
|
| 244 | + /// <summary> |
| 245 | + /// Sets the native macOS application menu. Avalonia's native backend reads the app menu (the |
| 246 | + /// bold app-name menu) from <see cref="NativeMenu"/> attached to the <see cref="Application"/> |
| 247 | + /// during AppBuilder's AfterSetup phase, then appends the standard Services / Hide / Quit (⌘Q) |
| 248 | + /// items. We add About and Settings… (⌘,) above those. Called from <see cref="Initialize"/>, |
| 249 | + /// which runs just before that phase, so it must not be moved any later or Avalonia falls back |
| 250 | + /// to its default "About Avalonia" menu. |
| 251 | + /// </summary> |
| 252 | + [SupportedOSPlatform("macos")] |
| 253 | + private void SetupMacOsApplicationMenu() |
| 254 | + { |
| 255 | + var aboutItem = new NativeMenuItem("About Stability Matrix"); |
| 256 | + aboutItem.Click += (_, _) => ShowAboutDialog(); |
| 257 | + |
| 258 | + var settingsItem = new NativeMenuItem("Settings…") |
| 259 | + { |
| 260 | + Gesture = new KeyGesture(Key.OemComma, KeyModifiers.Meta), |
| 261 | + }; |
| 262 | + settingsItem.Click += (_, _) => |
| 263 | + Services |
| 264 | + .GetRequiredService<INavigationService<MainWindowViewModel>>() |
| 265 | + .NavigateTo<SettingsViewModel>(); |
| 266 | + |
| 267 | + var appMenu = new NativeMenu { Items = { aboutItem, new NativeMenuItemSeparator(), settingsItem } }; |
| 268 | + |
| 269 | + NativeMenu.SetMenu(this, appMenu); |
| 270 | + } |
| 271 | + |
| 272 | + private static void ShowAboutDialog() |
| 273 | + { |
| 274 | + var dialog = DialogHelper.CreateTaskDialog( |
| 275 | + "Stability Matrix", |
| 276 | + $"Version {Compat.AppVersion.ToDisplayString()}" |
| 277 | + ); |
| 278 | + dialog.ShowProgressBar = false; |
| 279 | + dialog.FooterVisibility = TaskDialogFooterVisibility.Never; |
| 280 | + dialog.Buttons = new List<TaskDialogButton> { TaskDialogButton.CloseButton }; |
| 281 | + |
| 282 | + dialog.ShowAsync(true).SafeFireAndForget(); |
| 283 | + } |
| 284 | + |
223 | 285 | /// <summary> |
224 | 286 | /// Set the default font family for the application. |
225 | 287 | /// </summary> |
@@ -899,16 +961,81 @@ public static void Shutdown(int exitCode = 0) |
899 | 961 | } |
900 | 962 | } |
901 | 963 |
|
| 964 | + private static TaskDialog CreateExitConfirmDialog() |
| 965 | + { |
| 966 | + var dialog = DialogHelper.CreateTaskDialog( |
| 967 | + Languages.Resources.Label_ConfirmExit, |
| 968 | + Languages.Resources.Label_ConfirmExitDetail |
| 969 | + ); |
| 970 | + |
| 971 | + dialog.ShowProgressBar = false; |
| 972 | + dialog.FooterVisibility = TaskDialogFooterVisibility.Never; |
| 973 | + |
| 974 | + dialog.Buttons = new List<TaskDialogButton> |
| 975 | + { |
| 976 | + new("Exit", TaskDialogStandardResult.Yes), |
| 977 | + TaskDialogButton.CancelButton, |
| 978 | + }; |
| 979 | + dialog.Buttons[0].IsDefault = true; |
| 980 | + |
| 981 | + return dialog; |
| 982 | + } |
| 983 | + |
902 | 984 | private void OnShutdownRequested(object? sender, ShutdownRequestedEventArgs e) |
903 | 985 | { |
904 | 986 | Logger.Trace("Start OnShutdownRequested"); |
905 | 987 |
|
906 | 988 | if (e.Cancel) |
907 | 989 | return; |
908 | 990 |
|
909 | | - // Skip if Async Dispose already started, shutdown will be handled by it |
| 991 | + // Confirm exit while packages are running. This covers every quit path — the window |
| 992 | + // close button, ⌘Q, the app menu Quit, and dock Quit — since they all end up here. |
| 993 | + if (!isExitConfirmed && !isAsyncDisposeStarted && serviceProvider is not null) |
| 994 | + { |
| 995 | + var runningPackageService = serviceProvider.GetRequiredService<RunningPackageService>(); |
| 996 | + if (runningPackageService.RunningPackages.Count > 0) |
| 997 | + { |
| 998 | + e.Cancel = true; |
| 999 | + |
| 1000 | + // Avoid stacking dialogs if another shutdown request arrives while this one is open |
| 1001 | + if (isConfirmingExit) |
| 1002 | + return; |
| 1003 | + |
| 1004 | + isConfirmingExit = true; |
| 1005 | + Dispatcher |
| 1006 | + .UIThread.InvokeAsync(async () => |
| 1007 | + { |
| 1008 | + try |
| 1009 | + { |
| 1010 | + var dialog = CreateExitConfirmDialog(); |
| 1011 | + if ( |
| 1012 | + (TaskDialogStandardResult)await dialog.ShowAsync(true) |
| 1013 | + == TaskDialogStandardResult.Yes |
| 1014 | + ) |
| 1015 | + { |
| 1016 | + isExitConfirmed = true; |
| 1017 | + DesktopLifetime?.MainWindow?.Hide(); |
| 1018 | + Shutdown(); |
| 1019 | + } |
| 1020 | + } |
| 1021 | + finally |
| 1022 | + { |
| 1023 | + isConfirmingExit = false; |
| 1024 | + } |
| 1025 | + }) |
| 1026 | + .SafeFireAndForget(); |
| 1027 | + return; |
| 1028 | + } |
| 1029 | + } |
| 1030 | + |
| 1031 | + // If an async dispose is already running, cancel until it completes so we don't |
| 1032 | + // Environment.Exit before settings/database flushes finish |
910 | 1033 | if (isAsyncDisposeStarted) |
| 1034 | + { |
| 1035 | + if (!isAsyncDisposeComplete) |
| 1036 | + e.Cancel = true; |
911 | 1037 | return; |
| 1038 | + } |
912 | 1039 |
|
913 | 1040 | // Cancel shutdown for now to dispose |
914 | 1041 | e.Cancel = true; |
|
0 commit comments