diff --git a/osu.Framework/Platform/IWindow.cs b/osu.Framework/Platform/IWindow.cs index d98278435b..5c45c6804a 100644 --- a/osu.Framework/Platform/IWindow.cs +++ b/osu.Framework/Platform/IWindow.cs @@ -265,5 +265,11 @@ public interface IWindow : IDisposable /// The window title. /// string Title { get; set; } + + IBindable TrayIcon { get; } + + public void CreateNotificationTrayIcon(string text, Action? onClick); + + public void RemoveNotificationTrayIcon(); } } diff --git a/osu.Framework/Platform/NotificationTrayIcon.cs b/osu.Framework/Platform/NotificationTrayIcon.cs new file mode 100644 index 0000000000..c4c50b4330 --- /dev/null +++ b/osu.Framework/Platform/NotificationTrayIcon.cs @@ -0,0 +1,34 @@ +using System; +using osu.Framework.Platform.Windows; + +namespace osu.Framework.Platform +{ + /// + /// Represents an icon located in the OS notification tray. + /// + public abstract class NotificationTrayIcon : IDisposable + { + /// + /// The hint text shown when hovering over the icon with the cursor + /// + public string Text { get; init; } = string.Empty; + + /// + /// The action to perform when the icon gets clicked + /// + public Action? OnClick { get; init; } + + public static NotificationTrayIcon Create(string text, Action? onClick, IWindow window) + { + switch (RuntimeInfo.OS) + { + case RuntimeInfo.Platform.Windows: + return new WindowsNotificationTrayIcon(text, onClick, window); + } + + throw new PlatformNotSupportedException(); + } + + public abstract void Dispose(); + } +} diff --git a/osu.Framework/Platform/SDL2/SDL2Window.cs b/osu.Framework/Platform/SDL2/SDL2Window.cs index 2f787a506f..026fe1a5cb 100644 --- a/osu.Framework/Platform/SDL2/SDL2Window.cs +++ b/osu.Framework/Platform/SDL2/SDL2Window.cs @@ -469,6 +469,27 @@ private unsafe void setSDLIcon(Image image) }); } + public IBindable TrayIcon => trayIcon; + + private Bindable trayIcon = new Bindable(null); + + public virtual void CreateNotificationTrayIcon(string text, Action? onClick) + { + if (trayIcon.Value is not null) + { + throw new InvalidOperationException("a notification tray icon already exists!"); + } + + NotificationTrayIcon icon = NotificationTrayIcon.Create(text, onClick, this); + trayIcon.Value = icon; + } + + public virtual void RemoveNotificationTrayIcon() + { + trayIcon.Value?.Dispose(); + trayIcon.Value = null; + } + #region SDL Event Handling /// diff --git a/osu.Framework/Platform/SDL3/SDL3MobileWindow.cs b/osu.Framework/Platform/SDL3/SDL3MobileWindow.cs index ba57f6ab79..51472192d8 100644 --- a/osu.Framework/Platform/SDL3/SDL3MobileWindow.cs +++ b/osu.Framework/Platform/SDL3/SDL3MobileWindow.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using SDL; using static SDL.SDL3; @@ -20,5 +21,15 @@ protected override unsafe void UpdateWindowStateAndSize(WindowState state, Displ // Don't run base logic at all. Let's keep things simple. } + + public override void CreateNotificationTrayIcon(string text, Action? onClick) + { + throw new PlatformNotSupportedException(); + } + + public override void RemoveNotificationTrayIcon() + { + throw new PlatformNotSupportedException(); + } } } diff --git a/osu.Framework/Platform/SDL3/SDL3Window.cs b/osu.Framework/Platform/SDL3/SDL3Window.cs index 184ca2e916..613c6a082e 100644 --- a/osu.Framework/Platform/SDL3/SDL3Window.cs +++ b/osu.Framework/Platform/SDL3/SDL3Window.cs @@ -440,6 +440,27 @@ private void setSDLIcon(Image image) }); } + public IBindable TrayIcon => trayIcon; + + private Bindable trayIcon = new Bindable(null); + + public virtual void CreateNotificationTrayIcon(string text, Action? onClick) + { + if (trayIcon.Value is not null) + { + throw new InvalidOperationException("a notification tray icon already exists!"); + } + + NotificationTrayIcon icon = NotificationTrayIcon.Create(text, onClick, this); + trayIcon.Value = icon; + } + + public virtual void RemoveNotificationTrayIcon() + { + trayIcon.Value?.Dispose(); + trayIcon.Value = null; + } + #region SDL Event Handling /// diff --git a/osu.Framework/Platform/Windows/SDL2WindowsWindow.cs b/osu.Framework/Platform/Windows/SDL2WindowsWindow.cs index 4284cbf0e1..5aac93a914 100644 --- a/osu.Framework/Platform/Windows/SDL2WindowsWindow.cs +++ b/osu.Framework/Platform/Windows/SDL2WindowsWindow.cs @@ -25,8 +25,8 @@ internal class SDL2WindowsWindow : SDL2DesktopWindow, IWindowsWindow private const int large_icon_size = 256; private const int small_icon_size = 16; - private Icon? smallIcon; - private Icon? largeIcon; + internal Icon? smallIcon; + internal Icon? largeIcon; private const int wm_killfocus = 8; @@ -99,6 +99,12 @@ protected override void HandleEventFromFilter(SDL_Event e) switch (m.msg) { + case WindowsNotificationTrayIcon.TRAYICON: + if (WindowsNotificationTrayIcon.IsClick(m.lParam)) + { + TrayIcon.Value?.OnClick?.Invoke(); + } + break; case wm_killfocus: warpCursorFromFocusLoss(); break; diff --git a/osu.Framework/Platform/Windows/SDL3WindowsWindow.cs b/osu.Framework/Platform/Windows/SDL3WindowsWindow.cs index 698c3a4192..0848199f62 100644 --- a/osu.Framework/Platform/Windows/SDL3WindowsWindow.cs +++ b/osu.Framework/Platform/Windows/SDL3WindowsWindow.cs @@ -29,8 +29,8 @@ internal class SDL3WindowsWindow : SDL3DesktopWindow, IWindowsWindow private const int large_icon_size = 256; private const int small_icon_size = 16; - private Icon? smallIcon; - private Icon? largeIcon; + internal Icon? smallIcon; + internal Icon? largeIcon; /// /// Whether to apply the . @@ -82,6 +82,12 @@ private SDL_bool handleEventFromHook(MSG msg) { switch (msg.message) { + case WindowsNotificationTrayIcon.TRAYICON: + if (WindowsNotificationTrayIcon.IsClick(msg.lParam)) + { + TrayIcon.Value?.OnClick?.Invoke(); + } + break; case Imm.WM_IME_STARTCOMPOSITION: case Imm.WM_IME_COMPOSITION: case Imm.WM_IME_ENDCOMPOSITION: diff --git a/osu.Framework/Platform/Windows/WindowsNotificationTrayIcon.cs b/osu.Framework/Platform/Windows/WindowsNotificationTrayIcon.cs new file mode 100644 index 0000000000..50450d1895 --- /dev/null +++ b/osu.Framework/Platform/Windows/WindowsNotificationTrayIcon.cs @@ -0,0 +1,168 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using osu.Framework.Logging; + +namespace osu.Framework.Platform.Windows +{ + /// + /// A windows specific notification tray icon, + /// + [SupportedOSPlatform("windows")] + internal partial class WindowsNotificationTrayIcon : NotificationTrayIcon + { + internal IWindowsWindow window = null!; + + private NOTIFYICONDATAW inner; + + internal WindowsNotificationTrayIcon(string text, Action? onClick, IWindow win) + { + + if (win is not IWindowsWindow w) + { + throw new PlatformNotSupportedException(); + } + + window = w; + Text = text; + OnClick = onClick; + + NotifyIconFlags flags = NotifyIconFlags.NIF_MESSAGE | NotifyIconFlags.NIF_ICON | NotifyIconFlags.NIF_TIP | NotifyIconFlags.NIF_SHOWTIP; + IntPtr iconHandle = IntPtr.Zero; + IntPtr hwnd; + + if (window is SDL3WindowsWindow w3) + { + hwnd = w3.WindowHandle; + if (w3.smallIcon is not null) + { + iconHandle = w3.smallIcon.Handle; + } + } + else if (window is SDL2WindowsWindow w2) + { + hwnd = w2.WindowHandle; + if (w2.smallIcon is not null) + { + iconHandle = w2.smallIcon.Handle; + } + } + else + { + throw new PlatformNotSupportedException("Invalid windowing backend"); + } + + inner = new NOTIFYICONDATAW + { + cbSize = Marshal.SizeOf(inner), + uFlags = flags, + hIcon = iconHandle, + hWnd = hwnd, + szTip = text, + uCallbackMessage = TRAYICON + }; + + bool ret = Shell_NotifyIconW(NotifyIconAction.NIM_ADD, ref inner); + + inner.uTimeoutOrVersion = NOTIFYICON_VERSION_4; + + Shell_NotifyIconW(NotifyIconAction.NIM_SETVERSION, ref inner); + + if (!ret) + { + int err = Marshal.GetLastWin32Error(); + Logger.Log($"Error {err} while creating notification tray icon", LoggingTarget.Runtime, LogLevel.Error); + } + } + + public override void Dispose() + { + bool ret = Shell_NotifyIconW(NotifyIconAction.NIM_DELETE, ref inner); + + if (!ret) + { + int err = Marshal.GetLastWin32Error(); + Logger.Log($"Error {err} while removing notification tray icon", LoggingTarget.Runtime, LogLevel.Error); + } + } + + private const int NOTIFYICON_VERSION_4 = 4; + + internal const int TRAYICON = 0x0400 + 1024; + internal const int WM_LBUTTONUP = 0x0202; + internal const int WM_RBUTTONUP = 0x0205; + internal const int WM_MBUTTONUP = 0x0208; + internal const int NIN_SELECT = 0x400; + + internal static bool IsClick(long lParam) + { + switch ((short)lParam) + { + case WM_LBUTTONUP: + case WM_RBUTTONUP: + case WM_MBUTTONUP: + case NIN_SELECT: + return true; + default: + return false; + } + } + + [Flags] + internal enum NotifyIconAction : uint + { + NIM_ADD = 0x00000000, + NIM_DELETE = 0x00000002, + NIM_SETVERSION = 0x00000004, + } + + [DllImport("shell32.dll")] + internal static extern bool Shell_NotifyIconW(NotifyIconAction dwMessage, [In] ref NOTIFYICONDATAW pnid); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct NOTIFYICONDATAW + { + internal int cbSize; + internal IntPtr hWnd; + internal int uID; + internal NotifyIconFlags uFlags; + internal int uCallbackMessage; + internal IntPtr hIcon; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + internal string szTip; + internal int dwState; + internal int dwStateMask; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + internal string szInfo; + internal int uTimeoutOrVersion; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] + internal string szInfoTitle; + internal int dwInfoFlags; + internal Guid guidItem; + internal IntPtr hBalloonIcon; + } + + [Flags] + internal enum NotifyIconFlags : uint + { + NIF_MESSAGE = 0x00000001, + NIF_ICON = 0x00000002, + NIF_TIP = 0x00000004, + NIF_STATE = 0x00000008, + NIF_INFO = 0x00000010, + NIF_GUID = 0x00000020, + NIF_SHOWTIP = 0x00000080 + } + + internal enum ToolTipIcon + { + None = 0, + Info = 1, + Warning = 2, + Error = 3 + } + } +}