From b6371c63da74c2b86a7183979bb84543ccff239c Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Fri, 29 May 2026 17:40:37 +0000 Subject: [PATCH 1/3] Phase 5: hotkey, tray, close-to-tray, file picker abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WindowsHotkeyService registers the global hotkey via Win32 RegisterHotKey and subclasses the main window's WndProc with SetWindowSubclass to catch WM_HOTKEY. HotkeyModifiers map straight through to MOD_* constants (intentional alignment from Phase 1). Default Ctrl+Alt+S registered on startup; pressing it brings the window to the foreground from anywhere. HNotifyIconTrayService creates an H.NotifyIcon TaskbarIcon with: - left-click → ShowRequested → bring window forward - right-click context menu: "Show Snipdeck" and "Exit" Close-to-tray: App subscribes to AppWindow.Closing on the main window. When config says HideToTray, Closing is cancelled and the window hides. The tray's Exit flips an _allowClose flag so the next close passes through cleanly. The process keeps running while hidden so the hotkey stays live. IFilePickerService abstracts the FileOpenPicker setup (HWND init, filters, byte readback). CliEditorDialog no longer pokes Win32 directly. Identicon UserControl gains an IconRef dependency property; when set, it resolves the absolute path via IIconAssetStorage and renders the uploaded PNG instead of the identicon. CliCard binds both Seed (Cli.Id) and IconRef so uploaded icons replace the identicon in the home grid. This PR is the third in a stack of four. Base: feat/phase-4-authoring. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 20 ++ src/Snipdeck.App/App.xaml.cs | 61 +++++- src/Snipdeck.App/Bootstrap.cs | 3 + src/Snipdeck.App/Controls/CliCard.xaml | 1 + src/Snipdeck.App/Controls/Identicon.xaml.cs | 44 +++- .../Services/HNotifyIconTrayService.cs | 89 +++++++++ .../Services/WindowsFilePickerService.cs | 60 ++++++ .../Services/WindowsHotkeyService.cs | 189 ++++++++++++++++++ .../Services/WindowsShellInteractions.cs | 16 +- .../Views/CliEditorDialog.xaml.cs | 42 +--- .../Abstractions/IFilePickerService.cs | 9 + .../Abstractions/IHotkeyService.cs | 18 ++ .../Abstractions/ITrayService.cs | 11 + 13 files changed, 507 insertions(+), 56 deletions(-) create mode 100644 src/Snipdeck.App/Services/HNotifyIconTrayService.cs create mode 100644 src/Snipdeck.App/Services/WindowsFilePickerService.cs create mode 100644 src/Snipdeck.App/Services/WindowsHotkeyService.cs create mode 100644 src/Snipdeck.Core/Abstractions/IFilePickerService.cs create mode 100644 src/Snipdeck.Core/Abstractions/IHotkeyService.cs create mode 100644 src/Snipdeck.Core/Abstractions/ITrayService.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b0ae832..2e468ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added — Phase 5: Platform services +- **Global hotkey** via Win32 `RegisterHotKey`. Default Ctrl+Alt+S; pressed + anywhere brings the existing Snipdeck window to the foreground. + `WindowsHotkeyService` subclasses the main window's WndProc with + `SetWindowSubclass` to catch `WM_HOTKEY`. `HotkeyModifiers` map straight + through to `MOD_*` constants (intentional alignment from Phase 1). +- **Tray icon** via `H.NotifyIcon`. Left-click brings the window forward, + right-click shows a context menu with **Show Snipdeck** and **Exit**. +- **Close-to-tray** behaviour: when `AppConfig.CloseBehaviour` is + `HideToTray`, `AppWindow.Closing` is cancelled and the window hides. The + tray's **Exit** flips an internal flag and lets the next close pass + through cleanly. +- **`IFilePickerService`** abstraction (App-side `WindowsFilePickerService`) + centralises file-picker setup. `CliEditorDialog` no longer pokes Win32 itself + — it consumes the abstraction. +- **CLI cards now render uploaded icons** (when `Cli.IconRef` is set), falling + back to identicons when not. `Identicon` control gains an `IconRef` + dependency property and resolves the absolute path via + `IIconAssetStorage`. + ### Added — Phase 4: Authoring + parameter-fill flyout - Snip card actions are now live: **Copy** opens a parameter-fill `ContentDialog` (or copies the template directly when the Snip has no diff --git a/src/Snipdeck.App/App.xaml.cs b/src/Snipdeck.App/App.xaml.cs index 27854cd..8a911a0 100644 --- a/src/Snipdeck.App/App.xaml.cs +++ b/src/Snipdeck.App/App.xaml.cs @@ -3,13 +3,18 @@ using Microsoft.Windows.AppLifecycle; using Snipdeck.Core.Abstractions; +using Snipdeck.Core.Models; using Snipdeck.Core.Services; namespace Snipdeck.App { public partial class App : Application { - private Window? _mainWindow; + private MainWindow? _mainWindow; + private IHotkeyService? _hotkey; + private ITrayService? _tray; + private AppConfig? _config; + private bool _allowClose; public App() { @@ -32,8 +37,13 @@ protected override async void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventA await SeedFirstRunIfEmptyAsync(); + _config = Services.GetRequiredService(); _mainWindow = Services.GetRequiredService(); _mainWindow.Activate(); + + WireCloseToTray(_mainWindow, _config); + InitialiseTray(); + InitialiseHotkey(_config); } private static async Task SeedFirstRunIfEmptyAsync() @@ -49,10 +59,53 @@ private static async Task SeedFirstRunIfEmptyAsync() private void OnInstanceActivated(object? sender, AppActivationArguments e) { var dispatcher = Services.GetRequiredService(); - dispatcher.Enqueue(() => + dispatcher.Enqueue(() => BringToFront()); + } + + private void WireCloseToTray(MainWindow window, AppConfig config) + { + // AppWindow.Closing fires before the actual close happens and is + // cancellable. In hide-to-tray mode we hide the window and keep + // the process alive; the tray "Exit" command flips _allowClose + // and lets the next close pass through. + window.AppWindow.Closing += (_, args) => { - _mainWindow?.Activate(); - }); + if (_allowClose || config.CloseBehaviour == CloseBehaviour.Exit) + { + return; + } + args.Cancel = true; + window.AppWindow.Hide(); + }; + } + + private void InitialiseTray() + { + _tray = Services.GetRequiredService(); + _tray.ShowRequested += (_, _) => BringToFront(); + _tray.ExitRequested += (_, _) => + { + _allowClose = true; + _mainWindow?.Close(); + }; + _tray.Initialise(); + } + + private void InitialiseHotkey(AppConfig config) + { + _hotkey = Services.GetRequiredService(); + _hotkey.Pressed += (_, _) => BringToFront(); + _ = _hotkey.TryRegister(config.Hotkey); + } + + private void BringToFront() + { + if (_mainWindow is null) + { + return; + } + _mainWindow.AppWindow.Show(); + _mainWindow.Activate(); } } } diff --git a/src/Snipdeck.App/Bootstrap.cs b/src/Snipdeck.App/Bootstrap.cs index e6640c7..c454872 100644 --- a/src/Snipdeck.App/Bootstrap.cs +++ b/src/Snipdeck.App/Bootstrap.cs @@ -41,6 +41,9 @@ public static IServiceProvider Build() .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton(settingsStore) .AddSingleton(snipStore) diff --git a/src/Snipdeck.App/Controls/CliCard.xaml b/src/Snipdeck.App/Controls/CliCard.xaml index 65d80f9..68c3576 100644 --- a/src/Snipdeck.App/Controls/CliCard.xaml +++ b/src/Snipdeck.App/Controls/CliCard.xaml @@ -26,6 +26,7 @@ - /// Renders an identicon from a seed. The seed should be - /// the immutable Cli.Id so renaming a CLI doesn't change its icon. + /// Renders a CLI's icon. Falls back to a deterministic identicon seeded + /// off when is empty or the + /// referenced file can't be read. /// public sealed partial class Identicon : UserControl { public static readonly DependencyProperty SeedProperty = - DependencyProperty.Register( - nameof(Seed), - typeof(Guid), - typeof(Identicon), - new PropertyMetadata(Guid.Empty, OnSeedChanged)); + DependencyProperty.Register(nameof(Seed), typeof(Guid), typeof(Identicon), + new PropertyMetadata(Guid.Empty, OnVisualPropertyChanged)); + + public static readonly DependencyProperty IconRefProperty = + DependencyProperty.Register(nameof(IconRef), typeof(string), typeof(Identicon), + new PropertyMetadata(null, OnVisualPropertyChanged)); public Identicon() { @@ -32,7 +35,13 @@ public Guid Seed set => SetValue(SeedProperty, value); } - private static void OnSeedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + public string? IconRef + { + get => (string?)GetValue(IconRefProperty); + set => SetValue(IconRefProperty, value); + } + + private static void OnVisualPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is Identicon icon) { @@ -42,13 +51,28 @@ private static void OnSeedChanged(DependencyObject d, DependencyPropertyChangedE private async Task UpdateImageAsync() { + // Prefer the uploaded icon if present. + var storage = App.Services.GetService(typeof(IIconAssetStorage)) as IIconAssetStorage; + var absolute = storage?.ResolveAbsolutePath(IconRef); + if (!string.IsNullOrEmpty(absolute) && System.IO.File.Exists(absolute)) + { + var bytes = await System.IO.File.ReadAllBytesAsync(absolute); + IconImage.Source = await DecodeAsync(bytes); + return; + } + if (Seed == Guid.Empty) { IconImage.Source = null; return; } - var bytes = IdenticonService.GeneratePng(Seed); + var identicon = IdenticonService.GeneratePng(Seed); + IconImage.Source = await DecodeAsync(identicon); + } + + private static async Task DecodeAsync(byte[] bytes) + { var image = new BitmapImage(); using var stream = new InMemoryRandomAccessStream(); var writer = new DataWriter(stream); @@ -57,7 +81,7 @@ private async Task UpdateImageAsync() _ = writer.DetachStream(); stream.Seek(0); await image.SetSourceAsync(stream); - IconImage.Source = image; + return image; } } } diff --git a/src/Snipdeck.App/Services/HNotifyIconTrayService.cs b/src/Snipdeck.App/Services/HNotifyIconTrayService.cs new file mode 100644 index 0000000..65d2f18 --- /dev/null +++ b/src/Snipdeck.App/Services/HNotifyIconTrayService.cs @@ -0,0 +1,89 @@ +using H.NotifyIcon; + +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; + +using Snipdeck.Core.Abstractions; + +namespace Snipdeck.App.Services +{ + internal sealed class HNotifyIconTrayService : ITrayService + { + private TaskbarIcon? _icon; + private bool _disposed; + + public event EventHandler? ShowRequested; + + public event EventHandler? ExitRequested; + + public void Initialise() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_icon is not null) + { + return; + } + + _icon = new TaskbarIcon + { + ToolTipText = "Snipdeck", + IconSource = new FontIconSource + { + Glyph = "", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + }, + ContextFlyout = BuildContextMenu(), + NoLeftClickDelay = true, + }; + _icon.LeftClickCommand = new RelayCommand(() => ShowRequested?.Invoke(this, EventArgs.Empty)); + _icon.ForceCreate(); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + _icon?.Dispose(); + _icon = null; + } + + private MenuFlyout BuildContextMenu() + { + var menu = new MenuFlyout(); + + var showItem = new MenuFlyoutItem { Text = "Show Snipdeck" }; + showItem.Click += (_, _) => ShowRequested?.Invoke(this, EventArgs.Empty); + menu.Items.Add(showItem); + + menu.Items.Add(new MenuFlyoutSeparator()); + + var exitItem = new MenuFlyoutItem { Text = "Exit" }; + exitItem.Click += (_, _) => ExitRequested?.Invoke(this, EventArgs.Empty); + menu.Items.Add(exitItem); + + return menu; + } + + private sealed class RelayCommand : System.Windows.Input.ICommand + { + private readonly Action _execute; + + public RelayCommand(Action execute) + { + _execute = execute; + } + +#pragma warning disable CS0067 // 'CanExecuteChanged' is never used — relay never changes. + public event EventHandler? CanExecuteChanged; +#pragma warning restore CS0067 + + public bool CanExecute(object? parameter) => true; + + public void Execute(object? parameter) => _execute(); + } + } +} diff --git a/src/Snipdeck.App/Services/WindowsFilePickerService.cs b/src/Snipdeck.App/Services/WindowsFilePickerService.cs new file mode 100644 index 0000000..6399790 --- /dev/null +++ b/src/Snipdeck.App/Services/WindowsFilePickerService.cs @@ -0,0 +1,60 @@ +using Snipdeck.Core.Abstractions; + +using Windows.Storage; +using Windows.Storage.Pickers; +using Windows.Storage.Streams; + +namespace Snipdeck.App.Services +{ + internal sealed class WindowsFilePickerService : IFilePickerService + { + private readonly IServiceProvider _services; + + public WindowsFilePickerService(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + _services = services; + } + + public async Task PickImageAsync() + { + var picker = new FileOpenPicker + { + SuggestedStartLocation = PickerLocationId.PicturesLibrary, + ViewMode = PickerViewMode.Thumbnail, + }; + picker.FileTypeFilter.Add(".png"); + picker.FileTypeFilter.Add(".jpg"); + picker.FileTypeFilter.Add(".jpeg"); + picker.FileTypeFilter.Add(".bmp"); + picker.FileTypeFilter.Add(".webp"); + + var hwnd = GetMainWindowHandle(); + WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd); + + var file = await picker.PickSingleFileAsync(); + if (file is null) + { + return null; + } + + var bytes = await ReadAllBytesAsync(file); + return new PickedFile(file.Name, bytes); + } + + private IntPtr GetMainWindowHandle() + { + var mainWindow = (MainWindow)_services.GetService(typeof(MainWindow))!; + return WinRT.Interop.WindowNative.GetWindowHandle(mainWindow); + } + + private static async Task ReadAllBytesAsync(StorageFile file) + { + var buffer = await FileIO.ReadBufferAsync(file); + var bytes = new byte[buffer.Length]; + var reader = DataReader.FromBuffer(buffer); + reader.ReadBytes(bytes); + return bytes; + } + } +} diff --git a/src/Snipdeck.App/Services/WindowsHotkeyService.cs b/src/Snipdeck.App/Services/WindowsHotkeyService.cs new file mode 100644 index 0000000..8a483c8 --- /dev/null +++ b/src/Snipdeck.App/Services/WindowsHotkeyService.cs @@ -0,0 +1,189 @@ +using System.Runtime.InteropServices; + +using Snipdeck.Core.Abstractions; +using Snipdeck.Core.Models; + +namespace Snipdeck.App.Services +{ + /// + /// Win32 RegisterHotKey-backed global hotkey. The WM_HOTKEY message arrives + /// at the main window, so we subclass its WndProc to forward it as an + /// event on the original thread. + /// + internal sealed partial class WindowsHotkeyService : IHotkeyService, IDisposable + { + private const int _hotkeyId = 0x5DEC; + private const uint _wmHotkey = 0x0312; + private const uint _subclassId = 0xDECA; + + private readonly IServiceProvider _services; + private readonly SubclassProc _subclassProc; + private IntPtr _windowHandle; + private bool _subclassed; + private bool _registered; + + public WindowsHotkeyService(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + _services = services; + _subclassProc = OnSubclassedMessage; + } + + public event EventHandler? Pressed; + + public bool TryRegister(HotkeyBinding binding) + { + ArgumentNullException.ThrowIfNull(binding); + if (binding.IsEmpty) + { + return false; + } + + EnsureSubclassed(); + if (_windowHandle == IntPtr.Zero) + { + return false; + } + + if (_registered) + { + Unregister(); + } + + if (!TryGetVirtualKey(binding.Key, out var vk)) + { + return false; + } + + var modifiers = (uint)binding.Modifiers; + if (!RegisterHotKey(_windowHandle, _hotkeyId, modifiers, vk)) + { + return false; + } + + _registered = true; + return true; + } + + public void Unregister() + { + if (_registered && _windowHandle != IntPtr.Zero) + { + _ = UnregisterHotKey(_windowHandle, _hotkeyId); + _registered = false; + } + } + + public void Dispose() + { + Unregister(); + if (_subclassed && _windowHandle != IntPtr.Zero) + { + _ = RemoveWindowSubclass(_windowHandle, _subclassProc, _subclassId); + _subclassed = false; + } + } + + private void EnsureSubclassed() + { + if (_subclassed) + { + return; + } + var mainWindow = (MainWindow?)_services.GetService(typeof(MainWindow)); + if (mainWindow is null) + { + return; + } + _windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(mainWindow); + if (_windowHandle == IntPtr.Zero) + { + return; + } + _ = SetWindowSubclass(_windowHandle, _subclassProc, _subclassId, IntPtr.Zero); + _subclassed = true; + } + + private IntPtr OnSubclassedMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam, uint id, IntPtr refData) + { + if (msg == _wmHotkey && wParam.ToInt32() == _hotkeyId) + { + Pressed?.Invoke(this, EventArgs.Empty); + } + return DefSubclassProc(hWnd, msg, wParam, lParam); + } + + private static bool TryGetVirtualKey(string key, out uint vk) + { + vk = 0; + if (string.IsNullOrWhiteSpace(key)) + { + return false; + } + + var trimmed = key.Trim(); + if (trimmed.Length == 1) + { + var c = char.ToUpperInvariant(trimmed[0]); + if (c is >= 'A' and <= 'Z') + { + vk = c; + return true; + } + if (c is >= '0' and <= '9') + { + vk = c; + return true; + } + } + + return trimmed.ToUpperInvariant() switch + { + "F1" => Assign(0x70, out vk), + "F2" => Assign(0x71, out vk), + "F3" => Assign(0x72, out vk), + "F4" => Assign(0x73, out vk), + "F5" => Assign(0x74, out vk), + "F6" => Assign(0x75, out vk), + "F7" => Assign(0x76, out vk), + "F8" => Assign(0x77, out vk), + "F9" => Assign(0x78, out vk), + "F10" => Assign(0x79, out vk), + "F11" => Assign(0x7A, out vk), + "F12" => Assign(0x7B, out vk), + "SPACE" => Assign(0x20, out vk), + "ESCAPE" or "ESC" => Assign(0x1B, out vk), + "TAB" => Assign(0x09, out vk), + "ENTER" or "RETURN" => Assign(0x0D, out vk), + _ => false, + }; + } + + private static bool Assign(uint value, out uint vk) + { + vk = value; + return true; + } + + private delegate IntPtr SubclassProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam, uint id, IntPtr refData); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool UnregisterHotKey(IntPtr hWnd, int id); + + [DllImport("comctl32.dll", CharSet = CharSet.Auto)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetWindowSubclass(IntPtr hWnd, SubclassProc pfnSubclass, uint uIdSubclass, IntPtr dwRefData); + + [DllImport("comctl32.dll", CharSet = CharSet.Auto)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool RemoveWindowSubclass(IntPtr hWnd, SubclassProc pfnSubclass, uint uIdSubclass); + + [DllImport("comctl32.dll", CharSet = CharSet.Auto)] + private static extern IntPtr DefSubclassProc(IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam); + } +} diff --git a/src/Snipdeck.App/Services/WindowsShellInteractions.cs b/src/Snipdeck.App/Services/WindowsShellInteractions.cs index 64ee006..da9d4ce 100644 --- a/src/Snipdeck.App/Services/WindowsShellInteractions.cs +++ b/src/Snipdeck.App/Services/WindowsShellInteractions.cs @@ -17,13 +17,19 @@ internal sealed class WindowsShellInteractions : IShellInteractions { private readonly IServiceProvider _services; private readonly IIconNormaliser _iconNormaliser; + private readonly IFilePickerService _filePicker; - public WindowsShellInteractions(IServiceProvider services, IIconNormaliser iconNormaliser) + public WindowsShellInteractions( + IServiceProvider services, + IIconNormaliser iconNormaliser, + IFilePickerService filePicker) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(iconNormaliser); + ArgumentNullException.ThrowIfNull(filePicker); _services = services; _iconNormaliser = iconNormaliser; + _filePicker = filePicker; } public async Task ConfirmAsync(string title, string message, string confirmButtonText = "Yes", string cancelButtonText = "Cancel") @@ -59,8 +65,7 @@ public async Task ConfirmAsync(string title, string message, string confir { ArgumentNullException.ThrowIfNull(cli); var editor = new CliEditorViewModel(cli); - var hwnd = GetMainWindowHandle(); - var dialog = new CliEditorDialog(editor, _iconNormaliser, hwnd) + var dialog = new CliEditorDialog(editor, _iconNormaliser, _filePicker) { XamlRoot = GetXamlRoot(), }; @@ -92,10 +97,5 @@ private XamlRoot GetXamlRoot() return ((FrameworkElement)content).XamlRoot; } - private IntPtr GetMainWindowHandle() - { - var mainWindow = (MainWindow)_services.GetService(typeof(MainWindow))!; - return WinRT.Interop.WindowNative.GetWindowHandle(mainWindow); - } } } diff --git a/src/Snipdeck.App/Views/CliEditorDialog.xaml.cs b/src/Snipdeck.App/Views/CliEditorDialog.xaml.cs index bc091df..e3da04d 100644 --- a/src/Snipdeck.App/Views/CliEditorDialog.xaml.cs +++ b/src/Snipdeck.App/Views/CliEditorDialog.xaml.cs @@ -4,26 +4,24 @@ using Snipdeck.Core.Abstractions; using Snipdeck.Core.ViewModels; -using Windows.Storage; -using Windows.Storage.Pickers; - namespace Snipdeck.App.Views { public sealed partial class CliEditorDialog : ContentDialog { private readonly IIconNormaliser _iconNormaliser; - private readonly IntPtr _ownerWindowHandle; + private readonly IFilePickerService _filePicker; public CliEditorDialog( CliEditorViewModel viewModel, IIconNormaliser iconNormaliser, - IntPtr ownerWindowHandle) + IFilePickerService filePicker) { ArgumentNullException.ThrowIfNull(viewModel); ArgumentNullException.ThrowIfNull(iconNormaliser); + ArgumentNullException.ThrowIfNull(filePicker); ViewModel = viewModel; _iconNormaliser = iconNormaliser; - _ownerWindowHandle = ownerWindowHandle; + _filePicker = filePicker; InitializeComponent(); UpdatePrimaryButtonEnabled(); viewModel.PropertyChanged += (_, _) => UpdatePrimaryButtonEnabled(); @@ -38,38 +36,14 @@ private void UpdatePrimaryButtonEnabled() private async void OnPickIconClicked(object sender, RoutedEventArgs e) { - var picker = new FileOpenPicker - { - SuggestedStartLocation = PickerLocationId.PicturesLibrary, - ViewMode = PickerViewMode.Thumbnail, - }; - picker.FileTypeFilter.Add(".png"); - picker.FileTypeFilter.Add(".jpg"); - picker.FileTypeFilter.Add(".jpeg"); - picker.FileTypeFilter.Add(".bmp"); - picker.FileTypeFilter.Add(".webp"); - - WinRT.Interop.InitializeWithWindow.Initialize(picker, _ownerWindowHandle); - - var file = await picker.PickSingleFileAsync(); - if (file is null) + var picked = await _filePicker.PickImageAsync(); + if (picked is null) { return; } - - var raw = await ReadAllBytesAsync(file); - var normalised = await _iconNormaliser.NormaliseAsync(raw); + var normalised = await _iconNormaliser.NormaliseAsync(picked.Bytes); ViewModel.PickedIconBytes = normalised; - ViewModel.PickedIconFileName = file.Name; - } - - private static async Task ReadAllBytesAsync(StorageFile file) - { - var buffer = await FileIO.ReadBufferAsync(file); - var bytes = new byte[buffer.Length]; - var reader = Windows.Storage.Streams.DataReader.FromBuffer(buffer); - reader.ReadBytes(bytes); - return bytes; + ViewModel.PickedIconFileName = picked.FileName; } } } diff --git a/src/Snipdeck.Core/Abstractions/IFilePickerService.cs b/src/Snipdeck.Core/Abstractions/IFilePickerService.cs new file mode 100644 index 0000000..ddbb1d6 --- /dev/null +++ b/src/Snipdeck.Core/Abstractions/IFilePickerService.cs @@ -0,0 +1,9 @@ +namespace Snipdeck.Core.Abstractions +{ + public sealed record PickedFile(string FileName, byte[] Bytes); + + public interface IFilePickerService + { + Task PickImageAsync(); + } +} diff --git a/src/Snipdeck.Core/Abstractions/IHotkeyService.cs b/src/Snipdeck.Core/Abstractions/IHotkeyService.cs new file mode 100644 index 0000000..27b2884 --- /dev/null +++ b/src/Snipdeck.Core/Abstractions/IHotkeyService.cs @@ -0,0 +1,18 @@ +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.Abstractions +{ + /// + /// Registers a system-wide hotkey via the platform. The implementation is + /// responsible for translating into the + /// appropriate native constants (on Windows: MOD_* + virtual-key codes). + /// + public interface IHotkeyService + { + event EventHandler? Pressed; + + bool TryRegister(HotkeyBinding binding); + + void Unregister(); + } +} diff --git a/src/Snipdeck.Core/Abstractions/ITrayService.cs b/src/Snipdeck.Core/Abstractions/ITrayService.cs new file mode 100644 index 0000000..b986f67 --- /dev/null +++ b/src/Snipdeck.Core/Abstractions/ITrayService.cs @@ -0,0 +1,11 @@ +namespace Snipdeck.Core.Abstractions +{ + public interface ITrayService : IDisposable + { + event EventHandler? ShowRequested; + + event EventHandler? ExitRequested; + + void Initialise(); + } +} From 0bdd0853318ab7f3047aac204ecb287076fd5f68 Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Fri, 29 May 2026 17:45:27 +0000 Subject: [PATCH 2/3] Fix Phase 5 build: tray icon needs ImageSource and Identicon needs qualified App.Services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TaskbarIcon.IconSource is Microsoft.UI.Xaml.Media.ImageSource — FontIconSource isn't assignable. Generate a stable identicon PNG at init time via IdenticonService.GeneratePng and load it as a BitmapImage. The init has to go async (image decode requires await), so ITrayService.Initialise becomes ITrayService.InitialiseAsync. Inside namespace Snipdeck.App.Controls, the unqualified 'App' identifier doesn't resolve to Snipdeck.App.App (sibling namespaces aren't searched). Fully qualify the call in Identicon.cs. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Snipdeck.App/App.xaml.cs | 6 ++-- src/Snipdeck.App/Controls/Identicon.xaml.cs | 2 +- .../Services/HNotifyIconTrayService.cs | 32 +++++++++++++++---- .../Abstractions/ITrayService.cs | 2 +- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/Snipdeck.App/App.xaml.cs b/src/Snipdeck.App/App.xaml.cs index 8a911a0..31e0155 100644 --- a/src/Snipdeck.App/App.xaml.cs +++ b/src/Snipdeck.App/App.xaml.cs @@ -42,7 +42,7 @@ protected override async void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventA _mainWindow.Activate(); WireCloseToTray(_mainWindow, _config); - InitialiseTray(); + await InitialiseTrayAsync(); InitialiseHotkey(_config); } @@ -79,7 +79,7 @@ private void WireCloseToTray(MainWindow window, AppConfig config) }; } - private void InitialiseTray() + private async Task InitialiseTrayAsync() { _tray = Services.GetRequiredService(); _tray.ShowRequested += (_, _) => BringToFront(); @@ -88,7 +88,7 @@ private void InitialiseTray() _allowClose = true; _mainWindow?.Close(); }; - _tray.Initialise(); + await _tray.InitialiseAsync(); } private void InitialiseHotkey(AppConfig config) diff --git a/src/Snipdeck.App/Controls/Identicon.xaml.cs b/src/Snipdeck.App/Controls/Identicon.xaml.cs index 27ad9b2..53e7ec2 100644 --- a/src/Snipdeck.App/Controls/Identicon.xaml.cs +++ b/src/Snipdeck.App/Controls/Identicon.xaml.cs @@ -52,7 +52,7 @@ private static void OnVisualPropertyChanged(DependencyObject d, DependencyProper private async Task UpdateImageAsync() { // Prefer the uploaded icon if present. - var storage = App.Services.GetService(typeof(IIconAssetStorage)) as IIconAssetStorage; + var storage = Snipdeck.App.App.Services.GetService(typeof(IIconAssetStorage)) as IIconAssetStorage; var absolute = storage?.ResolveAbsolutePath(IconRef); if (!string.IsNullOrEmpty(absolute) && System.IO.File.Exists(absolute)) { diff --git a/src/Snipdeck.App/Services/HNotifyIconTrayService.cs b/src/Snipdeck.App/Services/HNotifyIconTrayService.cs index 65d2f18..618353a 100644 --- a/src/Snipdeck.App/Services/HNotifyIconTrayService.cs +++ b/src/Snipdeck.App/Services/HNotifyIconTrayService.cs @@ -1,14 +1,20 @@ using H.NotifyIcon; using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; using Snipdeck.Core.Abstractions; +using Snipdeck.Core.Services; + +using Windows.Storage.Streams; namespace Snipdeck.App.Services { internal sealed class HNotifyIconTrayService : ITrayService { + // Stable seed so the tray icon never changes between runs of Snipdeck. + private static readonly Guid _iconSeed = Guid.Parse("5147DEC0-0000-0000-0000-000000000001"); + private TaskbarIcon? _icon; private bool _disposed; @@ -16,7 +22,7 @@ internal sealed class HNotifyIconTrayService : ITrayService public event EventHandler? ExitRequested; - public void Initialise() + public async Task InitialiseAsync() { ObjectDisposedException.ThrowIf(_disposed, this); @@ -25,14 +31,12 @@ public void Initialise() return; } + var image = await BuildTrayBitmapAsync(); + _icon = new TaskbarIcon { ToolTipText = "Snipdeck", - IconSource = new FontIconSource - { - Glyph = "", - FontFamily = new FontFamily("Segoe MDL2 Assets"), - }, + IconSource = image, ContextFlyout = BuildContextMenu(), NoLeftClickDelay = true, }; @@ -68,6 +72,20 @@ private MenuFlyout BuildContextMenu() return menu; } + private static async Task BuildTrayBitmapAsync() + { + var bytes = IdenticonService.GeneratePng(_iconSeed, size: 32); + var image = new BitmapImage(); + using var stream = new InMemoryRandomAccessStream(); + var writer = new DataWriter(stream); + writer.WriteBytes(bytes); + _ = await writer.StoreAsync(); + _ = writer.DetachStream(); + stream.Seek(0); + await image.SetSourceAsync(stream); + return image; + } + private sealed class RelayCommand : System.Windows.Input.ICommand { private readonly Action _execute; diff --git a/src/Snipdeck.Core/Abstractions/ITrayService.cs b/src/Snipdeck.Core/Abstractions/ITrayService.cs index b986f67..a664629 100644 --- a/src/Snipdeck.Core/Abstractions/ITrayService.cs +++ b/src/Snipdeck.Core/Abstractions/ITrayService.cs @@ -6,6 +6,6 @@ public interface ITrayService : IDisposable event EventHandler? ExitRequested; - void Initialise(); + Task InitialiseAsync(); } } From 06ff20ec45957fa9eebd098716592936bf99e8d1 Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Fri, 29 May 2026 17:49:30 +0000 Subject: [PATCH 3/3] Fix Phase 5 analyser fallout: partial classes, LibraryImport, named event handlers - partial on HNotifyIconTrayService + its nested RelayCommand (CsWinRT1028). - RelayCommand uses primary constructor (IDE0290). - HNotifyIconTrayService menu construction switches to collection initialiser (IDE0017). - App.xaml.cs hoists the lambda subscriptions to named methods so IDE0200 stops flagging "(_, _) => Method()" patterns. - WindowsHotkeyService converts SetWindowSubclass / RemoveWindowSubclass / DefSubclassProc from DllImport to LibraryImport with the source-generated marshalling (SYSLIB1054). SubclassProc parameters are marked UnmanagedType.FunctionPtr. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Snipdeck.App/App.xaml.cs | 26 +++++----- .../Services/HNotifyIconTrayService.cs | 50 +++++++++++-------- .../Services/WindowsHotkeyService.cs | 19 ++++--- 3 files changed, 57 insertions(+), 38 deletions(-) diff --git a/src/Snipdeck.App/App.xaml.cs b/src/Snipdeck.App/App.xaml.cs index 31e0155..61b56ba 100644 --- a/src/Snipdeck.App/App.xaml.cs +++ b/src/Snipdeck.App/App.xaml.cs @@ -59,15 +59,11 @@ private static async Task SeedFirstRunIfEmptyAsync() private void OnInstanceActivated(object? sender, AppActivationArguments e) { var dispatcher = Services.GetRequiredService(); - dispatcher.Enqueue(() => BringToFront()); + dispatcher.Enqueue(BringToFront); } private void WireCloseToTray(MainWindow window, AppConfig config) { - // AppWindow.Closing fires before the actual close happens and is - // cancellable. In hide-to-tray mode we hide the window and keep - // the process alive; the tray "Exit" command flips _allowClose - // and lets the next close pass through. window.AppWindow.Closing += (_, args) => { if (_allowClose || config.CloseBehaviour == CloseBehaviour.Exit) @@ -82,22 +78,28 @@ private void WireCloseToTray(MainWindow window, AppConfig config) private async Task InitialiseTrayAsync() { _tray = Services.GetRequiredService(); - _tray.ShowRequested += (_, _) => BringToFront(); - _tray.ExitRequested += (_, _) => - { - _allowClose = true; - _mainWindow?.Close(); - }; + _tray.ShowRequested += OnTrayShowRequested; + _tray.ExitRequested += OnTrayExitRequested; await _tray.InitialiseAsync(); } private void InitialiseHotkey(AppConfig config) { _hotkey = Services.GetRequiredService(); - _hotkey.Pressed += (_, _) => BringToFront(); + _hotkey.Pressed += OnHotkeyPressed; _ = _hotkey.TryRegister(config.Hotkey); } + private void OnTrayShowRequested(object? sender, EventArgs e) => BringToFront(); + + private void OnTrayExitRequested(object? sender, EventArgs e) + { + _allowClose = true; + _mainWindow?.Close(); + } + + private void OnHotkeyPressed(object? sender, EventArgs e) => BringToFront(); + private void BringToFront() { if (_mainWindow is null) diff --git a/src/Snipdeck.App/Services/HNotifyIconTrayService.cs b/src/Snipdeck.App/Services/HNotifyIconTrayService.cs index 618353a..ebfd1f7 100644 --- a/src/Snipdeck.App/Services/HNotifyIconTrayService.cs +++ b/src/Snipdeck.App/Services/HNotifyIconTrayService.cs @@ -10,7 +10,7 @@ namespace Snipdeck.App.Services { - internal sealed class HNotifyIconTrayService : ITrayService + internal sealed partial class HNotifyIconTrayService : ITrayService { // Stable seed so the tray icon never changes between runs of Snipdeck. private static readonly Guid _iconSeed = Guid.Parse("5147DEC0-0000-0000-0000-000000000001"); @@ -39,8 +39,8 @@ public async Task InitialiseAsync() IconSource = image, ContextFlyout = BuildContextMenu(), NoLeftClickDelay = true, + LeftClickCommand = new RelayCommand(RaiseShowRequested), }; - _icon.LeftClickCommand = new RelayCommand(() => ShowRequested?.Invoke(this, EventArgs.Empty)); _icon.ForceCreate(); } @@ -57,19 +57,36 @@ public void Dispose() private MenuFlyout BuildContextMenu() { - var menu = new MenuFlyout(); - var showItem = new MenuFlyoutItem { Text = "Show Snipdeck" }; - showItem.Click += (_, _) => ShowRequested?.Invoke(this, EventArgs.Empty); - menu.Items.Add(showItem); - - menu.Items.Add(new MenuFlyoutSeparator()); + showItem.Click += OnShowItemClick; var exitItem = new MenuFlyoutItem { Text = "Exit" }; - exitItem.Click += (_, _) => ExitRequested?.Invoke(this, EventArgs.Empty); - menu.Items.Add(exitItem); + exitItem.Click += OnExitItemClick; - return menu; + return new MenuFlyout + { + Items = + { + showItem, + new MenuFlyoutSeparator(), + exitItem, + }, + }; + } + + private void OnShowItemClick(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + RaiseShowRequested(); + } + + private void OnExitItemClick(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + ExitRequested?.Invoke(this, EventArgs.Empty); + } + + private void RaiseShowRequested() + { + ShowRequested?.Invoke(this, EventArgs.Empty); } private static async Task BuildTrayBitmapAsync() @@ -86,22 +103,15 @@ private static async Task BuildTrayBitmapAsync() return image; } - private sealed class RelayCommand : System.Windows.Input.ICommand + private sealed partial class RelayCommand(Action execute) : System.Windows.Input.ICommand { - private readonly Action _execute; - - public RelayCommand(Action execute) - { - _execute = execute; - } - #pragma warning disable CS0067 // 'CanExecuteChanged' is never used — relay never changes. public event EventHandler? CanExecuteChanged; #pragma warning restore CS0067 public bool CanExecute(object? parameter) => true; - public void Execute(object? parameter) => _execute(); + public void Execute(object? parameter) => execute(); } } } diff --git a/src/Snipdeck.App/Services/WindowsHotkeyService.cs b/src/Snipdeck.App/Services/WindowsHotkeyService.cs index 8a483c8..4108572 100644 --- a/src/Snipdeck.App/Services/WindowsHotkeyService.cs +++ b/src/Snipdeck.App/Services/WindowsHotkeyService.cs @@ -175,15 +175,22 @@ private static bool Assign(uint value, out uint vk) [return: MarshalAs(UnmanagedType.Bool)] private static partial bool UnregisterHotKey(IntPtr hWnd, int id); - [DllImport("comctl32.dll", CharSet = CharSet.Auto)] + [LibraryImport("comctl32.dll")] [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool SetWindowSubclass(IntPtr hWnd, SubclassProc pfnSubclass, uint uIdSubclass, IntPtr dwRefData); + private static partial bool SetWindowSubclass( + IntPtr hWnd, + [MarshalAs(UnmanagedType.FunctionPtr)] SubclassProc pfnSubclass, + uint uIdSubclass, + IntPtr dwRefData); - [DllImport("comctl32.dll", CharSet = CharSet.Auto)] + [LibraryImport("comctl32.dll")] [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool RemoveWindowSubclass(IntPtr hWnd, SubclassProc pfnSubclass, uint uIdSubclass); + private static partial bool RemoveWindowSubclass( + IntPtr hWnd, + [MarshalAs(UnmanagedType.FunctionPtr)] SubclassProc pfnSubclass, + uint uIdSubclass); - [DllImport("comctl32.dll", CharSet = CharSet.Auto)] - private static extern IntPtr DefSubclassProc(IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam); + [LibraryImport("comctl32.dll")] + private static partial IntPtr DefSubclassProc(IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam); } }