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..61b56ba 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); + await InitialiseTrayAsync(); + InitialiseHotkey(_config); } private static async Task SeedFirstRunIfEmptyAsync() @@ -49,10 +59,55 @@ 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) + { + window.AppWindow.Closing += (_, args) => { - _mainWindow?.Activate(); - }); + if (_allowClose || config.CloseBehaviour == CloseBehaviour.Exit) + { + return; + } + args.Cancel = true; + window.AppWindow.Hide(); + }; + } + + private async Task InitialiseTrayAsync() + { + _tray = Services.GetRequiredService(); + _tray.ShowRequested += OnTrayShowRequested; + _tray.ExitRequested += OnTrayExitRequested; + await _tray.InitialiseAsync(); + } + + private void InitialiseHotkey(AppConfig config) + { + _hotkey = Services.GetRequiredService(); + _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) + { + 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 = Snipdeck.App.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..ebfd1f7 --- /dev/null +++ b/src/Snipdeck.App/Services/HNotifyIconTrayService.cs @@ -0,0 +1,117 @@ +using H.NotifyIcon; + +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; + +using Snipdeck.Core.Abstractions; +using Snipdeck.Core.Services; + +using Windows.Storage.Streams; + +namespace Snipdeck.App.Services +{ + 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"); + + private TaskbarIcon? _icon; + private bool _disposed; + + public event EventHandler? ShowRequested; + + public event EventHandler? ExitRequested; + + public async Task InitialiseAsync() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_icon is not null) + { + return; + } + + var image = await BuildTrayBitmapAsync(); + + _icon = new TaskbarIcon + { + ToolTipText = "Snipdeck", + IconSource = image, + ContextFlyout = BuildContextMenu(), + NoLeftClickDelay = true, + LeftClickCommand = new RelayCommand(RaiseShowRequested), + }; + _icon.ForceCreate(); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + _icon?.Dispose(); + _icon = null; + } + + private MenuFlyout BuildContextMenu() + { + var showItem = new MenuFlyoutItem { Text = "Show Snipdeck" }; + showItem.Click += OnShowItemClick; + + var exitItem = new MenuFlyoutItem { Text = "Exit" }; + exitItem.Click += OnExitItemClick; + + 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() + { + 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 partial class RelayCommand(Action execute) : System.Windows.Input.ICommand + { +#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..4108572 --- /dev/null +++ b/src/Snipdeck.App/Services/WindowsHotkeyService.cs @@ -0,0 +1,196 @@ +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); + + [LibraryImport("comctl32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetWindowSubclass( + IntPtr hWnd, + [MarshalAs(UnmanagedType.FunctionPtr)] SubclassProc pfnSubclass, + uint uIdSubclass, + IntPtr dwRefData); + + [LibraryImport("comctl32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool RemoveWindowSubclass( + IntPtr hWnd, + [MarshalAs(UnmanagedType.FunctionPtr)] SubclassProc pfnSubclass, + uint uIdSubclass); + + [LibraryImport("comctl32.dll")] + private static partial 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..a664629 --- /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; + + Task InitialiseAsync(); + } +}