diff --git a/apps/archive-tool/Cargo.lock b/apps/archive-tool/Cargo.lock index 1fa9ad2..9a19491 100644 --- a/apps/archive-tool/Cargo.lock +++ b/apps/archive-tool/Cargo.lock @@ -697,14 +697,14 @@ dependencies = [ [[package]] name = "rustolonia" -version = "0.1.0" +version = "12.1.1" dependencies = [ "rustolonia-sys", ] [[package]] name = "rustolonia-sys" -version = "0.1.0" +version = "12.1.1" dependencies = [ "dirs", "flate2", diff --git a/apps/archive-tool/generated/.avalonia-viewmodel.owned.json b/apps/archive-tool/generated/.avalonia-viewmodel.owned.json index 6583336..24b4209 100644 --- a/apps/archive-tool/generated/.avalonia-viewmodel.owned.json +++ b/apps/archive-tool/generated/.avalonia-viewmodel.owned.json @@ -3,11 +3,11 @@ "files": [ { "path": "generated_view_models.rs", - "sha256": "8bb21d23ecc3e8e2e29c2b70dd773ba9d04dcbc36b96232c5a1e501154fce65a" + "sha256": "6c5ded6447354792db8434c5ae599106ad8a0311413202653270f934a94d4bb0" }, { "path": "RustViewRegistry.g.cs", - "sha256": "79ef3106f64111ec092d47cae6c402065741f96d86df921097f49c369214426e" + "sha256": "da56ba7ca7f1e1afe9c267568d6cdb8cceea02c40123b3a1340addfaaff6059f" }, { "path": "view-model.contract.md", diff --git a/apps/archive-tool/generated/RustViewRegistry.g.cs b/apps/archive-tool/generated/RustViewRegistry.g.cs index d828066..9d2df7f 100644 --- a/apps/archive-tool/generated/RustViewRegistry.g.cs +++ b/apps/archive-tool/generated/RustViewRegistry.g.cs @@ -16,6 +16,11 @@ internal static class RustViewRegistry _ => throw new global::System.ArgumentOutOfRangeException(nameof(viewId)), }; + internal static IDisposable CreateTray(int trayId, IAvnRustViewModel model) => trayId switch + { + _ => throw new global::System.ArgumentOutOfRangeException(nameof(trayId)), + }; + private static TWindow CreateDynamic( IAvnRustViewModel model, RustViewModelDescriptor descriptor) diff --git a/apps/archive-tool/generated/generated_view_models.rs b/apps/archive-tool/generated/generated_view_models.rs index 73ec24b..2f84389 100644 --- a/apps/archive-tool/generated/generated_view_models.rs +++ b/apps/archive-tool/generated/generated_view_models.rs @@ -375,6 +375,7 @@ impl crate::view_model::DynamicViewModel for MainViewModelDisp } pub fn mount_main_window(scope: &crate::AppScope, model: impl MainViewModel) -> crate::Result<()> { scope.mount_dynamic_view_model(1, MainViewModelDispatch { model }) } +pub fn mount_main_window_hidden(scope: &crate::AppScope, model: impl MainViewModel) -> crate::Result<()> { scope.mount_dynamic_view_model_hidden(1, MainViewModelDispatch { model }).map(drop) } #[derive(Clone, Debug)] pub struct EntryRowViewModelSink(crate::view_model::ViewModelSink); diff --git a/apps/hash-calculator/Cargo.lock b/apps/hash-calculator/Cargo.lock index 0f93af6..5866506 100644 --- a/apps/hash-calculator/Cargo.lock +++ b/apps/hash-calculator/Cargo.lock @@ -486,14 +486,14 @@ dependencies = [ [[package]] name = "rustolonia" -version = "0.1.0" +version = "12.1.1" dependencies = [ "rustolonia-sys", ] [[package]] name = "rustolonia-sys" -version = "0.1.0" +version = "12.1.1" dependencies = [ "dirs", "flate2", diff --git a/apps/hash-calculator/generated/.avalonia-viewmodel.owned.json b/apps/hash-calculator/generated/.avalonia-viewmodel.owned.json index b540094..ea7ce4c 100644 --- a/apps/hash-calculator/generated/.avalonia-viewmodel.owned.json +++ b/apps/hash-calculator/generated/.avalonia-viewmodel.owned.json @@ -3,11 +3,11 @@ "files": [ { "path": "generated_view_models.rs", - "sha256": "cfd1c2fc7d9982baec51e290d316f4297b85e82513d9561ecb2f8da04353598f" + "sha256": "7d17b2e880e9476759ad6df1da9af86e1410d87400e17911afb0bc8bf48254c9" }, { "path": "RustViewRegistry.g.cs", - "sha256": "434e460c14fe7b82b96605d85037532ede47f71a50557b05ba7d00cd5f3698fe" + "sha256": "fef2c54b4a7001d7f6c369cd9edf1c2c5d2204c5819b22a83950860ddd18fdcb" }, { "path": "view-model.contract.md", diff --git a/apps/hash-calculator/generated/RustViewRegistry.g.cs b/apps/hash-calculator/generated/RustViewRegistry.g.cs index 0bc28f0..028b721 100644 --- a/apps/hash-calculator/generated/RustViewRegistry.g.cs +++ b/apps/hash-calculator/generated/RustViewRegistry.g.cs @@ -16,6 +16,11 @@ internal static class RustViewRegistry _ => throw new global::System.ArgumentOutOfRangeException(nameof(viewId)), }; + internal static IDisposable CreateTray(int trayId, IAvnRustViewModel model) => trayId switch + { + _ => throw new global::System.ArgumentOutOfRangeException(nameof(trayId)), + }; + private static TWindow CreateDynamic( IAvnRustViewModel model, RustViewModelDescriptor descriptor) diff --git a/apps/hash-calculator/generated/generated_view_models.rs b/apps/hash-calculator/generated/generated_view_models.rs index 119cd0f..b9a4279 100644 --- a/apps/hash-calculator/generated/generated_view_models.rs +++ b/apps/hash-calculator/generated/generated_view_models.rs @@ -203,6 +203,7 @@ impl crate::view_model::DynamicViewModel for MainViewModelDisp } pub fn mount_main_window(scope: &crate::AppScope, model: impl MainViewModel) -> crate::Result<()> { scope.mount_dynamic_view_model(1, MainViewModelDispatch { model }) } +pub fn mount_main_window_hidden(scope: &crate::AppScope, model: impl MainViewModel) -> crate::Result<()> { scope.mount_dynamic_view_model_hidden(1, MainViewModelDispatch { model }).map(drop) } #[derive(Clone, Debug)] pub struct FileRowViewModelSink(crate::view_model::ViewModelSink); diff --git a/apps/pdf-viewer/Cargo.lock b/apps/pdf-viewer/Cargo.lock index 7c76ba7..588ae25 100644 --- a/apps/pdf-viewer/Cargo.lock +++ b/apps/pdf-viewer/Cargo.lock @@ -1588,14 +1588,14 @@ dependencies = [ [[package]] name = "rustolonia" -version = "0.1.0" +version = "12.1.1" dependencies = [ "rustolonia-sys", ] [[package]] name = "rustolonia-sys" -version = "0.1.0" +version = "12.1.1" dependencies = [ "dirs", "flate2", diff --git a/apps/pdf-viewer/generated/.avalonia-viewmodel.owned.json b/apps/pdf-viewer/generated/.avalonia-viewmodel.owned.json index 238d991..b75ab1e 100644 --- a/apps/pdf-viewer/generated/.avalonia-viewmodel.owned.json +++ b/apps/pdf-viewer/generated/.avalonia-viewmodel.owned.json @@ -3,11 +3,11 @@ "files": [ { "path": "generated_view_models.rs", - "sha256": "56d824b36dce2cae7e11aeac53c97c38d91f492437313f2e94ab650c9df83d58" + "sha256": "b7aeddf4191e94904c0357dcc6174622e8e37de84d84adc56d5ac8270a61a5d5" }, { "path": "RustViewRegistry.g.cs", - "sha256": "f172d7ec40c7161b6b37b002fc8c65eda3b6a1ba15264a00440fdd1073772572" + "sha256": "1a25e176c95787b2379de17d145fc40fcb0d1f0265ee7df2efff283c0253d687" }, { "path": "view-model.contract.md", diff --git a/apps/pdf-viewer/generated/RustViewRegistry.g.cs b/apps/pdf-viewer/generated/RustViewRegistry.g.cs index 77ed7db..bbb5aee 100644 --- a/apps/pdf-viewer/generated/RustViewRegistry.g.cs +++ b/apps/pdf-viewer/generated/RustViewRegistry.g.cs @@ -16,6 +16,11 @@ internal static class RustViewRegistry _ => throw new global::System.ArgumentOutOfRangeException(nameof(viewId)), }; + internal static IDisposable CreateTray(int trayId, IAvnRustViewModel model) => trayId switch + { + _ => throw new global::System.ArgumentOutOfRangeException(nameof(trayId)), + }; + private static TWindow CreateDynamic( IAvnRustViewModel model, RustViewModelDescriptor descriptor) diff --git a/apps/pdf-viewer/generated/generated_view_models.rs b/apps/pdf-viewer/generated/generated_view_models.rs index 131663d..fd760f4 100644 --- a/apps/pdf-viewer/generated/generated_view_models.rs +++ b/apps/pdf-viewer/generated/generated_view_models.rs @@ -217,6 +217,7 @@ impl crate::view_model::DynamicViewModel for MainViewModelDisp } pub fn mount_main_window(scope: &crate::AppScope, model: impl MainViewModel) -> crate::Result<()> { scope.mount_dynamic_view_model(1, MainViewModelDispatch { model }) } +pub fn mount_main_window_hidden(scope: &crate::AppScope, model: impl MainViewModel) -> crate::Result<()> { scope.mount_dynamic_view_model_hidden(1, MainViewModelDispatch { model }).map(drop) } #[derive(Clone, Debug)] pub struct OutlineItemViewModelSink(crate::view_model::ViewModelSink); diff --git a/apps/system-monitor/.avalonia-viewmodel.owned.json b/apps/system-monitor/.avalonia-viewmodel.owned.json index bc8715b..ff093fe 100644 --- a/apps/system-monitor/.avalonia-viewmodel.owned.json +++ b/apps/system-monitor/.avalonia-viewmodel.owned.json @@ -3,7 +3,7 @@ "files": [ { "path": "generated_view_models.rs", - "sha256": "e89b37605370eef2114245fb6c59f04fe19cab320974d94afe2f225c689c54a3" + "sha256": "dbbe9c3fada1ebb2f673079a3e5b18abb6d62ae9f0fc167cbf7553e24dc7732f" }, { "path": "view-model.contract.md", diff --git a/apps/system-monitor/GeneratedHostRegistry/.avalonia-viewmodel.owned.json b/apps/system-monitor/GeneratedHostRegistry/.avalonia-viewmodel.owned.json index 28904a2..a69072a 100644 --- a/apps/system-monitor/GeneratedHostRegistry/.avalonia-viewmodel.owned.json +++ b/apps/system-monitor/GeneratedHostRegistry/.avalonia-viewmodel.owned.json @@ -3,7 +3,7 @@ "files": [ { "path": "RustViewRegistry.g.cs", - "sha256": "d18abf8696073fe9e9db9740072b6739d0d4f335e736ac23ffdd3e87c55f2f4f" + "sha256": "f85cc2c56eca59b3f967a62c34827e6918a497edf950f32b5b4d5c8b90bff004" } ] } diff --git a/apps/system-monitor/GeneratedHostRegistry/RustViewRegistry.g.cs b/apps/system-monitor/GeneratedHostRegistry/RustViewRegistry.g.cs index f01c220..9befe24 100644 --- a/apps/system-monitor/GeneratedHostRegistry/RustViewRegistry.g.cs +++ b/apps/system-monitor/GeneratedHostRegistry/RustViewRegistry.g.cs @@ -16,6 +16,11 @@ internal static class RustViewRegistry _ => throw new global::System.ArgumentOutOfRangeException(nameof(viewId)), }; + internal static IDisposable CreateTray(int trayId, IAvnRustViewModel model) => trayId switch + { + _ => throw new global::System.ArgumentOutOfRangeException(nameof(trayId)), + }; + private static TWindow CreateDynamic( IAvnRustViewModel model, RustViewModelDescriptor descriptor) diff --git a/apps/system-monitor/NeoHtop.App/Cargo.lock b/apps/system-monitor/NeoHtop.App/Cargo.lock index 5dccf06..8fbe502 100644 --- a/apps/system-monitor/NeoHtop.App/Cargo.lock +++ b/apps/system-monitor/NeoHtop.App/Cargo.lock @@ -563,14 +563,14 @@ dependencies = [ [[package]] name = "rustolonia" -version = "12.1.0" +version = "12.1.1" dependencies = [ "rustolonia-sys", ] [[package]] name = "rustolonia-sys" -version = "12.1.0" +version = "12.1.1" dependencies = [ "dirs", "flate2", diff --git a/apps/system-monitor/generated_view_models.rs b/apps/system-monitor/generated_view_models.rs index c418a59..0428741 100644 --- a/apps/system-monitor/generated_view_models.rs +++ b/apps/system-monitor/generated_view_models.rs @@ -704,6 +704,7 @@ impl crate::view_model::DynamicViewModel for MainViewModelDisp } pub fn mount_main_window(scope: &crate::AppScope, model: impl MainViewModel) -> crate::Result<()> { scope.mount_dynamic_view_model(1, MainViewModelDispatch { model }) } +pub fn mount_main_window_hidden(scope: &crate::AppScope, model: impl MainViewModel) -> crate::Result<()> { scope.mount_dynamic_view_model_hidden(1, MainViewModelDispatch { model }).map(drop) } #[derive(Clone, Debug)] pub struct ProcessRowViewModelSink(crate::view_model::ViewModelSink); diff --git a/host/Com/AvnApplication.Shell.cs b/host/Com/AvnApplication.Shell.cs new file mode 100644 index 0000000..df58b32 --- /dev/null +++ b/host/Com/AvnApplication.Shell.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Host.Generated.ViewModels; +using Avalonia.Rust.Interop; +using Avalonia.Threading; + +namespace Avalonia.Host.Com; + +/// +/// Stage 33 desktop shell integration: the shutdown mode, tray icons and +/// window-level settings. Avalonia's own ShutdownMode, TrayIcon, +/// Topmost and TransparencyLevelHint do the work; only the +/// capture exclusion calls the platform directly, because Avalonia has no +/// equivalent. +/// +public partial class AvnApplication : IAvnApplication6 +{ + private ShutdownMode _shutdownMode = ShutdownMode.OnLastWindowClose; + private readonly List _trays = new(); + + public int SetShutdownMode(int mode) + { + if (mode is < (int)ShutdownMode.OnLastWindowClose or > (int)ShutdownMode.OnExplicitShutdown) + return HResults.E_INVALIDARG; + try + { + _shutdownMode = (ShutdownMode)mode; + if (_lifetime is { } lifetime) + lifetime.ShutdownMode = _shutdownMode; + return HResults.S_OK; + } + catch (Exception e) + { + return AbiError.Capture(e); + } + } + + public int CreateRustVmTray(int trayId, IAvnRustViewModel? model) + { + if (model is null) + return HResults.E_POINTER; + try + { + Dispatcher.UIThread.VerifyAccess(); + _trays.Add(RustViewRegistry.CreateTray(trayId, model)); + return HResults.S_OK; + } + catch (Exception e) + { + return AbiError.Capture(e); + } + } + + public int GetPlatformHandle(IAvnWindow? window, out long handle) + { + long found = 0; + var hr = WithWindow(window, managed => found = managed.TryGetPlatformHandle()?.Handle ?? 0); + handle = found; + return hr; + } + + public int SetWindowTopmost(IAvnWindow? window, int topmost) => + WithWindow(window, managed => managed.Topmost = topmost != 0); + + public int SetWindowTransparent(IAvnWindow? window, int transparent) => + WithWindow(window, managed => managed.TransparencyLevelHint = transparent != 0 + ? [WindowTransparencyLevel.Transparent] + : []); + + public int SetWindowExcludedFromCapture(IAvnWindow? window, int excluded) + { + if (!OperatingSystem.IsWindows()) + return HResults.E_NOTIMPL; + var result = 0; + var hr = WithWindow(window, managed => + { + var hwnd = managed.TryGetPlatformHandle()?.Handle ?? 0; + if (hwnd == 0) + throw new InvalidOperationException("The window has no native handle yet; show it first."); + if (!Win32.SetWindowDisplayAffinity(hwnd, excluded != 0 ? Win32.WDA_EXCLUDEFROMCAPTURE : Win32.WDA_NONE)) + result = Marshal.GetHRForLastWin32Error(); + }); + return hr < 0 ? hr : result; + } + + /// Runs on the managed window, on the UI thread. + private static int WithWindow(IAvnWindow? window, Action action) + { + if (window is null) + return HResults.E_POINTER; + try + { + Dispatcher.UIThread.VerifyAccess(); + var managed = (Window?)ProjectionRuntime.Unwrap(window) + ?? throw new ObjectDisposedException(nameof(window)); + action(managed); + return HResults.S_OK; + } + catch (Exception e) + { + return AbiError.Capture(e); + } + } + + /// Removes the tray icons; Avalonia also disposes them as the dispatcher shuts down. + private void ReleaseTrays() + { + foreach (var tray in _trays) + { + try + { + tray.Dispose(); + } + catch (Exception) + { + // Shutdown keeps going; the platform removes the icon with the process. + } + } + _trays.Clear(); + } + + private static partial class Win32 + { + public const uint WDA_NONE = 0x00; + public const uint WDA_EXCLUDEFROMCAPTURE = 0x11; + + [LibraryImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool SetWindowDisplayAffinity(nint hwnd, uint affinity); + } +} diff --git a/host/Com/AvnApplication.cs b/host/Com/AvnApplication.cs index c9a2c47..8b189c3 100644 --- a/host/Com/AvnApplication.cs +++ b/host/Com/AvnApplication.cs @@ -34,7 +34,8 @@ public int Run(IAvnAppHandler? handler) var startupArguments = SnapshotStartupArguments(); var lifetime = new ClassicDesktopStyleApplicationLifetime { - ShutdownMode = ShutdownMode.OnLastWindowClose, + // OnLastWindowClose unless IAvnApplication6.SetShutdownMode said otherwise. + ShutdownMode = _shutdownMode, Args = startupArguments, }; _lifetime = lifetime; @@ -58,6 +59,7 @@ public int Run(IAvnAppHandler? handler) } finally { + ReleaseTrays(); ReleaseDesktopFileIntegration(); } } diff --git a/host/Com/AvnGuids.cs b/host/Com/AvnGuids.cs index 2537a77..52aa969 100644 --- a/host/Com/AvnGuids.cs +++ b/host/Com/AvnGuids.cs @@ -32,4 +32,9 @@ internal static class AvnGuids // the completion rides the shared async operation registry, and nothing below // is added to a published vtable. public const string IAvnApplication5 = "6B2E8F10-4C91-4E3A-9A77-1F0C2B3A4D70"; + + // Stage 33 desktop shell integration: shutdown mode, tray icons and the + // window-level settings a capture tool needs. Another separately versioned + // capability; nothing below is added to a published vtable. + public const string IAvnApplication6 = "6B2E8F10-4C91-4E3A-9A77-1F0C2B3A4D80"; } diff --git a/host/Com/IAvnDesktopShell.cs b/host/Com/IAvnDesktopShell.cs new file mode 100644 index 0000000..f9fa7d3 --- /dev/null +++ b/host/Com/IAvnDesktopShell.cs @@ -0,0 +1,59 @@ +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using Avalonia.Rust.Interop; + +namespace Avalonia.Host.Com; + +/// +/// The separately versioned desktop shell capability, queried from +/// IAvnApplication: how the application lifetime ends, notification-area +/// (tray) icons, and the window-level settings a tray or capture tool needs. +/// Nothing here is ever added to an already published vtable; a consumer that +/// predates the capability reads E_NOINTERFACE from the query instead. +/// +[GeneratedComInterface] +[Guid(AvnGuids.IAvnApplication6)] +public partial interface IAvnApplication6 +{ + /// + /// Sets when the application exits: 0 when its last window closes (the + /// default), 1 when its main window closes, 2 only on Shutdown. + /// Takes effect at once when the application is running, otherwise when it + /// starts. + /// + [PreserveSig] + int SetShutdownMode(int mode); + + /// + /// Shows the tray icon declared as in the view-model + /// schema, bound to . The icon stays until the + /// application shuts down. + /// + [PreserveSig] + int CreateRustVmTray(int trayId, IAvnRustViewModel? model); + + /// + /// The window's native handle (an HWND on Windows), or 0 when the + /// platform window does not exist yet or has none. + /// + [PreserveSig] + int GetPlatformHandle(IAvnWindow? window, out long handle); + + /// Keeps the window above every non-topmost window (non-zero) or not. + [PreserveSig] + int SetWindowTopmost(IAvnWindow? window, int topmost); + + /// + /// Asks for a per-pixel transparent window (non-zero), so a transparent + /// background shows what is behind it, or an opaque one. + /// + [PreserveSig] + int SetWindowTransparent(IAvnWindow? window, int transparent); + + /// + /// Leaves the window out of screenshots and recordings (non-zero) or not. + /// Windows only: E_NOTIMPL elsewhere. + /// + [PreserveSig] + int SetWindowExcludedFromCapture(IAvnWindow? window, int excluded); +} diff --git a/host/HResults.cs b/host/HResults.cs index a35a6cd..a8775f3 100644 --- a/host/HResults.cs +++ b/host/HResults.cs @@ -7,6 +7,7 @@ internal static class HResults public const int E_FAIL = unchecked((int)0x80004005); public const int E_INVALIDARG = unchecked((int)0x80070057); public const int E_ABORT = unchecked((int)0x80004004); + public const int E_NOTIMPL = unchecked((int)0x80004001); /// Fixture-only failure used to prove PreserveSig HRESULT passthrough. public const int AVN_E_FIXTURE = unchecked((int)0xA7A70001); diff --git a/host/ViewRegistry.cs b/host/ViewRegistry.cs index 3a5a448..c644584 100644 --- a/host/ViewRegistry.cs +++ b/host/ViewRegistry.cs @@ -17,4 +17,11 @@ internal static Window Create(int viewId, IAvnRustViewModel model) throw new InvalidOperationException( $"No view is registered for view id {viewId}. Publish or compile Avalonia.Host with AvaloniaRustViewRegistryFile (application-owned generated registry) and AvaloniaRustPresentationProjects (application AXAML project)."); } + + internal static IDisposable CreateTray(int trayId, IAvnRustViewModel model) + { + ArgumentNullException.ThrowIfNull(model); + throw new InvalidOperationException( + $"No tray is registered for tray id {trayId}. Publish or compile Avalonia.Host with AvaloniaRustViewRegistryFile (application-owned generated registry) and AvaloniaRustPresentationProjects (application AXAML project)."); + } } diff --git a/interop/Avalonia.Rust/RustTray.cs b/interop/Avalonia.Rust/RustTray.cs new file mode 100644 index 0000000..3aa19fe --- /dev/null +++ b/interop/Avalonia.Rust/RustTray.cs @@ -0,0 +1,99 @@ +using System; +using System.Windows.Input; +using Avalonia.Controls; +using Avalonia.Platform; + +namespace Avalonia.Rust; + +/// +/// A notification-area icon built from a generated tray menu: the icon, its +/// tooltip, the menu and every observer that keeps them current. Disposing +/// removes the icon and releases the menu scope and the model adapter. +/// +/// +/// Avalonia only shows a that is in the application's +/// collection, so attaching adds it there (creating the +/// collection on first use) and disposing removes it. Avalonia itself disposes +/// whatever is left in the collection when the dispatcher shuts down. +/// +public sealed class RustTrayAttachment : IDisposable +{ + private readonly RustMenuScope _scope; + private readonly IDisposable? _owner; + private TrayIcon? _icon; + + private RustTrayAttachment(TrayIcon icon, RustMenuScope scope, IDisposable? owner) + { + _icon = icon; + _scope = scope; + _owner = owner; + } + + /// The attached icon, until disposed. + public TrayIcon? Icon => _icon; + + /// + /// Shows as the menu of a new tray icon. + /// + /// The generated tray menu. + /// An avares:// resource URI or a file path. + /// Hover text; none when null. + /// Run when the icon itself is clicked; none when null. + /// The menu's observer scope, released on dispose. + /// Disposed last, typically the model adapter the menu reads. + public static RustTrayAttachment Attach( + NativeMenu menu, + string iconSource, + string? toolTip, + ICommand? activate, + RustMenuScope scope, + IDisposable? owner) + { + ArgumentNullException.ThrowIfNull(menu); + ArgumentException.ThrowIfNullOrWhiteSpace(iconSource); + ArgumentNullException.ThrowIfNull(scope); + var application = Application.Current + ?? throw new InvalidOperationException("A tray icon needs a running application."); + var icon = new TrayIcon + { + Icon = LoadIcon(iconSource), + ToolTipText = toolTip, + Menu = menu, + Command = activate, + IsVisible = true, + }; + var icons = TrayIcon.GetIcons(application); + if (icons is null) + { + icons = new TrayIcons(); + TrayIcon.SetIcons(application, icons); + } + icons.Add(icon); + return new RustTrayAttachment(icon, scope, owner); + } + + /// Loads an icon from an avares:// URI or a file path. + public static WindowIcon LoadIcon(string source) + { + ArgumentException.ThrowIfNullOrWhiteSpace(source); + if (source.StartsWith("avares://", StringComparison.OrdinalIgnoreCase)) + { + using var stream = AssetLoader.Open(new Uri(source)); + return new WindowIcon(stream); + } + return new WindowIcon(source); + } + + public void Dispose() + { + if (_icon is { } icon) + { + _icon = null; + if (Application.Current is { } application && TrayIcon.GetIcons(application) is { } icons) + icons.Remove(icon); + icon.Dispose(); + } + _scope.Dispose(); + _owner?.Dispose(); + } +} diff --git a/projection/Avalonia.Projection.Generator/ViewModelSourceEmitter.cs b/projection/Avalonia.Projection.Generator/ViewModelSourceEmitter.cs index 4193613..5246c56 100644 --- a/projection/Avalonia.Projection.Generator/ViewModelSourceEmitter.cs +++ b/projection/Avalonia.Projection.Generator/ViewModelSourceEmitter.cs @@ -41,7 +41,7 @@ public static string EmitRust(ViewModelIr ir, bool externalConsumer = false) foreach (var enumDefinition in ir.Enums) EmitRustEnum(sb, enumDefinition); foreach (var model in ir.Models) - EmitRustModel(sb, ir, model, ir.Views.Where(view => view.Model == model.Name), externalConsumer); + EmitRustModel(sb, ir, model, ir.Views.Where(view => view.Model == model.Name), ir.TrayIcons.Where(tray => tray.Model == model.Name), externalConsumer); EmitRustConverters(sb, ir.Converters, externalConsumer); return sb.ToString().TrimEnd() + "\n"; } @@ -1361,8 +1361,13 @@ private static string EmitCSharpMenus(ViewModelIr ir, ViewModelDefinition model) case ViewModelMenuKind.Context: EmitContextMenuFactory(sb, ir, model, menu, adapter); break; + case ViewModelMenuKind.Tray: + EmitTrayMenu(sb, ir, model, menu, adapter); + break; } } + foreach (var tray in ir.TrayIcons.Where(tray => tray.Model == model.Name)) + EmitTrayAttach(sb, model, tray, adapter); sb.AppendLine("}"); foreach (var menu in model.Menus.Where(menu => menu.Kind == ViewModelMenuKind.Context)) @@ -1453,6 +1458,56 @@ private static void EmitApplicationMenu( sb.AppendLine(); } + private static void EmitTrayMenu( + StringBuilder sb, + ViewModelIr ir, + ViewModelDefinition model, + ViewModelMenu menu, + string adapter) + { + sb.AppendLine(" /// "); + sb.AppendLine($" /// Builds the {menu.Name} tray menu."); + sb.AppendLine(" /// "); + sb.AppendLine($" public static NativeMenu Create{menu.Name}({adapter} model, RustMenuScope scope)"); + sb.AppendLine(" {"); + sb.AppendLine(" ArgumentNullException.ThrowIfNull(model);"); + sb.AppendLine(" ArgumentNullException.ThrowIfNull(scope);"); + sb.AppendLine(" var menu = new NativeMenu();"); + EmitNativeMenuItems(sb, ir, model, menu.Items, "menu.Items", " ", keyBindings: false); + sb.AppendLine(" return menu;"); + sb.AppendLine(" }"); + sb.AppendLine(); + } + + private static void EmitTrayAttach( + StringBuilder sb, + ViewModelDefinition model, + TrayDefinition tray, + string adapter) + { + var activate = tray.ActivateCommand is { } command ? $"model.{command}Command" : "null"; + sb.AppendLine(" /// "); + sb.AppendLine($" /// Shows the {tray.Name} tray icon with the {tray.Menu} menu. Disposing"); + sb.AppendLine(" /// the attachment removes the icon and disposes ."); + sb.AppendLine(" /// "); + sb.AppendLine($" public static RustTrayAttachment Attach{tray.Name}Tray({adapter} model)"); + sb.AppendLine(" {"); + sb.AppendLine(" ArgumentNullException.ThrowIfNull(model);"); + sb.AppendLine(" var scope = new RustMenuScope(model);"); + sb.AppendLine(" try"); + sb.AppendLine(" {"); + sb.AppendLine($" var menu = Create{tray.Menu}(model, scope);"); + sb.AppendLine($" return RustTrayAttachment.Attach(menu, {CSharpString(tray.Icon)}, {CSharpString(tray.ToolTip)}, {activate}, scope, model);"); + sb.AppendLine(" }"); + sb.AppendLine(" catch"); + sb.AppendLine(" {"); + sb.AppendLine(" scope.Dispose();"); + sb.AppendLine(" throw;"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine(); + } + private static void EmitAcceleratorMenu( StringBuilder sb, ViewModelIr ir, @@ -1864,6 +1919,18 @@ private static string EmitRegistry(ViewModelIr ir) sb.AppendLine(" _ => throw new global::System.ArgumentOutOfRangeException(nameof(viewId)),"); sb.AppendLine(" };"); sb.AppendLine(); + sb.AppendLine(" internal static IDisposable CreateTray(int trayId, IAvnRustViewModel model) => trayId switch"); + sb.AppendLine(" {"); + foreach (var tray in ir.TrayIcons) + { + var model = ir.Models.Single(model => model.Name == tray.Model); + var ns = model.ManagedNamespace; + sb.AppendLine( + $" {tray.Id} => global::{ns}.{model.Name}Menus.Attach{tray.Name}Tray(new global::{ns}.{model.Name}Adapter(model)),"); + } + sb.AppendLine(" _ => throw new global::System.ArgumentOutOfRangeException(nameof(trayId)),"); + sb.AppendLine(" };"); + sb.AppendLine(); sb.AppendLine(" private static TWindow CreateDynamic("); sb.AppendLine(" IAvnRustViewModel model,"); sb.AppendLine(" RustViewModelDescriptor descriptor)"); @@ -1899,6 +1966,7 @@ private static void EmitRustModel( ViewModelIr ir, ViewModelDefinition model, IEnumerable views, + IEnumerable trays, bool externalConsumer) { var traitName = model.Name; @@ -2033,6 +2101,10 @@ private static void EmitRustModel( sb.AppendLine($"pub fn mount_{Snake(view.Name)}(scope: &crate::AppScope, model: impl {traitName}) -> crate::Result<()> {{ scope.mount_dynamic_view_model({view.Id}, {traitName}Dispatch {{ model }}) }}"); else sb.AppendLine($"impl crate::AppScope {{ pub fn mount_{Snake(view.Name)}(&self, model: impl {traitName}) -> crate::Result<()> {{ self.mount_dynamic_view_model({view.Id}, {traitName}Dispatch {{ model }}) }} }}"); + if (externalConsumer) + sb.AppendLine($"pub fn mount_{Snake(view.Name)}_hidden(scope: &crate::AppScope, model: impl {traitName}) -> crate::Result<()> {{ scope.mount_dynamic_view_model_hidden({view.Id}, {traitName}Dispatch {{ model }}).map(drop) }}"); + else + sb.AppendLine($"impl crate::AppScope {{ pub fn mount_{Snake(view.Name)}_hidden(&self, model: impl {traitName}) -> crate::Result<()> {{ self.mount_dynamic_view_model_hidden({view.Id}, {traitName}Dispatch {{ model }}).map(drop) }} }}"); if (view.Converters.Count > 0) { if (externalConsumer) @@ -2059,6 +2131,13 @@ private static void EmitRustModel( sb.AppendLine("}"); } } + foreach (var tray in trays) + { + if (externalConsumer) + sb.AppendLine($"pub fn mount_{Snake(tray.Name)}(scope: &crate::AppScope, model: impl {traitName}) -> crate::Result<()> {{ scope.mount_dynamic_tray({tray.Id}, {traitName}Dispatch {{ model }}) }}"); + else + sb.AppendLine($"impl crate::AppScope {{ pub fn mount_{Snake(tray.Name)}(&self, model: impl {traitName}) -> crate::Result<()> {{ self.mount_dynamic_tray({tray.Id}, {traitName}Dispatch {{ model }}) }} }}"); + } sb.AppendLine(); } diff --git a/projection/Avalonia.Projection.Ir/ViewModelIr.cs b/projection/Avalonia.Projection.Ir/ViewModelIr.cs index 29e4082..4d866b2 100644 --- a/projection/Avalonia.Projection.Ir/ViewModelIr.cs +++ b/projection/Avalonia.Projection.Ir/ViewModelIr.cs @@ -20,8 +20,20 @@ public sealed class ViewModelIr public IReadOnlyList Enums { get; init; } = []; public IReadOnlyList Models { get; init; } = []; public IReadOnlyList Views { get; init; } = []; + + /// + /// Notification-area (system tray) icons, each with a menu declared on its + /// model. Omitted from the serialized schema when there are none. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? Trays { get; init; } + public IReadOnlyList Converters { get; init; } = []; + /// , empty when the schema declares none. + [JsonIgnore] + public IReadOnlyList TrayIcons => Trays ?? []; + public static ViewModelIr FromJson(string json) { var value = JsonSerializer.Deserialize(json, JsonOptions) @@ -45,13 +57,16 @@ public void Validate() if (Version < RicherDataShapesVersion && Models.Any(HasRicherDataShapes)) throw new InvalidOperationException( $"View-model IR version {Version} does not support keyed maps, windowed collections, tree metadata, structured command results, progress or cancellation; upgrade to version {RicherDataShapesVersion}."); - if (Version < CommandSurfaceVersion && Models.Any(HasCommandSurfaces)) + if (Version < CommandSurfaceVersion && (Models.Any(HasCommandSurfaces) || TrayIcons.Count > 0)) throw new InvalidOperationException( $"View-model IR version {Version} does not support menus, keyboard accelerators, recent-file lists or display paths; upgrade to version {CommandSurfaceVersion}."); EnsurePositiveIds(Enums, enumDefinition => enumDefinition.Id, "enum"); EnsurePositiveIds(Models, model => model.Id, "model"); EnsurePositiveIds(Views, view => view.Id, "view"); EnsurePositiveIds(Converters, converter => converter.Id, "converter"); + EnsurePositiveIds(TrayIcons, tray => tray.Id, "tray"); + EnsureUnique(TrayIcons, tray => tray.Id, "tray ID"); + EnsureUnique(TrayIcons, tray => tray.Name, "tray name"); EnsureUnique(Enums, enumDefinition => enumDefinition.Id, "enum ID"); EnsureUnique(Enums, enumDefinition => enumDefinition.Name, "enum name"); EnsureUnique(Models, model => model.Id, "model ID"); @@ -226,9 +241,36 @@ public void Validate() ValidateMenu(model, menu, enumsByName); } + foreach (var tray in TrayIcons) + ValidateTray(tray); + ValidateAcyclicModelGraph(); } + /// + /// A tray names a model, a menu on it, + /// an icon, and optionally hover text and a command run when the icon itself + /// is clicked. Like a view, a mounted tray gets its own model instance. + /// + private void ValidateTray(TrayDefinition tray) + { + EnsureIdentifier(tray.Name, "Tray name"); + EnsureNotBlank(tray.Icon, $"Icon for tray '{tray.Name}'"); + var model = Models.SingleOrDefault(candidate => candidate.Name == tray.Model) + ?? throw new InvalidOperationException($"Tray '{tray.Name}' references unknown model '{tray.Model}'."); + var menu = model.Menus.SingleOrDefault(candidate => candidate.Name == tray.Menu) + ?? throw new InvalidOperationException($"Tray '{tray.Name}' references unknown menu '{model.Name}.{tray.Menu}'."); + if (menu.Kind != ViewModelMenuKind.Tray) + throw new InvalidOperationException($"Tray '{tray.Name}' menu '{model.Name}.{menu.Name}' must be a Tray menu."); + if (tray.ActivateCommand is { } activate) + { + var command = model.Commands.SingleOrDefault(candidate => candidate.Name == activate) + ?? throw new InvalidOperationException($"Tray '{tray.Name}' references unknown activate command '{model.Name}.{activate}'."); + if (command.ParameterProperty is not null) + throw new InvalidOperationException($"Tray '{tray.Name}' activate command '{model.Name}.{command.Name}' must not use parameterProperty."); + } + } + private static void ValidateTable( ViewModelDefinition owner, ViewModelCollection collection, @@ -379,6 +421,9 @@ private static bool HasRicherDataShapes(ViewModelDefinition model) => model.Collections.Any(collection => collection.Window is not null || collection.Tree is not null || collection.Recursive) || model.Commands.Any(command => command.ResultModelName is not null || command.SupportsProgress || command.SupportsCancellation); + private static bool HasGesture(IReadOnlyList items) => + items.Any(item => item.Gesture is not null || HasGesture(item.Items)); + private static bool HasCommandSurfaces(ViewModelDefinition model) => model.Menus.Count > 0 || model.RecentFiles is not null || @@ -471,6 +516,9 @@ private static void ValidateMenu( EnsureIdentifier(menu.Name, $"Menu name in model '{model.Name}'"); if (menu.Items.Count == 0) throw new InvalidOperationException($"Menu '{model.Name}.{menu.Name}' must declare at least one item."); + if (menu.Kind == ViewModelMenuKind.Tray && HasGesture(menu.Items)) + throw new InvalidOperationException( + $"Tray menu '{model.Name}.{menu.Name}' must not declare gestures; a tray menu has no keyboard focus to bind them to."); if (menu.Kind == ViewModelMenuKind.Accelerators) { foreach (var item in menu.Items) @@ -977,6 +1025,14 @@ public enum ViewModelMenuKind /// bindings, for shortcuts that are not on any menu item. /// Accelerators, + + /// + /// The menu of a notification-area (system tray) icon declared in + /// . Projected as an Avalonia NativeMenu + /// on a TrayIcon; gestures are rejected because a tray menu has no + /// keyboard focus to bind them to. + /// + Tray, } /// What one declared menu item does. @@ -1249,6 +1305,26 @@ public sealed class ViewModelCommand public bool SupportsCancellation { get; init; } } +/// A notification-area (system tray) icon with a menu. +public sealed class TrayDefinition +{ + public required int Id { get; init; } + public required string Name { get; init; } + public required string Model { get; init; } + + /// The name of a menu on . + public required string Menu { get; init; } + + /// An avares:// resource URI or a file path. + public required string Icon { get; init; } + + /// Hover text, when any. + public string? ToolTip { get; init; } + + /// The command run when the icon itself is clicked, when any. + public string? ActivateCommand { get; init; } +} + public sealed class ViewDefinition { public required int Id { get; init; } diff --git a/rust/COMPATIBILITY.md b/rust/COMPATIBILITY.md index a48b878..ad8adec 100644 --- a/rust/COMPATIBILITY.md +++ b/rust/COMPATIBILITY.md @@ -130,8 +130,10 @@ example `IAvnRustVmSink2`, stage 29's `IAvnApplication3` desktop file integration capability, stage 30's `IAvnRustVmSink4` richer data shapes, `IAvnRustVmRangeBatch` windowed range payload, `IAvnRustRangeSource` and `IAvnRustViewModel2`, or stage 31's `IAvnApplication4` clipboard command -capability and its host-owned `IAvnClipboardData` payload builder, or -`IAvnRustVmSink5` for scalar-number collection elements), then +capability and its host-owned `IAvnClipboardData` payload builder, +`IAvnRustVmSink5` for scalar-number collection elements, or stage 33's +`IAvnApplication6` desktop shell capability: shutdown mode, schema-declared +tray icons and window handle/topmost/transparency/capture exclusion), then negotiate/query it as optional capability. A producer or host that predates an optional capability must report `E_NOINTERFACE` explicitly; silently dropping the affected updates is not an diff --git a/rust/MENUS.md b/rust/MENUS.md index 2ca2014..82a96a7 100644 --- a/rust/MENUS.md +++ b/rust/MENUS.md @@ -64,6 +64,7 @@ A model may declare menus, one recent-file list, and one display path: } ``` +Menu kinds are `Application`, `Context`, `Accelerators` and `Tray`. Item kinds are `Command`, `Separator`, `Toggle`, `Radio`, `Submenu` and `RecentFiles`. Validation is deliberately strict, because a menu that silently does nothing is worse than a schema error: a command must exist, a toggle must @@ -111,6 +112,29 @@ Projected only as key bindings, for shortcuts that appear on no menu: _shortcuts = SampleViewModelMenus.AttachShortcuts(this, adapter); ``` +### `Tray` + +The menu of a notification-area (system tray) icon. A tray is declared at the +top level of the schema, next to `views`, and names its model, a `Tray` menu on +it, an icon (an `avares://` resource or a file path), and optionally hover text +and a command run when the icon itself is clicked: + +```json +"trays": [ + { "id": 1, "name": "Notify", "model": "TrayViewModel", "menu": "Tray", + "icon": "avares://MyApp.Presentation/Assets/app.ico", + "toolTip": "My App", "activateCommand": "ShowWindow" } +] +``` + +Like a view, a mounted tray gets its own model instance; the generated Rust +`mount_notify(scope, model)` shows it through the stage 33 `IAvnApplication6` +capability, and the icon stays until the application shuts down. Tray menus +reject gestures, since a tray menu has no keyboard focus to bind them to. A +tray application usually also calls `scope.set_shutdown_mode(OnExplicitShutdown)` +and mounts its windows hidden (`mount_dynamic_view_model_hidden`), so it keeps +running with no window open. + ## Accelerators A declared gesture is carried through to `KeyGesture.Parse`, so the whole diff --git a/rust/avalonia-sample/src/.avalonia-viewmodel.owned.json b/rust/avalonia-sample/src/.avalonia-viewmodel.owned.json index bb45798..605a54e 100644 --- a/rust/avalonia-sample/src/.avalonia-viewmodel.owned.json +++ b/rust/avalonia-sample/src/.avalonia-viewmodel.owned.json @@ -3,7 +3,7 @@ "files": [ { "path": "generated_view_models.rs", - "sha256": "d8be03eccab4ce9e4af55df8ec27c5d9ac72c41366e01d9ca97cf10b7a055b5f" + "sha256": "dfdfd67c128f09c361b7307ec54a1df5c92e65350ee45bd9931c1b2643b2fdeb" } ] } diff --git a/rust/avalonia-sample/src/generated_view_models.rs b/rust/avalonia-sample/src/generated_view_models.rs index f8983b9..7bcd15d 100644 --- a/rust/avalonia-sample/src/generated_view_models.rs +++ b/rust/avalonia-sample/src/generated_view_models.rs @@ -405,6 +405,7 @@ impl crate::view_model::DynamicViewModel for SampleViewModel } pub fn mount_rust_vm_window(scope: &crate::AppScope, model: impl SampleViewModel) -> crate::Result<()> { scope.mount_dynamic_view_model(1, SampleViewModelDispatch { model }) } +pub fn mount_rust_vm_window_hidden(scope: &crate::AppScope, model: impl SampleViewModel) -> crate::Result<()> { scope.mount_dynamic_view_model_hidden(1, SampleViewModelDispatch { model }).map(drop) } pub fn mount_rust_vm_window_with_converters( scope: &crate::AppScope, model: impl SampleViewModel, @@ -414,6 +415,7 @@ pub fn mount_rust_vm_window_with_converters( mount_rust_vm_window(scope, model) } pub fn mount_rust_dynamic_vm_window(scope: &crate::AppScope, model: impl SampleViewModel) -> crate::Result<()> { scope.mount_dynamic_view_model(2, SampleViewModelDispatch { model }) } +pub fn mount_rust_dynamic_vm_window_hidden(scope: &crate::AppScope, model: impl SampleViewModel) -> crate::Result<()> { scope.mount_dynamic_view_model_hidden(2, SampleViewModelDispatch { model }).map(drop) } pub fn mount_rust_dynamic_vm_window_with_converters( scope: &crate::AppScope, model: impl SampleViewModel, diff --git a/rust/prepare-release.ps1 b/rust/prepare-release.ps1 index ab2f43a..953020e 100644 --- a/rust/prepare-release.ps1 +++ b/rust/prepare-release.ps1 @@ -11,7 +11,8 @@ Two steps, each followed by a commit: Sets the version of rustolonia, rustolonia-sys and rustolonia-bindgen, the exact `rustolonia-sys` pin in rustolonia, `rustoloniaVersion` in rust/release-manifest.json and in build/SharedVersion.props, - clears rust/rustolonia-sys/host-checksums.txt and refreshes Cargo.lock. + clears rust/rustolonia-sys/host-checksums.txt and refreshes Cargo.lock + (and the crates' entries in the in-repository apps' lock files). Commit to master, then dispatch .github/workflows/release.yml with phase=build. It stages host tarballs and SHA256SUMS as Actions artifacts. @@ -75,7 +76,17 @@ if ($PSCmdlet.ParameterSetName -eq 'Version') { Set-Content-Replace (Join-Path $root 'build' 'SharedVersion.props') '[^<]+' "$Version" Write-Utf8Lf $checksumFile "$checksumHeader`n" - if (-not $SkipCargoUpdate) { & cargo update --workspace --manifest-path (Join-Path $root 'rust' 'Cargo.toml') } + if (-not $SkipCargoUpdate) { + & cargo update --workspace --manifest-path (Join-Path $root 'rust' 'Cargo.toml') + # The in-repository apps use the crates by path with their own lock + # files; only the crates' own entries move (CI builds them --locked). + foreach ($lock in @(& git -C $root ls-files 'apps/*Cargo.lock' 'apps/**/Cargo.lock' | Sort-Object -Unique)) { + $manifest = Join-Path $root (Split-Path -Parent $lock) 'Cargo.toml' + if (Test-Path -LiteralPath $manifest) { + & cargo update -p rustolonia -p rustolonia-sys --manifest-path $manifest + } + } + } $null = Get-RustoloniaReleaseVersion -RustoloniaRoot $root Write-Host "Version set to $Version. Commit to master, then dispatch phase=build (no tag needed)." return diff --git a/rust/rustolonia-sys/src/lib.rs b/rust/rustolonia-sys/src/lib.rs index faa109f..7b98e73 100644 --- a/rust/rustolonia-sys/src/lib.rs +++ b/rust/rustolonia-sys/src/lib.rs @@ -30,6 +30,7 @@ mod filters; mod notification; mod popup_placement; mod selectors; +mod shell; #[rustfmt::skip] mod generated; mod guid; @@ -69,6 +70,7 @@ pub use rust_vm::{ RustVmUpdate, RUST_VM_RANGE_FILL, RUST_VM_RANGE_INVALIDATE, RUST_VM_RANGE_RESET, }; pub use selectors::{item_selector, text_selector, ItemSelector, TextSelector}; +pub use shell::{IAvnApplication6, IAVN_APPLICATION6_METHOD_COUNT}; pub use storage::{ activation_handler, file_drop_handler, storage_completion, ActivationArgs, FileDropArgs, IAvnActivationHandler, IAvnApplication3, IAvnFileDropHandler, IAvnFilePickerOptions, diff --git a/rust/rustolonia-sys/src/shell.rs b/rust/rustolonia-sys/src/shell.rs new file mode 100644 index 0000000..585fe51 --- /dev/null +++ b/rust/rustolonia-sys/src/shell.rs @@ -0,0 +1,160 @@ +//! Stage 33 desktop shell integration ABI. +//! +//! `IAvnApplication6` is a separately versioned capability queried from +//! `IAvnApplication`; nothing here is added to an already published vtable. It +//! sets how the application lifetime ends, shows the tray icons a view-model +//! schema declares, and carries the window-level settings a tray or capture +//! tool needs: the native handle, topmost, per-pixel transparency and capture +//! exclusion. + +use crate::application::IAvnApplication; +use crate::com::{ComInterface, ComPtr, IUnknown}; +use crate::generated::IAvnWindow; +use crate::guid::Guid; +use crate::hresult::{self, Result}; +use crate::rust_vm::IAvnRustViewModel; +use std::ffi::c_void; + +const IAVN_APPLICATION6_IID: Guid = Guid { + data1: 0x6B2E8F10, + data2: 0x4C91, + data3: 0x4E3A, + data4: [0x9A, 0x77, 0x1F, 0x0C, 0x2B, 0x3A, 0x4D, 0x80], +}; + +#[repr(C)] +struct IAvnApplication6Vtbl { + query_interface: unsafe extern "system" fn(*mut IUnknown, *const Guid, *mut *mut c_void) -> i32, + add_ref: unsafe extern "system" fn(*mut IUnknown) -> u32, + release: unsafe extern "system" fn(*mut IUnknown) -> u32, + set_shutdown_mode: unsafe extern "system" fn(*mut IAvnApplication6, i32) -> i32, + create_rust_vm_tray: + unsafe extern "system" fn(*mut IAvnApplication6, i32, *mut IAvnRustViewModel) -> i32, + get_platform_handle: + unsafe extern "system" fn(*mut IAvnApplication6, *mut IAvnWindow, *mut i64) -> i32, + set_window_topmost: + unsafe extern "system" fn(*mut IAvnApplication6, *mut IAvnWindow, i32) -> i32, + set_window_transparent: + unsafe extern "system" fn(*mut IAvnApplication6, *mut IAvnWindow, i32) -> i32, + set_window_excluded_from_capture: + unsafe extern "system" fn(*mut IAvnApplication6, *mut IAvnWindow, i32) -> i32, +} + +#[repr(C)] +pub struct IAvnApplication6 { + vtbl: *const IAvnApplication6Vtbl, +} + +unsafe impl ComInterface for IAvnApplication6 { + const IID: Guid = IAVN_APPLICATION6_IID; +} + +impl ComPtr { + fn vtbl(&self) -> &IAvnApplication6Vtbl { + unsafe { (*self.as_raw()).vtbl.as_ref().unwrap() } + } + + /// Sets when the application exits: 0 on last window close (the default), + /// 1 on main window close, 2 only on an explicit shutdown. + pub fn set_shutdown_mode(&self, mode: i32) -> Result<()> { + unsafe { hresult::check((self.vtbl().set_shutdown_mode)(self.as_raw(), mode)) } + } + + /// Shows the tray icon declared as `tray_id`, bound to `model`. + pub fn create_rust_vm_tray( + &self, + tray_id: i32, + model: &ComPtr, + ) -> Result<()> { + unsafe { + hresult::check((self.vtbl().create_rust_vm_tray)( + self.as_raw(), + tray_id, + model.as_raw(), + )) + } + } + + /// The window's native handle (an `HWND` on Windows), 0 when it has none yet. + pub fn platform_handle(&self, window: &ComPtr) -> Result { + unsafe { + let mut handle = 0; + let hr = (self.vtbl().get_platform_handle)(self.as_raw(), window.as_raw(), &mut handle); + hresult::check(hr).map(|_| handle) + } + } + + pub fn set_window_topmost(&self, window: &ComPtr, topmost: bool) -> Result<()> { + unsafe { + hresult::check((self.vtbl().set_window_topmost)( + self.as_raw(), + window.as_raw(), + topmost as i32, + )) + } + } + + pub fn set_window_transparent( + &self, + window: &ComPtr, + transparent: bool, + ) -> Result<()> { + unsafe { + hresult::check((self.vtbl().set_window_transparent)( + self.as_raw(), + window.as_raw(), + transparent as i32, + )) + } + } + + /// Windows only; `E_NOTIMPL` elsewhere. + pub fn set_window_excluded_from_capture( + &self, + window: &ComPtr, + excluded: bool, + ) -> Result<()> { + unsafe { + hresult::check((self.vtbl().set_window_excluded_from_capture)( + self.as_raw(), + window.as_raw(), + excluded as i32, + )) + } + } +} + +impl ComPtr { + /// Queries the separately versioned desktop shell capability. + pub fn shell(&self) -> Result> { + self.query_interface::() + } +} + +/// Number of methods `IAvnApplication6` publishes after `IUnknown`. +/// +/// A cross-language tripwire: the managed interface and this vtable must stay +/// the same shape, and a published capability is never widened in place. +pub const IAVN_APPLICATION6_METHOD_COUNT: usize = 6; + +#[cfg(test)] +mod tests { + use super::*; + use std::mem::size_of; + + #[test] + fn application6_vtable_has_expected_slot_count() { + let slots = size_of::() / size_of::(); + assert_eq!(3 + IAVN_APPLICATION6_METHOD_COUNT, slots); + } + + #[test] + fn application6_iid_is_distinct_and_in_the_stage_33_slot() { + assert_eq!(0x80, IAVN_APPLICATION6_IID.data4[7]); + assert_ne!(crate::dialogs::IAvnApplication5::IID, IAVN_APPLICATION6_IID); + assert_ne!( + crate::clipboard::IAvnApplication4::IID, + IAVN_APPLICATION6_IID + ); + } +} diff --git a/rust/rustolonia-sys/tests/nativeaot_fixture.rs b/rust/rustolonia-sys/tests/nativeaot_fixture.rs index 8721f8c..a0941dd 100644 --- a/rust/rustolonia-sys/tests/nativeaot_fixture.rs +++ b/rust/rustolonia-sys/tests/nativeaot_fixture.rs @@ -169,6 +169,18 @@ fn factory_creates_application() { let _ = app.as_iunknown().unwrap(); } +#[test] +fn application_exposes_the_shell_capability() { + let host = load(); + let factory = host.activation_factory().unwrap(); + let app = factory.create_application().unwrap(); + let shell = app.shell().unwrap(); + // Out-of-range modes are rejected; a valid mode is accepted before Run. + assert!(shell.set_shutdown_mode(7).is_err()); + shell.set_shutdown_mode(2).unwrap(); + shell.set_shutdown_mode(0).unwrap(); +} + #[test] fn factory_unknown_qi_succeeds() { let host = load(); diff --git a/rust/rustolonia/src/lib.rs b/rust/rustolonia/src/lib.rs index 366f7be..25fb0cc 100644 --- a/rust/rustolonia/src/lib.rs +++ b/rust/rustolonia/src/lib.rs @@ -6,6 +6,7 @@ pub mod data_shapes; pub mod menu; pub mod reactive; mod runtime; +pub mod shell; pub mod storage; pub mod value_converter; pub mod view; @@ -24,6 +25,7 @@ pub use runtime::{ discover_host_path, App, AppContext, AppScope, AsControl, Downcast, EventSubscription, NonOwningRef, ResourceValue, ThemeVariant, Upcast, HOST_NATIVE_LIB_ENV_VAR, }; +pub use shell::ShutdownMode; pub use storage::{ ActivationEvent, DragDropEffects, FileDropEvent, FileTypeFilter, FolderPickerOptions, OpenFilePickerOptions, PickerOutcome, SaveFilePickerOptions, StorageCapabilities, StorageItem, diff --git a/rust/rustolonia/src/runtime.rs b/rust/rustolonia/src/runtime.rs index 4ad1ede..7f1eead 100644 --- a/rust/rustolonia/src/runtime.rs +++ b/rust/rustolonia/src/runtime.rs @@ -205,12 +205,16 @@ impl AppScope { pub fn mount(&self, window: Window) -> Result<()> { window.raw.show()?; + self.record_window(window); + Ok(()) + } + + pub(crate) fn record_window(&self, window: Window) { self.state .windows .lock() .expect("application window scope lock poisoned") .push(window); - Ok(()) } /// Every window mounted through [`AppScope::mount`], in mount order. diff --git a/rust/rustolonia/src/shell.rs b/rust/rustolonia/src/shell.rs new file mode 100644 index 0000000..e56104f --- /dev/null +++ b/rust/rustolonia/src/shell.rs @@ -0,0 +1,93 @@ +//! Desktop shell integration: the separately versioned stage 33 +//! `IAvnApplication6` capability. +//! +//! A tray or capture tool needs a few things the rest of the projection does +//! not cover: an application that keeps running with no window open +//! ([`AppContext::set_shutdown_mode`]), windows created hidden and shown later +//! ([`AppScope::mount_dynamic_view_model_hidden`], [`Window::show`]), +//! notification-area icons whose menu comes from the view-model schema +//! ([`AppScope::mount_dynamic_tray`], usually through a generated +//! `mount_`), and window-level settings: the native handle, topmost, +//! per-pixel transparency and exclusion from screen capture. + +use crate::{AppContext, AppScope, Result, Window}; +use rustolonia_sys as sys; + +/// When the application exits. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ShutdownMode { + /// When its last window closes (the default). + #[default] + OnLastWindowClose, + /// When the first window mounted closes. + OnMainWindowClose, + /// Only through [`AppScope::shutdown`]: a tray application keeps running + /// with every window closed or hidden. + OnExplicitShutdown, +} + +impl ShutdownMode { + fn to_abi(self) -> i32 { + match self { + ShutdownMode::OnLastWindowClose => 0, + ShutdownMode::OnMainWindowClose => 1, + ShutdownMode::OnExplicitShutdown => 2, + } + } +} + +impl AppContext { + fn shell(&self) -> Result> { + Ok(self.application().shell()?) + } + + /// Sets when the application exits. Takes effect at once; call it from the + /// startup callback, before the first window can close. + pub fn set_shutdown_mode(&self, mode: ShutdownMode) -> Result<()> { + Ok(self.shell()?.set_shutdown_mode(mode.to_abi())?) + } + + /// The window's native handle (an `HWND` on Windows), or 0 while the + /// platform window does not exist yet (before it is first shown). + pub fn platform_handle(&self, window: &Window) -> Result { + Ok(self.shell()?.platform_handle(&window.raw)? as isize) + } + + /// Keeps `window` above every window that is not topmost. + pub fn set_topmost(&self, window: &Window, topmost: bool) -> Result<()> { + Ok(self.shell()?.set_window_topmost(&window.raw, topmost)?) + } + + /// Asks for per-pixel transparency, so a transparent background shows + /// what is behind the window. + pub fn set_transparent(&self, window: &Window, transparent: bool) -> Result<()> { + Ok(self + .shell()? + .set_window_transparent(&window.raw, transparent)?) + } + + /// Leaves `window` out of screenshots and screen recordings. Windows only: + /// an error elsewhere. The window must have been shown once, so that its + /// native window exists. + pub fn set_excluded_from_capture(&self, window: &Window, excluded: bool) -> Result<()> { + Ok(self + .shell()? + .set_window_excluded_from_capture(&window.raw, excluded)?) + } +} + +impl AppScope { + /// Like [`AppScope::mount`], without showing the window: it is kept with + /// the other mounted windows and shown later with [`Window::show`]. + pub fn mount_hidden(&self, window: Window) -> Result<()> { + self.record_window(window); + Ok(()) + } +} + +impl Window { + /// Shows the window (a window mounted hidden, or one hidden since). + pub fn show(&self) -> Result<()> { + Ok(self.raw.show()?) + } +} diff --git a/rust/rustolonia/src/view_model.rs b/rust/rustolonia/src/view_model.rs index b3a7901..24e1c61 100644 --- a/rust/rustolonia/src/view_model.rs +++ b/rust/rustolonia/src/view_model.rs @@ -1281,6 +1281,37 @@ impl AppScope { self.retain_object(model); self.mount(Window { raw }) } + + /// Like [`AppScope::mount_dynamic_view_model`], without showing the window, + /// which is returned so it can be shown later (see [`Window::show`]). The + /// generated `mount__hidden` drops it; it is also the last entry of + /// [`AppScope::windows`]. + pub fn mount_dynamic_view_model_hidden( + &self, + view_id: i32, + model: impl DynamicViewModel, + ) -> Result { + let model = ViewModelHandle::new(model); + let raw = self + .application() + .create_rust_vm_window(view_id, &model.raw)?; + self.retain_object(model); + let window = Window { raw }; + self.mount_hidden(window.clone())?; + Ok(window) + } + + /// Shows the tray icon declared as `tray_id` in the view-model schema, + /// bound to its own `model` instance (as a window is). The icon stays until + /// the application shuts down. + pub fn mount_dynamic_tray(&self, tray_id: i32, model: impl DynamicViewModel) -> Result<()> { + let model = ViewModelHandle::new(model); + self.application() + .shell()? + .create_rust_vm_tray(tray_id, &model.raw)?; + self.retain_object(model); + Ok(()) + } } fn utf16(value: impl AsRef) -> Vec { diff --git a/samples/RustViewModelSample.Managed/Generated/.avalonia-viewmodel.owned.json b/samples/RustViewModelSample.Managed/Generated/.avalonia-viewmodel.owned.json index 8ce4237..76ca68e 100644 --- a/samples/RustViewModelSample.Managed/Generated/.avalonia-viewmodel.owned.json +++ b/samples/RustViewModelSample.Managed/Generated/.avalonia-viewmodel.owned.json @@ -27,7 +27,7 @@ }, { "path": "RustViewRegistry.g.cs", - "sha256": "10fc1b676cbb85e76bdf0ac16bf05fa24083e606b455b367751c3b7bd112e2b9" + "sha256": "6ab11cce3dd27fa0feb7b5a8954bb81cd1ca1fec48d65bea6539e56d8eefde4f" }, { "path": "SampleViewModelAdapter.g.cs", diff --git a/samples/RustViewModelSample.Managed/Generated/RustViewRegistry.g.cs b/samples/RustViewModelSample.Managed/Generated/RustViewRegistry.g.cs index 9291cf5..1ecef07 100644 --- a/samples/RustViewModelSample.Managed/Generated/RustViewRegistry.g.cs +++ b/samples/RustViewModelSample.Managed/Generated/RustViewRegistry.g.cs @@ -17,6 +17,11 @@ internal static class RustViewRegistry _ => throw new global::System.ArgumentOutOfRangeException(nameof(viewId)), }; + internal static IDisposable CreateTray(int trayId, IAvnRustViewModel model) => trayId switch + { + _ => throw new global::System.ArgumentOutOfRangeException(nameof(trayId)), + }; + private static TWindow CreateDynamic( IAvnRustViewModel model, RustViewModelDescriptor descriptor) diff --git a/tests/Avalonia.Projection.Generator.Tests/ViewModelTrayEmitterTests.cs b/tests/Avalonia.Projection.Generator.Tests/ViewModelTrayEmitterTests.cs new file mode 100644 index 0000000..6a23bb7 --- /dev/null +++ b/tests/Avalonia.Projection.Generator.Tests/ViewModelTrayEmitterTests.cs @@ -0,0 +1,124 @@ +using System; +using Avalonia.Projection.Ir; +using Xunit; + +namespace Avalonia.Projection.Generator.Tests; + +/// +/// Stage 33 generated tray icons: the tray menu factory, the attach method the +/// registry calls, and the Rust mount API. +/// +public class ViewModelTrayEmitterTests +{ + [Fact] + public void A_tray_menu_builds_a_native_menu_without_key_bindings() + { + var menus = ViewModelSourceEmitter.EmitCSharp(TrayIr())["ShellViewModelMenus.g.cs"]; + + Assert.Contains("public static NativeMenu CreateNotify(ShellViewModelAdapter model, RustMenuScope scope)", menus, StringComparison.Ordinal); + Assert.DoesNotContain("keyBindings", Section(menus, "CreateNotify("), StringComparison.Ordinal); + } + + [Fact] + public void A_tray_attaches_its_icon_tooltip_and_activate_command() + { + var menus = ViewModelSourceEmitter.EmitCSharp(TrayIr())["ShellViewModelMenus.g.cs"]; + + Assert.Contains("public static RustTrayAttachment AttachNotifyTray(ShellViewModelAdapter model)", menus, StringComparison.Ordinal); + Assert.Contains( + "RustTrayAttachment.Attach(menu, \"avares://Tests/Assets/app.ico\", \"Tests & co\", model.ShowCommand, scope, model)", + menus, + StringComparison.Ordinal); + } + + [Fact] + public void The_registry_creates_trays_by_id_with_their_own_adapter() + { + var registry = ViewModelSourceEmitter.EmitCSharp(TrayIr())["RustViewRegistry.g.cs"]; + + Assert.Contains("internal static IDisposable CreateTray(int trayId, IAvnRustViewModel model) => trayId switch", registry, StringComparison.Ordinal); + Assert.Contains("3 => global::Tests.ShellViewModelMenus.AttachNotifyTray(new global::Tests.ShellViewModelAdapter(model)),", registry, StringComparison.Ordinal); + } + + [Fact] + public void A_registry_without_trays_still_declares_create_tray() + { + var plain = new ViewModelIr { Models = TrayIr().Models }; + var registry = ViewModelSourceEmitter.EmitCSharp(plain)["RustViewRegistry.g.cs"]; + + Assert.Contains("internal static IDisposable CreateTray(int trayId, IAvnRustViewModel model) => trayId switch", registry, StringComparison.Ordinal); + } + + [Fact] + public void Rust_gets_a_named_mount_for_each_tray() + { + var ir = TrayIr(); + + Assert.Contains( + "pub fn mount_notify(&self, model: impl ShellViewModel) -> crate::Result<()> { self.mount_dynamic_tray(3, ShellViewModelDispatch { model }) }", + ViewModelSourceEmitter.EmitRust(ir), + StringComparison.Ordinal); + Assert.Contains( + "pub fn mount_notify(scope: &crate::AppScope, model: impl ShellViewModel) -> crate::Result<()> { scope.mount_dynamic_tray(3, ShellViewModelDispatch { model }) }", + ViewModelSourceEmitter.EmitRust(ir, externalConsumer: true), + StringComparison.Ordinal); + } + + [Fact] + public void Each_view_also_gets_a_hidden_mount() + { + var ir = new ViewModelIr + { + Models = TrayIr().Models, + Views = [new ViewDefinition { Id = 1, Name = "MainWindow", Model = "ShellViewModel", ManagedTypeName = "Tests.MainWindow" }], + }; + + Assert.Contains( + "pub fn mount_main_window_hidden(scope: &crate::AppScope, model: impl ShellViewModel) -> crate::Result<()> { scope.mount_dynamic_view_model_hidden(1, ShellViewModelDispatch { model }).map(drop) }", + ViewModelSourceEmitter.EmitRust(ir, externalConsumer: true), + StringComparison.Ordinal); + } + + /// The text of the generated method starting at . + private static string Section(string source, string signature) + { + var start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, signature); + var end = source.IndexOf("\n }\n", start, StringComparison.Ordinal); + return source[start..(end < 0 ? source.Length : end)]; + } + + private static ViewModelIr TrayIr() => new() + { + Models = + [ + new ViewModelDefinition + { + Id = 1, + Name = "ShellViewModel", + ManagedNamespace = "Tests", + Properties = [new ViewModelProperty { Id = 1, Name = "Paused", Kind = ViewModelValueKind.Boolean, Writable = true }], + Commands = [new ViewModelCommand { Id = 1, Name = "Show" }, new ViewModelCommand { Id = 2, Name = "Exit" }], + Menus = + [ + new ViewModelMenu + { + Id = 1, + Name = "Notify", + Kind = ViewModelMenuKind.Tray, + Items = + [ + new ViewModelMenuItem { Id = 1, Name = "Paused", Kind = ViewModelMenuItemKind.Toggle, Header = "Paused", ToggleProperty = "Paused" }, + new ViewModelMenuItem { Id = 2, Name = "Gap", Kind = ViewModelMenuItemKind.Separator }, + new ViewModelMenuItem { Id = 3, Name = "Exit", Kind = ViewModelMenuItemKind.Command, Header = "E_xit", Command = "Exit" }, + ], + }, + ], + }, + ], + Trays = + [ + new TrayDefinition { Id = 3, Name = "Notify", Model = "ShellViewModel", Menu = "Notify", Icon = "avares://Tests/Assets/app.ico", ToolTip = "Tests & co", ActivateCommand = "Show" }, + ], + }; +} diff --git a/tests/Avalonia.Projection.Ir.Tests/ViewModelTrayIrTests.cs b/tests/Avalonia.Projection.Ir.Tests/ViewModelTrayIrTests.cs new file mode 100644 index 0000000..dacc41e --- /dev/null +++ b/tests/Avalonia.Projection.Ir.Tests/ViewModelTrayIrTests.cs @@ -0,0 +1,112 @@ +using System; +using Avalonia.Projection.Ir; +using Xunit; + +namespace Avalonia.Projection.Ir.Tests; + +/// +/// Stage 33 notification-area (tray) icons: a top-level tray list naming a +/// model, a Tray menu on it, an icon and an optional activate command. +/// +public class ViewModelTrayIrTests +{ + [Fact] + public void A_tray_with_a_tray_menu_validates_and_round_trips() + { + var ir = Tray(); + ir.Validate(); + var again = ViewModelIr.FromJson(ir.ToJson()); + var tray = Assert.Single(again.TrayIcons); + Assert.Equal("Notify", tray.Name); + Assert.Equal("avares://Tests/Assets/app.ico", tray.Icon); + Assert.Equal("Show", tray.ActivateCommand); + } + + [Fact] + public void A_schema_without_trays_serializes_without_a_trays_member() + { + var ir = Tray(); + var plain = new ViewModelIr { Models = ir.Models }; + Assert.DoesNotContain("\"trays\"", plain.ToJson(), StringComparison.OrdinalIgnoreCase); + Assert.Empty(ViewModelIr.FromJson(plain.ToJson()).TrayIcons); + } + + [Fact] + public void A_tray_must_name_a_tray_menu() + { + var error = Assert.Throws(() => Tray(menuKind: ViewModelMenuKind.Application).Validate()); + Assert.Contains("must be a Tray menu", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_tray_must_name_known_models_menus_and_commands() + { + Assert.Contains("unknown model", Assert.Throws(() => Tray(model: "Missing").Validate()).Message); + Assert.Contains("unknown menu", Assert.Throws(() => Tray(menu: "Missing").Validate()).Message); + Assert.Contains("unknown activate command", Assert.Throws(() => Tray(activate: "Missing").Validate()).Message); + } + + [Fact] + public void A_tray_needs_an_icon() + { + Assert.Throws(() => Tray(icon: " ").Validate()); + } + + [Fact] + public void A_tray_menu_rejects_gestures_even_in_submenus() + { + var error = Assert.Throws(() => Tray(gesture: "Ctrl+Q").Validate()); + Assert.Contains("must not declare gestures", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void Trays_need_schema_version_five() + { + // A model with no menus of its own, so only the tray needs version 5. + var downgraded = new ViewModelIr + { + Version = 4, + Models = [new() { Id = 1, Name = "Shell", ManagedNamespace = "Tests", Commands = [new() { Id = 1, Name = "Show" }] }], + Trays = Tray().Trays, + }; + Assert.Contains("upgrade to version 5", Assert.Throws(() => downgraded.Validate()).Message); + } + + private static ViewModelIr Tray( + string model = "Shell", + string menu = "Notify", + string activate = "Show", + string icon = "avares://Tests/Assets/app.ico", + ViewModelMenuKind menuKind = ViewModelMenuKind.Tray, + string? gesture = null) => new() + { + Models = + [ + new() + { + Id = 1, + Name = "Shell", + ManagedNamespace = "Tests", + Commands = [new() { Id = 1, Name = "Show" }, new() { Id = 2, Name = "Exit" }], + Menus = + [ + new() + { + Id = 1, + Name = "Notify", + Kind = menuKind, + Items = + [ + new() + { + Id = 1, Name = "More", Kind = ViewModelMenuItemKind.Submenu, Header = "More", + Items = [new() { Id = 2, Name = "Exit", Kind = ViewModelMenuItemKind.Command, Header = "Exit", Command = "Exit", Gesture = gesture }], + }, + ], + }, + ], + }, + ], + Trays = [new() { Id = 1, Name = "Notify", Model = model, Menu = menu, Icon = icon, ToolTip = "Tests", ActivateCommand = activate }], + }; +}