diff --git a/.vs/hass-workstation-service/DesignTimeBuild/.dtbcache.v2 b/.vs/hass-workstation-service/DesignTimeBuild/.dtbcache.v2 index bdb375a..4e4a2cb 100644 Binary files a/.vs/hass-workstation-service/DesignTimeBuild/.dtbcache.v2 and b/.vs/hass-workstation-service/DesignTimeBuild/.dtbcache.v2 differ diff --git a/.vs/hass-workstation-service/v16/.suo b/.vs/hass-workstation-service/v16/.suo index 9a5ddda..fd6daf5 100644 Binary files a/.vs/hass-workstation-service/v16/.suo and b/.vs/hass-workstation-service/v16/.suo differ diff --git a/UserInterface/.gitignore b/UserInterface/.gitignore new file mode 100644 index 0000000..2bbc24e --- /dev/null +++ b/UserInterface/.gitignore @@ -0,0 +1,25 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/vscode,dotnetcore +# Edit at https://www.toptal.com/developers/gitignore?templates=vscode,dotnetcore + +### DotnetCore ### +# .NET Core build folders +bin/ +obj/ + +# Common node modules locations +/node_modules +/wwwroot/node_modules + +### vscode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# End of https://www.toptal.com/developers/gitignore/api/vscode,dotnetcore + +# ignore logs +logs/ \ No newline at end of file diff --git a/UserInterface/App.axaml b/UserInterface/App.axaml new file mode 100644 index 0000000..172252f --- /dev/null +++ b/UserInterface/App.axaml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/UserInterface/App.axaml.cs b/UserInterface/App.axaml.cs new file mode 100644 index 0000000..eaa87a9 --- /dev/null +++ b/UserInterface/App.axaml.cs @@ -0,0 +1,29 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using UserInterface.ViewModels; +using UserInterface.Views; + +namespace UserInterface +{ + public class App : Application + { + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = new MainWindowViewModel(), + }; + } + + base.OnFrameworkInitializationCompleted(); + } + } +} diff --git a/UserInterface/Assets/hass-workstation-logo.ico b/UserInterface/Assets/hass-workstation-logo.ico new file mode 100644 index 0000000..323eb64 Binary files /dev/null and b/UserInterface/Assets/hass-workstation-logo.ico differ diff --git a/UserInterface/ITrayIcon.cs b/UserInterface/ITrayIcon.cs new file mode 100644 index 0000000..ff33265 --- /dev/null +++ b/UserInterface/ITrayIcon.cs @@ -0,0 +1,13 @@ +namespace MangaReader.Avalonia.Platform +{ + public interface ITrayIcon : System.IDisposable + { + System.Windows.Input.ICommand DoubleClickCommand { get; set; } + + System.Windows.Input.ICommand BalloonClickedCommand { get; set; } + + void SetIcon(); + + void ShowBalloon(string text, object state); + } +} diff --git a/UserInterface/Program.cs b/UserInterface/Program.cs new file mode 100644 index 0000000..bef6f96 --- /dev/null +++ b/UserInterface/Program.cs @@ -0,0 +1,24 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Logging.Serilog; +using Avalonia.ReactiveUI; +using System; + +namespace UserInterface +{ + class Program + { + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .LogToDebug() + .UseReactiveUI(); + } +} diff --git a/UserInterface/Properties/PublishProfiles/FolderProfile.pubxml b/UserInterface/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..bac3ebc --- /dev/null +++ b/UserInterface/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,17 @@ + + + + + Release + Any CPU + bin\Release\netcoreapp3.1\publish\ + FileSystem + netcoreapp3.1 + win-x64 + false + True + False + + \ No newline at end of file diff --git a/UserInterface/Properties/PublishProfiles/FolderProfile.pubxml.user b/UserInterface/Properties/PublishProfiles/FolderProfile.pubxml.user new file mode 100644 index 0000000..312c6e3 --- /dev/null +++ b/UserInterface/Properties/PublishProfiles/FolderProfile.pubxml.user @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/UserInterface/UserInterface.csproj b/UserInterface/UserInterface.csproj new file mode 100644 index 0000000..94e5121 --- /dev/null +++ b/UserInterface/UserInterface.csproj @@ -0,0 +1,28 @@ + + + WinExe + netcoreapp3.1 + + + + + + + + + + + + + + + + + + + + + %(Filename) + + + diff --git a/UserInterface/UserInterface.csproj.user b/UserInterface/UserInterface.csproj.user new file mode 100644 index 0000000..3d9bdf3 --- /dev/null +++ b/UserInterface/UserInterface.csproj.user @@ -0,0 +1,6 @@ + + + + <_LastSelectedProfileId>C:\Users\Maurits\Documents\Repo\hass-desktop-service\UserInterface\Properties\PublishProfiles\FolderProfile.pubxml + + \ No newline at end of file diff --git a/UserInterface/ViewLocator.cs b/UserInterface/ViewLocator.cs new file mode 100644 index 0000000..4a3da86 --- /dev/null +++ b/UserInterface/ViewLocator.cs @@ -0,0 +1,32 @@ +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using System; +using UserInterface.ViewModels; + +namespace UserInterface +{ + public class ViewLocator : IDataTemplate + { + public bool SupportsRecycling => false; + + public IControl Build(object data) + { + var name = data.GetType().FullName.Replace("ViewModel", "View"); + var type = Type.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type); + } + else + { + return new TextBlock { Text = "Not Found: " + name }; + } + } + + public bool Match(object data) + { + return data is ViewModelBase; + } + } +} \ No newline at end of file diff --git a/UserInterface/ViewModels/BrokerSettingsViewModel.cs b/UserInterface/ViewModels/BrokerSettingsViewModel.cs new file mode 100644 index 0000000..f57b81a --- /dev/null +++ b/UserInterface/ViewModels/BrokerSettingsViewModel.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace UserInterface.ViewModels +{ + public class BrokerSettingsViewModel : ViewModelBase + { + public string Host { get; set; } + public string Username { get; set; } + public string Password { get; set; } + } +} diff --git a/UserInterface/ViewModels/MainWindowViewModel.cs b/UserInterface/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..667fc49 --- /dev/null +++ b/UserInterface/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace UserInterface.ViewModels +{ + public class MainWindowViewModel : ViewModelBase + { + public string Greeting => "Welcome to Avalonia!"; + } +} diff --git a/UserInterface/ViewModels/ViewModelBase.cs b/UserInterface/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..987b7c8 --- /dev/null +++ b/UserInterface/ViewModels/ViewModelBase.cs @@ -0,0 +1,11 @@ +using ReactiveUI; +using System; +using System.Collections.Generic; +using System.Text; + +namespace UserInterface.ViewModels +{ + public class ViewModelBase : ReactiveObject + { + } +} diff --git a/UserInterface/Views/BrokerSettings/BrokerSettings.axaml b/UserInterface/Views/BrokerSettings/BrokerSettings.axaml new file mode 100644 index 0000000..110b651 --- /dev/null +++ b/UserInterface/Views/BrokerSettings/BrokerSettings.axaml @@ -0,0 +1,17 @@ + + + Mqtt broker + IP or hostname + + Username + + Password + + + + diff --git a/UserInterface/Views/BrokerSettings/BrokerSettings.axaml.cs b/UserInterface/Views/BrokerSettings/BrokerSettings.axaml.cs new file mode 100644 index 0000000..75ff0f3 --- /dev/null +++ b/UserInterface/Views/BrokerSettings/BrokerSettings.axaml.cs @@ -0,0 +1,56 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Microsoft.Extensions.DependencyInjection; +using hass_workstation_service.Communication.NamedPipe; +using JKang.IpcServiceFramework.Client; +using System.Threading.Tasks; +using Avalonia.Interactivity; +using System.Reactive.Linq; +using UserInterface.ViewModels; +using System.Security; + +namespace UserInterface.Views +{ + public class BrokerSettings : UserControl + { + private readonly IIpcClient client; + private string _host { get; set; } + private string _username { get; set; } + private string _password { get; set; } + public BrokerSettings() + { + DataContext = new BrokerSettingsViewModel(); + this.InitializeComponent(); + // register IPC clients + ServiceProvider serviceProvider = new ServiceCollection() + .AddNamedPipeIpcClient("client1", pipeName: "pipeinternal") + .BuildServiceProvider(); + + // resolve IPC client factory + IIpcClientFactory clientFactory = serviceProvider + .GetRequiredService>(); + + // create client + this.client = clientFactory.CreateClient("client1"); + + } + public void Ping(object sender, RoutedEventArgs args) { + var result = this.client.InvokeAsync(x => x.Ping("ping")).Result; + } + + public void Configure(object sender, RoutedEventArgs args) + { + var model = (BrokerSettingsViewModel)this.DataContext; + var result = this.client.InvokeAsync(x => x.WriteMqttBrokerSettings(model.Host, model.Username, model.Password)); + } + + + + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/UserInterface/Views/MainWindow.axaml b/UserInterface/Views/MainWindow.axaml new file mode 100644 index 0000000..f154da4 --- /dev/null +++ b/UserInterface/Views/MainWindow.axaml @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/UserInterface/Views/MainWindow.axaml.cs b/UserInterface/Views/MainWindow.axaml.cs new file mode 100644 index 0000000..55de57f --- /dev/null +++ b/UserInterface/Views/MainWindow.axaml.cs @@ -0,0 +1,22 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace UserInterface.Views +{ + public class MainWindow : Window + { + public MainWindow() + { + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/UserInterface/Win/Interop/BalloonFlags.cs b/UserInterface/Win/Interop/BalloonFlags.cs new file mode 100644 index 0000000..119e0fe --- /dev/null +++ b/UserInterface/Win/Interop/BalloonFlags.cs @@ -0,0 +1,61 @@ +namespace MangaReader.Avalonia.Platform.Win.Interop +{ + /// + /// Flags that define the icon that is shown on a balloon + /// tooltip. + /// + public enum BalloonFlags + { + /// + /// No icon is displayed. + /// + None = 0x00, + + /// + /// An information icon is displayed. + /// + Info = 0x01, + + /// + /// A warning icon is displayed. + /// + Warning = 0x02, + + /// + /// An error icon is displayed. + /// + Error = 0x03, + + /// + /// Windows XP Service Pack 2 (SP2) and later. + /// Use a custom icon as the title icon. + /// + User = 0x04, + + /// + /// Windows XP (Shell32.dll version 6.0) and later. + /// Do not play the associated sound. Applies only to balloon ToolTips. + /// + NoSound = 0x10, + + /// + /// Windows Vista (Shell32.dll version 6.0.6) and later. The large version + /// of the icon should be used as the balloon icon. This corresponds to the + /// icon with dimensions SM_CXICON x SM_CYICON. If this flag is not set, + /// the icon with dimensions XM_CXSMICON x SM_CYSMICON is used.
+ /// - This flag can be used with all stock icons.
+ /// - Applications that use older customized icons (NIIF_USER with hIcon) must + /// provide a new SM_CXICON x SM_CYICON version in the tray icon (hIcon). These + /// icons are scaled down when they are displayed in the System Tray or + /// System Control Area (SCA).
+ /// - New customized icons (NIIF_USER with hBalloonIcon) must supply an + /// SM_CXICON x SM_CYICON version in the supplied icon (hBalloonIcon). + ///
+ LargeIcon = 0x20, + + /// + /// Windows 7 and later. + /// + RespectQuietTime = 0x80 + } +} diff --git a/UserInterface/Win/Interop/IconDataMembers.cs b/UserInterface/Win/Interop/IconDataMembers.cs new file mode 100644 index 0000000..fb92b20 --- /dev/null +++ b/UserInterface/Win/Interop/IconDataMembers.cs @@ -0,0 +1,70 @@ +using System; + +namespace MangaReader.Avalonia.Platform.Win.Interop +{ + /// + /// Indicates which members of a structure + /// were set, and thus contain valid data or provide additional information + /// to the ToolTip as to how it should display. + /// + [Flags] + public enum IconDataMembers + { + /// + /// The message ID is set. + /// + Message = 0x01, + + /// + /// The notification icon is set. + /// + Icon = 0x02, + + /// + /// The tooltip is set. + /// + Tip = 0x04, + + /// + /// State information () is set. This + /// applies to both and + /// . + /// + State = 0x08, + + /// + /// The balloon ToolTip is set. Accordingly, the following + /// members are set: , + /// , , + /// and . + /// + Info = 0x10, + + // Internal identifier is set. Reserved, thus commented out. + //Guid = 0x20, + + /// + /// Windows Vista (Shell32.dll version 6.0.6) and later. If the ToolTip + /// cannot be displayed immediately, discard it.
+ /// Use this flag for ToolTips that represent real-time information which + /// would be meaningless or misleading if displayed at a later time. + /// For example, a message that states "Your telephone is ringing."
+ /// This modifies and must be combined with the flag. + ///
+ Realtime = 0x40, + + /// + /// Windows Vista (Shell32.dll version 6.0.6) and later. + /// Use the standard ToolTip. Normally, when uVersion is set + /// to NOTIFYICON_VERSION_4, the standard ToolTip is replaced + /// by the application-drawn pop-up user interface (UI). + /// If the application wants to show the standard tooltip + /// in that case, regardless of whether the on-hover UI is showing, + /// it can specify NIF_SHOWTIP to indicate the standard tooltip + /// should still be shown.
+ /// Note that the NIF_SHOWTIP flag is effective until the next call + /// to Shell_NotifyIcon. + ///
+ UseLegacyToolTips = 0x80 + } +} diff --git a/UserInterface/Win/Interop/IconState.cs b/UserInterface/Win/Interop/IconState.cs new file mode 100644 index 0000000..7e1dc20 --- /dev/null +++ b/UserInterface/Win/Interop/IconState.cs @@ -0,0 +1,22 @@ +namespace MangaReader.Avalonia.Platform.Win.Interop +{ + /// + /// The state of the icon - can be set to + /// hide the icon. + /// + public enum IconState + { + /// + /// The icon is visible. + /// + Visible = 0x00, + + /// + /// Hide the icon. + /// + Hidden = 0x01, + + // The icon is shared - currently not supported, thus commented out. + //Shared = 0x02 + } +} diff --git a/UserInterface/Win/Interop/MouseEvent.cs b/UserInterface/Win/Interop/MouseEvent.cs new file mode 100644 index 0000000..a0e5b7a --- /dev/null +++ b/UserInterface/Win/Interop/MouseEvent.cs @@ -0,0 +1,54 @@ +namespace MangaReader.Avalonia.Platform.Win.Interop +{ + /// + /// Event flags for clicked events. + /// + public enum MouseEvent + { + /// + /// The mouse was moved withing the + /// taskbar icon's area. + /// + MouseMove, + + /// + /// The right mouse button was clicked. + /// + IconRightMouseDown, + + /// + /// The left mouse button was clicked. + /// + IconLeftMouseDown, + + /// + /// The right mouse button was released. + /// + IconRightMouseUp, + + /// + /// The left mouse button was released. + /// + IconLeftMouseUp, + + /// + /// The middle mouse button was clicked. + /// + IconMiddleMouseDown, + + /// + /// The middle mouse button was released. + /// + IconMiddleMouseUp, + + /// + /// The taskbar icon was double clicked. + /// + IconDoubleClick, + + /// + /// The balloon tip was clicked. + /// + BalloonToolTipClicked + } +} diff --git a/UserInterface/Win/Interop/NotifyCommand.cs b/UserInterface/Win/Interop/NotifyCommand.cs new file mode 100644 index 0000000..b0ebe00 --- /dev/null +++ b/UserInterface/Win/Interop/NotifyCommand.cs @@ -0,0 +1,41 @@ +namespace MangaReader.Avalonia.Platform.Win.Interop +{ + /// + /// Main operations performed on the + /// function. + /// + public enum NotifyCommand + { + /// + /// The taskbar icon is being created. + /// + Add = 0x00, + + /// + /// The settings of the taskbar icon are being updated. + /// + Modify = 0x01, + + /// + /// The taskbar icon is deleted. + /// + Delete = 0x02, + + /// + /// Focus is returned to the taskbar icon. Currently not in use. + /// + SetFocus = 0x03, + + /// + /// Shell32.dll version 5.0 and later only. Instructs the taskbar + /// to behave according to the version number specified in the + /// uVersion member of the structure pointed to by lpdata. + /// This message allows you to specify whether you want the version + /// 5.0 behavior found on Microsoft Windows 2000 systems, or the + /// behavior found on earlier Shell versions. The default value for + /// uVersion is zero, indicating that the original Windows 95 notify + /// icon behavior should be used. + /// + SetVersion = 0x04 + } +} diff --git a/UserInterface/Win/Interop/NotifyIconData.cs b/UserInterface/Win/Interop/NotifyIconData.cs new file mode 100644 index 0000000..37ba684 --- /dev/null +++ b/UserInterface/Win/Interop/NotifyIconData.cs @@ -0,0 +1,154 @@ +using System; +using System.Runtime.InteropServices; + +namespace MangaReader.Avalonia.Platform.Win.Interop +{ + /// + /// A struct that is submitted in order to configure + /// the taskbar icon. Provides various members that + /// can be configured partially, according to the + /// values of the + /// that were defined. + /// + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct NotifyIconData + { + /// + /// Size of this structure, in bytes. + /// + public uint cbSize; + + /// + /// Handle to the window that receives notification messages associated with an icon in the + /// taskbar status area. The Shell uses hWnd and uID to identify which icon to operate on + /// when Shell_NotifyIcon is invoked. + /// + public IntPtr WindowHandle; + + /// + /// Application-defined identifier of the taskbar icon. The Shell uses hWnd and uID to identify + /// which icon to operate on when Shell_NotifyIcon is invoked. You can have multiple icons + /// associated with a single hWnd by assigning each a different uID. This feature, however + /// is currently not used. + /// + public uint TaskbarIconId; + + /// + /// Flags that indicate which of the other members contain valid data. This member can be + /// a combination of the NIF_XXX constants. + /// + public IconDataMembers ValidMembers; + + /// + /// Application-defined message identifier. The system uses this identifier to send + /// notifications to the window identified in hWnd. + /// + public uint CallbackMessageId; + + /// + /// A handle to the icon that should be displayed. Just + /// Icon.Handle. + /// + public IntPtr IconHandle; + + /// + /// String with the text for a standard ToolTip. It can have a maximum of 64 characters including + /// the terminating NULL. For Version 5.0 and later, szTip can have a maximum of + /// 128 characters, including the terminating NULL. + /// + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + public string ToolTipText; + + + /// + /// State of the icon. Remember to also set the . + /// + public IconState IconState; + + /// + /// A value that specifies which bits of the state member are retrieved or modified. + /// For example, setting this member to + /// causes only the item's hidden + /// state to be retrieved. + /// + public IconState StateMask; + + /// + /// String with the text for a balloon ToolTip. It can have a maximum of 255 characters. + /// To remove the ToolTip, set the NIF_INFO flag in uFlags and set szInfo to an empty string. + /// + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string BalloonText; + + /// + /// Mainly used to set the version when is invoked + /// with . However, for legacy operations, + /// the same member is also used to set timeouts for balloon ToolTips. + /// + public uint VersionOrTimeout; + + /// + /// String containing a title for a balloon ToolTip. This title appears in boldface + /// above the text. It can have a maximum of 63 characters. + /// + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] + public string BalloonTitle; + + /// + /// Adds an icon to a balloon ToolTip, which is placed to the left of the title. If the + /// member is zero-length, the icon is not shown. + /// + public BalloonFlags BalloonFlags; + + /// + /// Windows XP (Shell32.dll version 6.0) and later.
+ /// - Windows 7 and later: A registered GUID that identifies the icon. + /// This value overrides uID and is the recommended method of identifying the icon.
+ /// - Windows XP through Windows Vista: Reserved. + ///
+ public Guid TaskbarIconGuid; + + /// + /// Windows Vista (Shell32.dll version 6.0.6) and later. The handle of a customized + /// balloon icon provided by the application that should be used independently + /// of the tray icon. If this member is non-NULL and the + /// flag is set, this icon is used as the balloon icon.
+ /// If this member is NULL, the legacy behavior is carried out. + ///
+ public IntPtr CustomBalloonIconHandle; + + + /// + /// Creates a default data structure that provides + /// a hidden taskbar icon without the icon being set. + /// + /// + /// NotifyIconData + public static NotifyIconData CreateDefault(IntPtr handle) + { + var data = new NotifyIconData(); + + //use the current size + data.cbSize = (uint) Marshal.SizeOf(data); + + data.WindowHandle = handle; + data.TaskbarIconId = 0x0; + data.CallbackMessageId = WindowMessageSink.CallbackMessageId; + data.VersionOrTimeout = (uint) NotifyIconVersion.Vista; + + data.IconHandle = IntPtr.Zero; + + //hide initially + data.IconState = IconState.Hidden; + data.StateMask = IconState.Hidden; + + //set flags + data.ValidMembers = IconDataMembers.Message | IconDataMembers.Icon | IconDataMembers.Tip | IconDataMembers.UseLegacyToolTips; + + //reset strings + data.ToolTipText = data.BalloonText = data.BalloonTitle = string.Empty; + + return data; + } + } +} diff --git a/UserInterface/Win/Interop/NotifyIconVersion.cs b/UserInterface/Win/Interop/NotifyIconVersion.cs new file mode 100644 index 0000000..349ac75 --- /dev/null +++ b/UserInterface/Win/Interop/NotifyIconVersion.cs @@ -0,0 +1,15 @@ +namespace MangaReader.Avalonia.Platform.Win.Interop +{ + /// + /// The notify icon version that is used. The higher + /// the version, the more capabilities are available. + /// + public enum NotifyIconVersion + { + /// + /// Extended tooltip support, which is available for Vista and later. + /// Detailed information about what the different versions do, can be found here + /// + Vista = 0x4 + } +} diff --git a/UserInterface/Win/Interop/WinApi.cs b/UserInterface/Win/Interop/WinApi.cs new file mode 100644 index 0000000..d0580e1 --- /dev/null +++ b/UserInterface/Win/Interop/WinApi.cs @@ -0,0 +1,59 @@ +using System; +using System.Runtime.InteropServices; + +namespace MangaReader.Avalonia.Platform.Win.Interop +{ + /// + /// Win32 API imports. + /// + internal static class WinApi + { + private const string User32 = "user32.dll"; + + /// + /// Creates, updates or deletes the taskbar icon. + /// + [DllImport("shell32.Dll", CharSet = CharSet.Unicode)] + public static extern bool Shell_NotifyIcon(NotifyCommand cmd, [In] ref NotifyIconData data); + + + /// + /// Creates the helper window that receives messages from the taskar icon. + /// + [DllImport(User32, EntryPoint = "CreateWindowExW", SetLastError = true)] + public static extern IntPtr CreateWindowEx(int dwExStyle, [MarshalAs(UnmanagedType.LPWStr)] string lpClassName, + [MarshalAs(UnmanagedType.LPWStr)] string lpWindowName, int dwStyle, int x, int y, + int nWidth, int nHeight, IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, + IntPtr lpParam); + + + /// + /// Processes a default windows procedure. + /// + [DllImport(User32)] + public static extern IntPtr DefWindowProc(IntPtr hWnd, uint msg, IntPtr wparam, IntPtr lparam); + + /// + /// Registers the helper window class. + /// + [DllImport(User32, EntryPoint = "RegisterClassW", SetLastError = true)] + public static extern short RegisterClass(ref WindowClass lpWndClass); + + /// + /// Registers a listener for a window message. + /// + /// + /// uint + [DllImport(User32, EntryPoint = "RegisterWindowMessageW")] + public static extern uint RegisterWindowMessage([MarshalAs(UnmanagedType.LPWStr)] string lpString); + + /// + /// Used to destroy the hidden helper window that receives messages from the + /// taskbar icon. + /// + /// + /// bool + [DllImport(User32, SetLastError = true)] + public static extern bool DestroyWindow(IntPtr hWnd); + } +} diff --git a/UserInterface/Win/Interop/WindowClass.cs b/UserInterface/Win/Interop/WindowClass.cs new file mode 100644 index 0000000..b6840a0 --- /dev/null +++ b/UserInterface/Win/Interop/WindowClass.cs @@ -0,0 +1,35 @@ +using System; +using System.Runtime.InteropServices; + +namespace MangaReader.Avalonia.Platform.Win.Interop +{ + /// + /// Callback delegate which is used by the Windows API to + /// submit window messages. + /// + public delegate IntPtr WindowProcedureHandler(IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam); + + + /// + /// Win API WNDCLASS struct - represents a single window. + /// Used to receive window messages. + /// + [StructLayout(LayoutKind.Sequential)] + public struct WindowClass + { + #pragma warning disable 1591 + + public uint style; + public WindowProcedureHandler lpfnWndProc; + public int cbClsExtra; + public int cbWndExtra; + public IntPtr hInstance; + public IntPtr hIcon; + public IntPtr hCursor; + public IntPtr hbrBackground; + [MarshalAs(UnmanagedType.LPWStr)] public string lpszMenuName; + [MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName; + + #pragma warning restore 1591 + } +} diff --git a/UserInterface/Win/Interop/WindowMessageSink.cs b/UserInterface/Win/Interop/WindowMessageSink.cs new file mode 100644 index 0000000..f4ef6d7 --- /dev/null +++ b/UserInterface/Win/Interop/WindowMessageSink.cs @@ -0,0 +1,357 @@ +// hardcodet.net NotifyIcon for WPF +// Copyright (c) 2009 - 2013 Philipp Sumi +// Contact and Information: http://www.hardcodet.net +// +// This library is free software; you can redistribute it and/or +// modify it under the terms of the Code Project Open License (CPOL); +// either version 1.0 of the License, or (at your option) any later +// version. +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +// THIS COPYRIGHT NOTICE MAY NOT BE REMOVED FROM THIS FILE + + +using System; +using System.ComponentModel; +using System.Diagnostics; + +namespace MangaReader.Avalonia.Platform.Win.Interop +{ + /// + /// Receives messages from the taskbar icon through + /// window messages of an underlying helper window. + /// + public class WindowMessageSink : IDisposable + { + #region members + + /// + /// The ID of messages that are received from the the + /// taskbar icon. + /// + public const int CallbackMessageId = 0x400; + + /// + /// The ID of the message that is being received if the + /// taskbar is (re)started. + /// + private uint taskbarRestartMessageId; + + /// + /// Used to track whether a mouse-up event is just + /// the aftermath of a double-click and therefore needs + /// to be suppressed. + /// + private bool isDoubleClick; + + /// + /// A delegate that processes messages of the hidden + /// native window that receives window messages. Storing + /// this reference makes sure we don't loose our reference + /// to the message window. + /// + private WindowProcedureHandler messageHandler; + + /// + /// Window class ID. + /// + internal string WindowId { get; private set; } + + /// + /// Handle for the message window. + /// + internal IntPtr MessageWindowHandle { get; private set; } + + /// + /// The version of the underlying icon. Defines how + /// incoming messages are interpreted. + /// + public NotifyIconVersion Version { get; set; } + + #endregion + + #region events + + /// + /// The custom tooltip should be closed or hidden. + /// + public event Action ChangeToolTipStateRequest; + + /// + /// Fired in case the user clicked or moved within + /// the taskbar icon area. + /// + public event Action MouseEventReceived; + + /// + /// Fired if a balloon ToolTip was either displayed + /// or closed (indicated by the boolean flag). + /// + public event Action BalloonToolTipChanged; + + /// + /// Fired if the taskbar was created or restarted. Requires the taskbar + /// icon to be reset. + /// + public event Action TaskbarCreated; + + #endregion + + #region construction + + /// + /// Creates a new message sink that receives message from + /// a given taskbar icon. + /// + /// + public WindowMessageSink(NotifyIconVersion version) + { + Version = version; + CreateMessageWindow(); + } + + #endregion + + #region CreateMessageWindow + + /// + /// Creates the helper message window that is used + /// to receive messages from the taskbar icon. + /// + private void CreateMessageWindow() + { + //generate a unique ID for the window + WindowId = "WPFTaskbarIcon_" + Guid.NewGuid(); + + //register window message handler + messageHandler = OnWindowMessageReceived; + + // Create a simple window class which is reference through + //the messageHandler delegate + WindowClass wc; + + wc.style = 0; + wc.lpfnWndProc = messageHandler; + wc.cbClsExtra = 0; + wc.cbWndExtra = 0; + wc.hInstance = IntPtr.Zero; + wc.hIcon = IntPtr.Zero; + wc.hCursor = IntPtr.Zero; + wc.hbrBackground = IntPtr.Zero; + wc.lpszMenuName = string.Empty; + wc.lpszClassName = WindowId; + + // Register the window class + WinApi.RegisterClass(ref wc); + + // Get the message used to indicate the taskbar has been restarted + // This is used to re-add icons when the taskbar restarts + taskbarRestartMessageId = WinApi.RegisterWindowMessage("TaskbarCreated"); + + // Create the message window + MessageWindowHandle = WinApi.CreateWindowEx(0, WindowId, "", 0, 0, 0, 1, 1, IntPtr.Zero, IntPtr.Zero, + IntPtr.Zero, IntPtr.Zero); + + if (MessageWindowHandle == IntPtr.Zero) + { + throw new Win32Exception("Message window handle was not a valid pointer"); + } + } + + #endregion + + #region Handle Window Messages + + /// + /// Callback method that receives messages from the taskbar area. + /// + private IntPtr OnWindowMessageReceived(IntPtr hWnd, uint messageId, IntPtr wParam, IntPtr lParam) + { + if (messageId == taskbarRestartMessageId) + { + //recreate the icon if the taskbar was restarted (e.g. due to Win Explorer shutdown) + var listener = TaskbarCreated; + listener?.Invoke(); + } + + //forward message + ProcessWindowMessage(messageId, wParam, lParam); + + // Pass the message to the default window procedure + return WinApi.DefWindowProc(hWnd, messageId, wParam, lParam); + } + + + /// + /// Processes incoming system messages. + /// + /// Callback ID. + /// If the version is + /// or higher, this parameter can be used to resolve mouse coordinates. + /// Currently not in use. + /// Provides information about the event. + private void ProcessWindowMessage(uint msg, IntPtr wParam, IntPtr lParam) + { + if (msg != CallbackMessageId) return; + + var message = (WindowsMessages) lParam.ToInt32(); + switch (message) + { + case WindowsMessages.WM_CONTEXTMENU: + // TODO: Handle WM_CONTEXTMENU, see https://docs.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-shell_notifyiconw + Debug.WriteLine("Unhandled WM_CONTEXTMENU"); + break; + + case WindowsMessages.WM_MOUSEMOVE: + MouseEventReceived?.Invoke(MouseEvent.MouseMove); + break; + + case WindowsMessages.WM_LBUTTONDOWN: + MouseEventReceived?.Invoke(MouseEvent.IconLeftMouseDown); + break; + + case WindowsMessages.WM_LBUTTONUP: + if (!isDoubleClick) + { + MouseEventReceived?.Invoke(MouseEvent.IconLeftMouseUp); + } + isDoubleClick = false; + break; + + case WindowsMessages.WM_LBUTTONDBLCLK: + isDoubleClick = true; + MouseEventReceived?.Invoke(MouseEvent.IconDoubleClick); + break; + + case WindowsMessages.WM_RBUTTONDOWN: + MouseEventReceived?.Invoke(MouseEvent.IconRightMouseDown); + break; + + case WindowsMessages.WM_RBUTTONUP: + MouseEventReceived?.Invoke(MouseEvent.IconRightMouseUp); + break; + + case WindowsMessages.WM_RBUTTONDBLCLK: + //double click with right mouse button - do not trigger event + break; + + case WindowsMessages.WM_MBUTTONDOWN: + MouseEventReceived?.Invoke(MouseEvent.IconMiddleMouseDown); + break; + + case WindowsMessages.WM_MBUTTONUP: + MouseEventReceived?.Invoke(MouseEvent.IconMiddleMouseUp); + break; + + case WindowsMessages.WM_MBUTTONDBLCLK: + //double click with middle mouse button - do not trigger event + break; + + case WindowsMessages.NIN_BALLOONSHOW: + BalloonToolTipChanged?.Invoke(true); + break; + + case WindowsMessages.NIN_BALLOONHIDE: + case WindowsMessages.NIN_BALLOONTIMEOUT: + BalloonToolTipChanged?.Invoke(false); + break; + + case WindowsMessages.NIN_BALLOONUSERCLICK: + MouseEventReceived?.Invoke(MouseEvent.BalloonToolTipClicked); + break; + + case WindowsMessages.NIN_POPUPOPEN: + ChangeToolTipStateRequest?.Invoke(true); + break; + + case WindowsMessages.NIN_POPUPCLOSE: + ChangeToolTipStateRequest?.Invoke(false); + break; + + case WindowsMessages.NIN_SELECT: + // TODO: Handle NIN_SELECT see https://docs.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-shell_notifyiconw + Debug.WriteLine("Unhandled NIN_SELECT"); + break; + + case WindowsMessages.NIN_KEYSELECT: + // TODO: Handle NIN_KEYSELECT see https://docs.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-shell_notifyiconw + Debug.WriteLine("Unhandled NIN_KEYSELECT"); + break; + + default: + Debug.WriteLine("Unhandled NotifyIcon message ID: " + lParam); + break; + } + } + + #endregion + + #region Dispose + + /// + /// Set to true as soon as Dispose has been invoked. + /// + public bool IsDisposed { get; private set; } + + + /// + /// Disposes the object. + /// + /// This method is not virtual by design. Derived classes + /// should override . + /// + public void Dispose() + { + Dispose(true); + + // This object will be cleaned up by the Dispose method. + // Therefore, you should call GC.SuppressFinalize to + // take this object off the finalization queue + // and prevent finalization code for this object + // from executing a second time. + GC.SuppressFinalize(this); + } + + /// + /// This destructor will run only if the + /// method does not get called. This gives this base class the + /// opportunity to finalize. + /// + /// Important: Do not provide destructor in types derived from + /// this class. + /// + /// + ~WindowMessageSink() + { + Dispose(false); + } + + /// + /// Removes the windows hook that receives window + /// messages and closes the underlying helper window. + /// + private void Dispose(bool disposing) + { + //don't do anything if the component is already disposed + if (IsDisposed) return; + IsDisposed = true; + + //always destroy the unmanaged handle (even if called from the GC) + WinApi.DestroyWindow(MessageWindowHandle); + messageHandler = null; + } + + #endregion + } +} diff --git a/UserInterface/Win/Interop/WindowsMessages.cs b/UserInterface/Win/Interop/WindowsMessages.cs new file mode 100644 index 0000000..80bdc71 --- /dev/null +++ b/UserInterface/Win/Interop/WindowsMessages.cs @@ -0,0 +1,190 @@ +// hardcodet.net NotifyIcon for WPF +// Copyright (c) 2009 - 2013 Philipp Sumi +// Contact and Information: http://www.hardcodet.net +// +// This library is free software; you can redistribute it and/or +// modify it under the terms of the Code Project Open License (CPOL); +// either version 1.0 of the License, or (at your option) any later +// version. +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +// THIS COPYRIGHT NOTICE MAY NOT BE REMOVED FROM THIS FILE + +// ReSharper disable InconsistentNaming + +using System.Diagnostics.CodeAnalysis; + +namespace MangaReader.Avalonia.Platform.Win.Interop +{ + /// + /// This enum defines the windows messages we respond to. + /// See more on Windows messages here + /// + [SuppressMessage("ReSharper", "IdentifierTypo")] + public enum WindowsMessages : uint + { + /// + /// Notifies a window that the user clicked the right mouse button (right-clicked) in the window. + /// See WM_CONTEXTMENU message + /// + /// In case of a notify icon: + /// If a user selects a notify icon's shortcut menu with the keyboard, the Shell now sends the associated application a WM_CONTEXTMENU message. Earlier versions send WM_RBUTTONDOWN and WM_RBUTTONUP messages. + /// See Shell_NotifyIcon function + /// + WM_CONTEXTMENU = 0x007b, + + /// + /// Posted to a window when the cursor moves. + /// If the mouse is not captured, the message is posted to the window that contains the cursor. + /// Otherwise, the message is posted to the window that has captured the mouse. + /// + /// See WM_MOUSEMOVE message + /// + WM_MOUSEMOVE = 0x0200, + + /// + /// Posted when the user presses the left mouse button while the cursor is in the client area of a window. + /// If the mouse is not captured, the message is posted to the window beneath the cursor. + /// Otherwise, the message is posted to the window that has captured the mouse. + /// + /// See WM_LBUTTONDOWN message + /// + WM_LBUTTONDOWN = 0x0201, + + /// + /// Posted when the user releases the left mouse button while the cursor is in the client area of a window. + /// If the mouse is not captured, the message is posted to the window beneath the cursor. + /// Otherwise, the message is posted to the window that has captured the mouse. + /// + /// See WM_LBUTTONUP message + /// + WM_LBUTTONUP = 0x0202, + + /// + /// Posted when the user double-clicks the left mouse button while the cursor is in the client area of a window. + /// If the mouse is not captured, the message is posted to the window beneath the cursor. + /// Otherwise, the message is posted to the window that has captured the mouse. + /// + /// See WM_LBUTTONDBLCLK message + /// + WM_LBUTTONDBLCLK = 0x0203, + + /// + /// Posted when the user presses the right mouse button while the cursor is in the client area of a window. + /// If the mouse is not captured, the message is posted to the window beneath the cursor. + /// Otherwise, the message is posted to the window that has captured the mouse. + /// + /// See WM_RBUTTONDOWN message + /// + WM_RBUTTONDOWN = 0x0204, + + /// + /// Posted when the user releases the right mouse button while the cursor is in the client area of a window. + /// If the mouse is not captured, the message is posted to the window beneath the cursor. + /// Otherwise, the message is posted to the window that has captured the mouse. + /// + /// See WM_RBUTTONUP message + /// + WM_RBUTTONUP = 0x0205, + + /// + /// Posted when the user double-clicks the right mouse button while the cursor is in the client area of a window. + /// If the mouse is not captured, the message is posted to the window beneath the cursor. + /// Otherwise, the message is posted to the window that has captured the mouse. + /// + /// See WM_RBUTTONDBLCLK message + /// + WM_RBUTTONDBLCLK = 0x0206, + + /// + /// Posted when the user presses the middle mouse button while the cursor is in the client area of a window. + /// If the mouse is not captured, the message is posted to the window beneath the cursor. + /// Otherwise, the message is posted to the window that has captured the mouse. + /// + /// See WM_MBUTTONDOWN message + /// + WM_MBUTTONDOWN = 0x0207, + + /// + /// Posted when the user releases the middle mouse button while the cursor is in the client area of a window. + /// If the mouse is not captured, the message is posted to the window beneath the cursor. + /// Otherwise, the message is posted to the window that has captured the mouse. + /// + /// See WM_MBUTTONUP message + /// + WM_MBUTTONUP = 0x0208, + + /// + /// Posted when the user double-clicks the middle mouse button while the cursor is in the client area of a window. + /// If the mouse is not captured, the message is posted to the window beneath the cursor. + /// Otherwise, the message is posted to the window that has captured the mouse. + /// + /// See WM_MBUTTONDBLCLK message + /// + WM_MBUTTONDBLCLK = 0x0209, + + /// + /// Used to define private messages for use by private window classes, usually of the form WM_USER+x, where x is an integer value. + /// + WM_USER = 0x0400, + + /// + /// This message is only send when using NOTIFYICON_VERSION_4, the Shell now sends the associated application an NIN_SELECT notification. + /// Send when a notify icon is activated with mouse or ENTER key. + /// Earlier versions send WM_RBUTTONDOWN and WM_RBUTTONUP messages. + /// + NIN_SELECT = WM_USER, + + /// + /// This message is only send when using NOTIFYICON_VERSION_4, the Shell now sends the associated application an NIN_SELECT notification. + /// Send when a notify icon is activated with SPACEBAR or ENTER key. + /// Earlier versions send WM_RBUTTONDOWN and WM_RBUTTONUP messages. + /// + NIN_KEYSELECT = WM_USER + 1, + + /// + /// Sent when the balloon is shown (balloons are queued). + /// + NIN_BALLOONSHOW = WM_USER + 2, + + /// + /// Sent when the balloon disappears. For example, when the icon is deleted. + /// This message is not sent if the balloon is dismissed because of a timeout or if the user clicks the mouse. + /// + /// As of Windows 7, NIN_BALLOONHIDE is also sent when a notification with the NIIF_RESPECT_QUIET_TIME flag set attempts to display during quiet time (a user's first hour on a new computer). + /// In that case, the balloon is never displayed at all. + /// + NIN_BALLOONHIDE = WM_USER + 3, + + /// + /// Sent when the balloon is dismissed because of a timeout. + /// + NIN_BALLOONTIMEOUT = WM_USER + 4, + + /// + /// Sent when the balloon is dismissed because the user clicked the mouse. + /// + NIN_BALLOONUSERCLICK = WM_USER + 5, + + /// + /// Sent when the user hovers the cursor over an icon to indicate that the richer pop-up UI should be used in place of a standard textual tooltip. + /// + NIN_POPUPOPEN = WM_USER + 6, + + /// + /// Sent when a cursor no longer hovers over an icon to indicate that the rich pop-up UI should be closed. + /// + NIN_POPUPCLOSE = WM_USER + 7 + } +} diff --git a/UserInterface/Win/NotifyIcon.cs b/UserInterface/Win/NotifyIcon.cs new file mode 100644 index 0000000..c0c2cbd --- /dev/null +++ b/UserInterface/Win/NotifyIcon.cs @@ -0,0 +1,403 @@ +// hardcodet.net NotifyIcon for WPF +// Copyright (c) 2009 - 2013 Philipp Sumi +// Contact and Information: http://www.hardcodet.net +// +// This library is free software; you can redistribute it and/or +// modify it under the terms of the Code Project Open License (CPOL); +// either version 1.0 of the License, or (at your option) any later +// version. +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +// THIS COPYRIGHT NOTICE MAY NOT BE REMOVED FROM THIS FILE + + +using System; +using System.Drawing; +using System.Linq; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using MangaReader.Avalonia.Platform.Win.Interop; + +namespace MangaReader.Avalonia.Platform.Win +{ + /// + /// A WPF proxy to for a taskbar icon (NotifyIcon) that sits in the system's + /// taskbar notification area ("system tray"). + /// + public class TaskBarIcon : IDisposable + { + private readonly object lockObject = new object(); + + #region Members + + /// + /// Represents the current icon data. + /// + private NotifyIconData iconData; + + /// + /// Receives messages from the taskbar icon. + /// + private readonly WindowMessageSink messageSink; + + /// + /// Indicates whether the taskbar icon has been created or not. + /// + public bool IsTaskbarIconCreated { get; private set; } + + public Icon Icon { get; } + + public event EventHandler MouseEventHandler; + + #endregion + + #region Construction + + /// + /// Initializes the taskbar icon and registers a message listener + /// in order to receive events from the taskbar area. + /// + public TaskBarIcon(Icon icon) + { + Icon = icon; + + // using dummy sink in design mode + messageSink = new WindowMessageSink(NotifyIconVersion.Vista); + + // init icon data structure + iconData = NotifyIconData.CreateDefault(messageSink.MessageWindowHandle); + iconData.IconHandle = Icon?.Handle ?? IntPtr.Zero; + iconData.ToolTipText = nameof(MangaReader); + + // create the taskbar icon + CreateTaskbarIcon(); + + // register event listeners + messageSink.MouseEventReceived += OnMouseEvent; + messageSink.TaskbarCreated += OnTaskbarCreated; + + // register listener in order to get notified when the application closes + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime) + { + lifetime.Exit += OnExit; + } + } + + #endregion + + #region Process Incoming Mouse Events + + /// + /// Processes mouse events, which are bubbled + /// through the class' routed events, trigger + /// certain actions (e.g. show a popup), or + /// both. + /// + /// Event flag. + private void OnMouseEvent(MouseEvent me) + { + if (IsDisposed) + return; + + switch (me) + { + case MouseEvent.MouseMove: + // immediately return - there's nothing left to evaluate + return; + case MouseEvent.IconRightMouseDown: + case MouseEvent.IconLeftMouseDown: + case MouseEvent.IconRightMouseUp: + case MouseEvent.IconLeftMouseUp: + case MouseEvent.IconMiddleMouseDown: + case MouseEvent.IconMiddleMouseUp: + case MouseEvent.BalloonToolTipClicked: + case MouseEvent.IconDoubleClick: + MouseEventHandler?.Invoke(this, me); + break; + default: + throw new ArgumentOutOfRangeException(nameof(me), "Missing handler for mouse event flag: " + me); + } + } + + #endregion + + #region Balloon Tips + + /// + /// Displays a balloon tip with the specified title, + /// text, and icon in the taskbar for the specified time period. + /// + /// The title to display on the balloon tip. + /// The text to display on the balloon tip. + /// A symbol that indicates the severity. + public void ShowBalloonTip(string title, string message, BalloonFlags symbol) + { + lock (lockObject) + { + ShowBalloonTip(title, message, symbol, IntPtr.Zero); + } + } + + + /// + /// Invokes in order to display + /// a given balloon ToolTip. + /// + /// The title to display on the balloon tip. + /// The text to display on the balloon tip. + /// Indicates what icon to use. + /// A handle to a custom icon, if any, or + /// . + private void ShowBalloonTip(string title, string message, BalloonFlags flags, IntPtr balloonIconHandle) + { + EnsureNotDisposed(); + + iconData.BalloonText = message ?? string.Empty; + iconData.BalloonTitle = title ?? string.Empty; + + iconData.BalloonFlags = flags; + iconData.CustomBalloonIconHandle = balloonIconHandle; + WriteIconData(ref iconData, NotifyCommand.Modify, IconDataMembers.Info | IconDataMembers.Icon); + } + + + /// + /// Hides a balloon ToolTip, if any is displayed. + /// + public void HideBalloonTip() + { + EnsureNotDisposed(); + + // reset balloon by just setting the info to an empty string + iconData.BalloonText = iconData.BalloonTitle = string.Empty; + WriteIconData(ref iconData, NotifyCommand.Modify, IconDataMembers.Info); + } + + #endregion + + #region Create / Remove Taskbar Icon + + /// + /// Recreates the taskbar icon if the whole taskbar was + /// recreated (e.g. because Explorer was shut down). + /// + private void OnTaskbarCreated() + { + IsTaskbarIconCreated = false; + CreateTaskbarIcon(); + } + + + /// + /// Creates the taskbar icon. This message is invoked during initialization, + /// if the taskbar is restarted, and whenever the icon is displayed. + /// + private void CreateTaskbarIcon() + { + lock (lockObject) + { + if (IsTaskbarIconCreated) + { + return; + } + + const IconDataMembers members = IconDataMembers.Message | IconDataMembers.Icon | IconDataMembers.Tip; + + //write initial configuration + var status = WriteIconData(ref iconData, NotifyCommand.Add, members); + if (!status) + { + // couldn't create the icon - we can assume this is because explorer is not running (yet!) + // -> try a bit later again rather than throwing an exception. Typically, if the windows + // shell is being loaded later, this method is being re-invoked from OnTaskbarCreated + // (we could also retry after a delay, but that's currently YAGNI) + return; + } + + messageSink.Version = (NotifyIconVersion)iconData.VersionOrTimeout; + + IsTaskbarIconCreated = true; + } + } + + /// + /// Closes the taskbar icon if required. + /// + private void RemoveTaskbarIcon() + { + lock (lockObject) + { + // make sure we didn't schedule a creation + + if (!IsTaskbarIconCreated) + { + return; + } + + WriteIconData(ref iconData, NotifyCommand.Delete, IconDataMembers.Message); + IsTaskbarIconCreated = false; + } + } + + #endregion + + #region Dispose / Exit + + /// + /// Set to true as soon as Dispose has been invoked. + /// + public bool IsDisposed { get; private set; } + + + /// + /// Checks if the object has been disposed and + /// raises a in case + /// the flag is true. + /// + private void EnsureNotDisposed() + { + if (IsDisposed) + throw new ObjectDisposedException(GetType().FullName); + } + + + /// + /// Disposes the class if the application exits. + /// + private void OnExit(object sender, EventArgs e) + { + Dispose(); + } + + + /// + /// This destructor will run only if the + /// method does not get called. This gives this base class the + /// opportunity to finalize. + /// + /// Important: Do not provide destructor in types derived from this class. + /// + /// + ~TaskBarIcon() + { + Dispose(false); + } + + + /// + /// Disposes the object. + /// + /// This method is not virtual by design. Derived classes + /// should override . + /// + public void Dispose() + { + Dispose(true); + + // This object will be cleaned up by the Dispose method. + // Therefore, you should call GC.SuppressFinalize to + // take this object off the finalization queue + // and prevent finalization code for this object + // from executing a second time. + GC.SuppressFinalize(this); + } + + + /// + /// Closes the tray and releases all resources. + /// + /// + /// Dispose(bool disposing) executes in two distinct scenarios. + /// If disposing equals true, the method has been called directly + /// or indirectly by a user's code. Managed and unmanaged resources + /// can be disposed. + /// + /// If disposing equals false, the method + /// has been called by the runtime from inside the finalizer and you + /// should not reference other objects. Only unmanaged resources can + /// be disposed. + /// Check the property to determine whether + /// the method has already been called. + private void Dispose(bool disposing) + { + // don't do anything if the component is already disposed + if (IsDisposed || !disposing) + return; + + lock (lockObject) + { + IsDisposed = true; + + // de-register application event listener + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime) + { + lifetime.Exit -= OnExit; + } + + // dispose message sink + messageSink.Dispose(); + + // remove icon + RemoveTaskbarIcon(); + } + } + + #endregion + + #region WriteIconData + + /// + /// Updates the taskbar icons with data provided by a given + /// instance. + /// + /// Configuration settings for the NotifyIcon. + /// Operation on the icon (e.g. delete the icon). + /// Defines which members of the + /// structure are set. + /// True if the data was successfully written. + /// See Shell_NotifyIcon documentation on MSDN for details. + private bool WriteIconData(ref NotifyIconData data, NotifyCommand command, IconDataMembers flags) + { + data.ValidMembers |= flags; + lock (lockObject) + { + return WinApi.Shell_NotifyIcon(command, ref data); + } + } + + #endregion + + /// + /// Reads a given image resource into a WinForms icon. + /// + /// Image source pointing to + /// an icon file (*.ico). + /// An icon object that can be used with the + /// taskbar area. + public static Icon ToIcon(string imageSource) + { + if (imageSource == null) + return null; + + var executingAssembly = System.Reflection.Assembly.GetExecutingAssembly(); + if (executingAssembly.GetManifestResourceNames().Contains(imageSource)) + { + var stream = executingAssembly.GetManifestResourceStream(imageSource); + return new Icon(stream); + } + + return null; + } + } +} diff --git a/UserInterface/Win/WindowsTrayIcon.cs b/UserInterface/Win/WindowsTrayIcon.cs new file mode 100644 index 0000000..532ce8b --- /dev/null +++ b/UserInterface/Win/WindowsTrayIcon.cs @@ -0,0 +1,62 @@ +using System.Runtime.InteropServices; +using System.Windows.Input; +using MangaReader.Avalonia.Platform.Win.Interop; + +namespace MangaReader.Avalonia.Platform.Win +{ + public class WindowsTrayIcon : ITrayIcon + { + public ICommand DoubleClickCommand { get; set; } + + public ICommand BalloonClickedCommand { get; set; } + + private TaskBarIcon taskBarIcon; + + private object lastBalloonState; + + public void SetIcon() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var iconSource = "MangaReader.Avalonia.Assets.main.ico"; + var icon = TaskBarIcon.ToIcon(iconSource); + taskBarIcon = new TaskBarIcon(icon); + taskBarIcon.MouseEventHandler += TaskBarIconOnMouseEventHandler; + } + } + + public void ShowBalloon(string text, object state) + { + this.lastBalloonState = state; + taskBarIcon?.ShowBalloonTip(nameof(MangaReader), text, BalloonFlags.Info); + } + + private void TaskBarIconOnMouseEventHandler(object sender, MouseEvent e) + { + if (e == MouseEvent.IconDoubleClick) + { + var command = this.DoubleClickCommand; + if (command != null && command.CanExecute(null)) + { + command.Execute(null); + } + } + + if (e == MouseEvent.BalloonToolTipClicked) + { + var command = this.BalloonClickedCommand; + if (command != null && command.CanExecute(lastBalloonState)) + { + command.Execute(lastBalloonState); + } + } + } + + public void Dispose() + { + if (taskBarIcon != null) + taskBarIcon.MouseEventHandler -= TaskBarIconOnMouseEventHandler; + taskBarIcon?.Dispose(); + } + } +} diff --git a/UserInterface/nuget.config b/UserInterface/nuget.config new file mode 100644 index 0000000..6c273ab --- /dev/null +++ b/UserInterface/nuget.config @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/hass-workstation-service.sln b/hass-workstation-service.sln index 18edd60..8815dcc 100644 --- a/hass-workstation-service.sln +++ b/hass-workstation-service.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 16.0.30804.86 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "hass-workstation-service", "hass-workstation-service\hass-workstation-service.csproj", "{78EC7ACA-8826-4A0A-AA8E-664D03ACBE88}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UserInterface", "UserInterface\UserInterface.csproj", "{8ECB6FEE-1AD2-40E3-897D-E75EDB637BB5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,14 @@ Global {78EC7ACA-8826-4A0A-AA8E-664D03ACBE88}.Release|Any CPU.Build.0 = Release|Any CPU {78EC7ACA-8826-4A0A-AA8E-664D03ACBE88}.Release|x86.ActiveCfg = Release|Any CPU {78EC7ACA-8826-4A0A-AA8E-664D03ACBE88}.Release|x86.Build.0 = Release|Any CPU + {8ECB6FEE-1AD2-40E3-897D-E75EDB637BB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8ECB6FEE-1AD2-40E3-897D-E75EDB637BB5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8ECB6FEE-1AD2-40E3-897D-E75EDB637BB5}.Debug|x86.ActiveCfg = Debug|Any CPU + {8ECB6FEE-1AD2-40E3-897D-E75EDB637BB5}.Debug|x86.Build.0 = Debug|Any CPU + {8ECB6FEE-1AD2-40E3-897D-E75EDB637BB5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8ECB6FEE-1AD2-40E3-897D-E75EDB637BB5}.Release|Any CPU.Build.0 = Release|Any CPU + {8ECB6FEE-1AD2-40E3-897D-E75EDB637BB5}.Release|x86.ActiveCfg = Release|Any CPU + {8ECB6FEE-1AD2-40E3-897D-E75EDB637BB5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/hass-workstation-service/Communication/InterProcesCommunication/ServiceContractInterfaces.cs b/hass-workstation-service/Communication/InterProcesCommunication/ServiceContractInterfaces.cs new file mode 100644 index 0000000..8750f31 --- /dev/null +++ b/hass-workstation-service/Communication/InterProcesCommunication/ServiceContractInterfaces.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace hass_workstation_service.Communication.NamedPipe +{ + public interface ServiceContractInterfaces + { + public string Ping(string str); + void WriteMqttBrokerSettings(string host, string username, string password); + } +} diff --git a/hass-workstation-service/Communication/MQTT/MqttPublisher.cs b/hass-workstation-service/Communication/MQTT/MqttPublisher.cs index 95d4894..2b1dd92 100644 --- a/hass-workstation-service/Communication/MQTT/MqttPublisher.cs +++ b/hass-workstation-service/Communication/MQTT/MqttPublisher.cs @@ -17,7 +17,7 @@ namespace hass_workstation_service.Communication { private readonly IMqttClient _mqttClient; private readonly ILogger _logger; - private readonly ConfigurationService _configurationService; + private readonly IConfigurationService _configurationService; public DateTime LastConfigAnnounce { get; private set; } public DeviceConfigModel DeviceConfigModel { get; private set; } public bool IsConnected @@ -38,7 +38,7 @@ namespace hass_workstation_service.Communication public MqttPublisher( ILogger logger, DeviceConfigModel deviceConfigModel, - ConfigurationService configurationService) + IConfigurationService configurationService) { this._logger = logger; @@ -46,6 +46,7 @@ namespace hass_workstation_service.Communication this._configurationService = configurationService; var options = _configurationService.ReadMqttSettings().Result; + _configurationService.MqqtConfigChangedHandler = this.ReplaceMqttClient; var factory = new MqttFactory(); this._mqttClient = factory.CreateMqttClient(); @@ -54,17 +55,21 @@ namespace hass_workstation_service.Communication // configure what happens on disconnect this._mqttClient.UseDisconnectedHandler(async e => { - _logger.LogWarning("Disconnected from server"); - await Task.Delay(TimeSpan.FromSeconds(5)); - - try - { - await this._mqttClient.ConnectAsync(options, CancellationToken.None); - } - catch (Exception ex) + if (e.ReasonCode != MQTTnet.Client.Disconnecting.MqttClientDisconnectReason.NormalDisconnection) { - _logger.LogError(ex, "Reconnecting failed"); + _logger.LogWarning("Disconnected from server"); + await Task.Delay(TimeSpan.FromSeconds(5)); + + try + { + await this._mqttClient.ConnectAsync(options, CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogError(ex, "Reconnecting failed"); + } } + }); } @@ -76,7 +81,7 @@ namespace hass_workstation_service.Communication } else { - this._logger.LogInformation($"message dropped because mqtt not connected: {message}"); + this._logger.LogInformation($"Message dropped because mqtt not connected: {message}"); } } @@ -99,5 +104,24 @@ namespace hass_workstation_service.Communication LastConfigAnnounce = DateTime.UtcNow; } } + + public async void ReplaceMqttClient(IMqttClientOptions options) + { + this._logger.LogInformation($"Replacing Mqtt client with new config"); + await _mqttClient.DisconnectAsync(); + try + { + await _mqttClient.ConnectAsync(options); + } + catch (Exception ex) + { + Log.Logger.Error("Could not connect to broker: " + ex.Message); + } + finally + { + Log.Logger.Information("Connected to new broker"); + } + + } } } diff --git a/hass-workstation-service/Data/ConfigurationService.cs b/hass-workstation-service/Data/ConfigurationService.cs index 834f032..dcb4f0b 100644 --- a/hass-workstation-service/Data/ConfigurationService.cs +++ b/hass-workstation-service/Data/ConfigurationService.cs @@ -2,9 +2,11 @@ using System; using System.Collections.Generic; using System.IO; using System.IO.IsolatedStorage; +using System.Security; using System.Text.Json; using System.Threading.Tasks; using hass_workstation_service.Communication; +using hass_workstation_service.Communication.NamedPipe; using hass_workstation_service.Domain.Sensors; using Microsoft.Extensions.Configuration; using MQTTnet; @@ -14,9 +16,11 @@ using Serilog; namespace hass_workstation_service.Data { - public class ConfigurationService + public class ConfigurationService : ServiceContractInterfaces, IConfigurationService { public ICollection ConfiguredSensors { get; private set; } + public Action MqqtConfigChangedHandler { get; set; } + private readonly IsolatedStorageFile _fileStorage; public ConfigurationService() @@ -64,8 +68,9 @@ namespace hass_workstation_service.Data configuredBroker = await JsonSerializer.DeserializeAsync(stream); } stream.Close(); - if (configuredBroker != null) + if (configuredBroker != null && configuredBroker.Host != null) { + var mqttClientOptions = new MqttClientOptionsBuilder() .WithTcpServer(configuredBroker.Host) // .WithTls() @@ -110,5 +115,38 @@ namespace hass_workstation_service.Data sensors.ForEach((sensor) => this.ConfiguredSensors.Add(sensor)); WriteSettings(); } + + public async void WriteMqttBrokerSettings(string host, string username, string password) + { + IsolatedStorageFileStream stream = this._fileStorage.OpenFile("mqttbroker.json", FileMode.OpenOrCreate); + Log.Logger.Information($"writing configured mqttbroker to: {stream.Name}"); + ConfiguredMqttBroker configuredBroker = new ConfiguredMqttBroker() + { + Host = host, + Username = username, + Password = password + }; + + await JsonSerializer.SerializeAsync(stream, configuredBroker); + stream.Close(); + + this.MqqtConfigChangedHandler.Invoke(await this.ReadMqttSettings()); + } + + + + /// + /// You can use this to check if the application responds. + /// + /// + /// + public string Ping(string str) + { + if (str == "ping") + { + return "pong"; + } + return "what?"; + } } } \ No newline at end of file diff --git a/hass-workstation-service/Data/ConfiguredMqttBroker.cs b/hass-workstation-service/Data/ConfiguredMqttBroker.cs index 77afbc2..a86e249 100644 --- a/hass-workstation-service/Data/ConfiguredMqttBroker.cs +++ b/hass-workstation-service/Data/ConfiguredMqttBroker.cs @@ -7,6 +7,6 @@ namespace hass_workstation_service.Data { public string Host { get; set; } public string Username { get; set; } - public SecureString Password { get; set; } + public string Password { get; set; } } } \ No newline at end of file diff --git a/hass-workstation-service/Data/IConfigurationService.cs b/hass-workstation-service/Data/IConfigurationService.cs new file mode 100644 index 0000000..7dac53c --- /dev/null +++ b/hass-workstation-service/Data/IConfigurationService.cs @@ -0,0 +1,24 @@ +using hass_workstation_service.Communication; +using hass_workstation_service.Domain.Sensors; +using MQTTnet.Client.Options; +using System; +using System.Collections.Generic; +using System.Security; +using System.Threading.Tasks; + +namespace hass_workstation_service.Data +{ + public interface IConfigurationService + { + ICollection ConfiguredSensors { get; } + Action MqqtConfigChangedHandler { get; set; } + + void AddConfiguredSensor(AbstractSensor sensor); + void AddConfiguredSensors(List sensors); + string Ping(string str); + Task ReadMqttSettings(); + void ReadSensorSettings(MqttPublisher publisher); + void WriteMqttBrokerSettings(string host, string username, string password); + void WriteSettings(); + } +} \ No newline at end of file diff --git a/hass-workstation-service/Program.cs b/hass-workstation-service/Program.cs index 7b3eac7..b5223bc 100644 --- a/hass-workstation-service/Program.cs +++ b/hass-workstation-service/Program.cs @@ -16,6 +16,8 @@ using System.IO.IsolatedStorage; using System.Reflection; using System.IO; using Microsoft.Win32; +using JKang.IpcServiceFramework.Hosting; +using hass_workstation_service.Communication.NamedPipe; namespace hass_workstation_service { @@ -86,9 +88,15 @@ namespace hass_workstation_service Sw_version = GetVersion() }; services.AddSingleton(deviceConfig); - services.AddSingleton(); + ConfigurationService configurationService = new ConfigurationService(); + services.AddSingleton(configurationService); + services.AddSingleton(configurationService); services.AddSingleton(); services.AddHostedService(); + }).ConfigureIpcHost(builder => + { + // configure IPC endpoints + builder.AddNamedPipeEndpoint(pipeName: "pipeinternal"); }); static internal string GetVersion() { diff --git a/hass-workstation-service/Properties/PublishProfiles/ClickOnceProfile.pubxml b/hass-workstation-service/Properties/PublishProfiles/ClickOnceProfile.pubxml index e801e01..9a49144 100644 --- a/hass-workstation-service/Properties/PublishProfiles/ClickOnceProfile.pubxml +++ b/hass-workstation-service/Properties/PublishProfiles/ClickOnceProfile.pubxml @@ -4,7 +4,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121. --> - 33 + 34 1.0.0.* True Release diff --git a/hass-workstation-service/Worker.cs b/hass-workstation-service/Worker.cs index 8e09eeb..f054334 100644 --- a/hass-workstation-service/Worker.cs +++ b/hass-workstation-service/Worker.cs @@ -15,11 +15,11 @@ namespace hass_workstation_service public class Worker : BackgroundService { private readonly ILogger _logger; - private readonly ConfigurationService _configurationService; + private readonly IConfigurationService _configurationService; private readonly MqttPublisher _mqttPublisher; public Worker(ILogger logger, - ConfigurationService configuredSensorsService, + IConfigurationService configuredSensorsService, MqttPublisher mqttPublisher) { _logger = logger; diff --git a/hass-workstation-service/hass-workstation-service.csproj b/hass-workstation-service/hass-workstation-service.csproj index ee48ed1..f61dee6 100644 --- a/hass-workstation-service/hass-workstation-service.csproj +++ b/hass-workstation-service/hass-workstation-service.csproj @@ -4,7 +4,7 @@ netcoreapp3.1 dotnet-hass_workstation_service-C65C2EBE-1977-4C24-AC6B-6921877E1390 hass_workstation_service - WinExe + Exe Sleevezipper https://github.com/sleevezipper/hass-workstation-service false @@ -30,7 +30,8 @@ - + +