diff --git a/.gitignore b/.gitignore index 0c306e1..dba7998 100644 --- a/.gitignore +++ b/.gitignore @@ -396,3 +396,4 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml TopNotify/.DS_Store +/TopNotify/dist/ diff --git a/TopNotify/Common/AppReference.cs b/TopNotify/Common/AppReference.cs index 174648e..bf67c4b 100644 --- a/TopNotify/Common/AppReference.cs +++ b/TopNotify/Common/AppReference.cs @@ -50,6 +50,29 @@ public class AppReference /// public string SoundDisplayName; + public NotificationStickyMode StickyMode = NotificationStickyMode.Default; + public int StickyDurationSeconds = 5; + + public NotificationStickyMode GetStickyMode(Settings settings) + { + if (StickyMode == NotificationStickyMode.Default) + { + return settings.StickyMode; + } + + return StickyMode; + } + + public int GetStickyDurationSeconds(Settings settings) + { + if (StickyMode == NotificationStickyMode.Default) + { + return settings.StickyDurationSeconds; + } + + return StickyDurationSeconds; + } + /// /// Identifies An AppReference Based On A Notification /// diff --git a/TopNotify/Common/NotificationLifetimeController.cs b/TopNotify/Common/NotificationLifetimeController.cs new file mode 100644 index 0000000..eea1e88 --- /dev/null +++ b/TopNotify/Common/NotificationLifetimeController.cs @@ -0,0 +1,82 @@ +using Microsoft.Win32; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; + +namespace TopNotify.Common +{ + public class NotificationLifetimeController + { + [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] + private static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool SystemParametersInfo(uint uiAction, uint uiParam, uint pvParam, uint fWinIni); + + const uint HWND_BROADCAST = 0xffff; + const uint WM_SETTINGCHANGE = 0x001A; + const uint SMTO_ABORTIFHUNG = 0x0002; + const uint SPI_SETMESSAGEDURATION = 0x2017; + const uint SPIF_UPDATEINIFILE = 0x01; + const uint SPIF_SENDCHANGE = 0x02; + const int PermanentDurationSeconds = 86400; + + public static void Apply(Settings settings) + { + var duration = GetWindowsDuration(settings); + if (duration == null) { return; } + + try + { + SystemParametersInfo(SPI_SETMESSAGEDURATION, 0, (uint)duration.Value, SPIF_UPDATEINIFILE | SPIF_SENDCHANGE); + + using var key = Registry.CurrentUser.CreateSubKey(@"Control Panel\Accessibility"); + key.SetValue("MessageDuration", duration.Value, RegistryValueKind.DWord); + SendMessageTimeout(new IntPtr(HWND_BROADCAST), WM_SETTINGCHANGE, UIntPtr.Zero, @"Control Panel\Accessibility", SMTO_ABORTIFHUNG, 1000, out _); + Program.Logger?.Information($"Applied Windows notification duration: {duration.Value} seconds"); + } + catch (Exception ex) + { + Program.Logger?.Warning($"Failed to update Windows notification duration: {ex.Message}"); + } + } + + public static int? GetWindowsDuration(Settings settings) + { + var durations = new List(); + + AddDuration(durations, settings.StickyMode, settings.StickyDurationSeconds); + + foreach (var appReference in settings.AppReferences) + { + var mode = appReference.GetStickyMode(settings); + var seconds = appReference.GetStickyDurationSeconds(settings); + AddDuration(durations, mode, seconds); + } + + if (!durations.Any()) { return null; } + + return durations.Max(); + } + + public static int GetPermanentDurationSeconds() + { + return PermanentDurationSeconds; + } + + private static void AddDuration(List durations, NotificationStickyMode mode, int seconds) + { + if (mode == NotificationStickyMode.Permanent) + { + durations.Add(PermanentDurationSeconds); + } + else if (mode == NotificationStickyMode.Seconds) + { + durations.Add(Math.Clamp(seconds, 1, PermanentDurationSeconds)); + } + } + } +} diff --git a/TopNotify/Common/Settings.cs b/TopNotify/Common/Settings.cs index e5328f9..f6a08e9 100644 --- a/TopNotify/Common/Settings.cs +++ b/TopNotify/Common/Settings.cs @@ -37,6 +37,9 @@ public class Settings public string PreferredMonitor = "primary"; + public NotificationStickyMode StickyMode = NotificationStickyMode.WindowsDefault; + public int StickyDurationSeconds = 5; + public List AppReferences = new List(); // Dynamic Fields That Are Cached, Useful For Interop @@ -163,4 +166,12 @@ public enum NotifyLocation BottomRight, Custom } + + public enum NotificationStickyMode + { + Default, + WindowsDefault, + Seconds, + Permanent + } } diff --git a/TopNotify/Daemon/InterceptorManager.cs b/TopNotify/Daemon/InterceptorManager.cs index 8906e36..d3b0db9 100644 --- a/TopNotify/Daemon/InterceptorManager.cs +++ b/TopNotify/Daemon/InterceptorManager.cs @@ -29,8 +29,11 @@ public class InterceptorManager public const int ReflowTimeout = 50; public ConcurrentDictionary CleanUpFunctions = new ConcurrentDictionary(); // Maps HandledNotifications to the associated clean up function + public ConcurrentDictionary KnownNotificationIds = new ConcurrentDictionary(); public UserNotificationListener Listener; public bool CanListenToNotifications = false; + public bool IsPollingNotifications = false; + public DateTime LastNotificationPoll = DateTime.MinValue; public static Interceptor[] InstalledInterceptors = { @@ -44,6 +47,7 @@ public void Start() { Instance = this; CurrentSettings = Settings.Get(); + NotificationLifetimeController.Apply(CurrentSettings); foreach (var possibleInterceptor in InstalledInterceptors) { @@ -62,8 +66,7 @@ public void Start() var access = await Listener.RequestAccessAsync(); if (access != UserNotificationListenerAccessStatus.Allowed) { - var msg = "Failed To Start Notification Listener: Permission Denied"; - DaemonErrorHandler.ThrowNonCritical(new DaemonError("listener_failure_no_permission", msg)); + Program.Logger.Warning("Failed To Start Notification Listener: Permission Denied"); return; } @@ -76,12 +79,12 @@ public void Start() } catch (Exception ex) { - var msg = "Failed To Start Notification Listener: Not Packaged"; - DaemonErrorHandler.ThrowNonCritical(new DaemonError("listener_failure_not_packaged", msg)); + Program.Logger.Warning("Failed To Start Notification Listener: Not Packaged"); return; } CanListenToNotifications = true; + PollNotifications(); }); @@ -105,6 +108,7 @@ public void MainLoop() Reflow(); } + PollNotifications(); Update(); Thread.Sleep(10); @@ -162,22 +166,70 @@ public async void OnNotificationChanged(UserNotificationListener sender, UserNot if (args.ChangeKind == UserNotificationChangedKind.Added) { - foreach (Interceptor i in Interceptors) + if (KnownNotificationIds.TryAdd(args.UserNotificationId, true)) { - try - { - i.OnNotification(userNotification); - } - catch { } + DispatchNotification(userNotification); } } + else + { + KnownNotificationIds.TryRemove(args.UserNotificationId, out _); + } Update(); } + public void PollNotifications() + { + if (!CanListenToNotifications) { return; } + if (IsPollingNotifications) { return; } + if ((DateTime.Now - LastNotificationPoll).TotalMilliseconds < 500) { return; } + + IsPollingNotifications = true; + LastNotificationPoll = DateTime.Now; + + Task.Run(async () => + { + try + { + var userNotifications = await Listener.GetNotificationsAsync(NotificationKinds.Toast); + foreach (var notification in userNotifications) + { + if (KnownNotificationIds.TryAdd(notification.Id, true)) + { + DispatchNotification(notification); + } + } + } + catch (Exception ex) + { + Program.Logger?.Warning($"Failed to poll notifications: {ex.Message}"); + } + finally + { + IsPollingNotifications = false; + } + }); + } + + public void DispatchNotification(UserNotification userNotification) + { + if (userNotification == null) { return; } + + foreach (Interceptor i in Interceptors) + { + try + { + i.OnNotification(userNotification); + } + catch { } + } + } + public void OnSettingsChanged() { CurrentSettings = Settings.Get(); + NotificationLifetimeController.Apply(CurrentSettings); foreach (Interceptor i in Interceptors) { diff --git a/TopNotify/Daemon/Interceptors/NativeInterceptor.cs b/TopNotify/Daemon/Interceptors/NativeInterceptor.cs index 8f42937..985c802 100644 --- a/TopNotify/Daemon/Interceptors/NativeInterceptor.cs +++ b/TopNotify/Daemon/Interceptors/NativeInterceptor.cs @@ -6,6 +6,7 @@ using System.Runtime.InteropServices; using System.Text; using System.Windows; +using System.Windows.Automation; using System.Threading.Tasks; using TopNotify.Common; using SamsidParty_TopNotify.Daemon; @@ -39,6 +40,9 @@ public class NativeInterceptor : Interceptor [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern IntPtr SendMessage(IntPtr hWnd, UInt32 Msg, IntPtr wParam, IntPtr lParam); + [DllImport("user32.dll")] + public static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport("user32.dll", SetLastError = true)] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); @@ -49,6 +53,8 @@ public class NativeInterceptor : Interceptor public static extern int GetWindowTextLength(IntPtr hWnd); const UInt32 WM_CLOSE = 0x0010; + const int SW_HIDE = 0; + const int SW_SHOW = 5; const short SWP_NOMOVE = 0X2; const short SWP_NOSIZE = 1; const short SWP_NOZORDER = 0X4; @@ -71,6 +77,14 @@ public class NativeInterceptor : Interceptor public int RealPreferredDisplayWidth; public int RealPreferredDisplayHeight; public float ScaleFactor; + public DateTime CurrentNotificationStarted = DateTime.MinValue; + public NotificationStickyMode CurrentStickyMode = NotificationStickyMode.WindowsDefault; + public int CurrentStickyDurationSeconds = 5; + public bool CurrentNotificationClosed = false; + public bool WasNotificationVisible = false; + public uint? CurrentNotificationId = null; + public IntPtr LastClosedHwnd = IntPtr.Zero; + public Dictionary ClosedNotificationSignatures = new(); public override void Start() { @@ -148,6 +162,7 @@ public override void Reflow() { Program.Logger.Information($"Found notification window {foundHwnd}"); hwnd = foundHwnd; + WasNotificationVisible = false; } else if (foundHwnd == IntPtr.Zero) { @@ -172,13 +187,84 @@ public override void OnKeyUpdate() base.OnKeyUpdate(); } + public override void OnNotification(UserNotification notification) + { + if (notification == null) { return; } + + var appReference = AppReference.FromNotification(notification); + StartNotificationLifetime( + appReference?.GetStickyMode(Settings) ?? Settings.StickyMode, + appReference?.GetStickyDurationSeconds(Settings) ?? Settings.StickyDurationSeconds, + notification.AppInfo.DisplayInfo.DisplayName, + notification.Id + ); + Program.Logger.Information($"Notification sticky lifetime for {notification.AppInfo.DisplayInfo.DisplayName}: {CurrentStickyMode}, {CurrentStickyDurationSeconds} seconds"); + + Reflow(); + + base.OnNotification(notification); + } + public override void Update() { base.Update(); + if (hwnd == IntPtr.Zero) { return; } + + var isNotificationVisible = IsNotificationActuallyVisible(hwnd); + var isSuppressedClosedHandle = isNotificationVisible && IsClosedNotificationWindow(hwnd); + if (isSuppressedClosedHandle) + { + isNotificationVisible = false; + } + + if (isNotificationVisible && !WasNotificationVisible && (CurrentNotificationStarted == DateTime.MinValue || CurrentNotificationClosed)) + { + StartFallbackNotificationLifetime(); + } + else if (!isNotificationVisible && !isSuppressedClosedHandle) + { + CurrentNotificationStarted = DateTime.MinValue; + CurrentNotificationClosed = false; + LastClosedHwnd = IntPtr.Zero; + } + WasNotificationVisible = isNotificationVisible; + // Update extended styles ExStyleManager.Update(hwnd); + if (ShouldCloseCurrentNotification()) + { + if (CurrentNotificationId != null) + { + try + { + InterceptorManager.Instance.Listener.RemoveNotification(CurrentNotificationId.Value); + InterceptorManager.Instance.KnownNotificationIds.TryRemove(CurrentNotificationId.Value, out _); + } + catch (Exception ex) + { + Program.Logger.Warning($"Failed to remove notification {CurrentNotificationId.Value}: {ex.Message}"); + } + } + + ClosedNotificationSignatures[hwnd] = GetNotificationWindowSignature(hwnd); + DismissNotificationWindow(hwnd); + ShowWindow(hwnd, SW_HIDE); + SendMessage(hwnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); + CurrentNotificationClosed = true; + WasNotificationVisible = false; + LastClosedHwnd = hwnd; + Program.Logger.Information($"Closed notification after {CurrentStickyDurationSeconds} seconds"); + hwnd = IntPtr.Zero; + return; + } + + if (ShouldKeepCurrentNotificationVisible()) + { + ShowWindow(hwnd, SW_SHOW); + } + // Find The Bounds Of The Notification Window Rectangle NotifyRect = new Rectangle(); GetWindowRect(hwnd, ref NotifyRect); @@ -228,5 +314,166 @@ public override void Update() } } + + public bool ShouldCloseCurrentNotification() + { + if (CurrentNotificationStarted == DateTime.MinValue) { return false; } + if (CurrentNotificationClosed) { return false; } + if (CurrentStickyMode != NotificationStickyMode.Seconds) { return false; } + + var duration = Math.Clamp(CurrentStickyDurationSeconds, 1, NotificationLifetimeController.GetPermanentDurationSeconds()); + return (DateTime.Now - CurrentNotificationStarted).TotalSeconds >= duration; + } + + public bool ShouldKeepCurrentNotificationVisible() + { + if (CurrentNotificationStarted == DateTime.MinValue) { return false; } + if (CurrentNotificationClosed) { return false; } + return CurrentStickyMode == NotificationStickyMode.Seconds || CurrentStickyMode == NotificationStickyMode.Permanent; + } + + public bool IsNotificationActuallyVisible(IntPtr targetHwnd) + { + if (!IsWindowVisible(targetHwnd)) { return false; } + + Rectangle rect = new Rectangle(); + GetWindowRect(targetHwnd, ref rect); + return rect.Width > rect.X && rect.Height > rect.Y; + } + + public void DismissNotificationWindow(IntPtr targetHwnd) + { + try + { + var root = AutomationElement.FromHandle(targetHwnd); + if (root == null) { return; } + + var buttons = root.FindAll(TreeScope.Descendants, new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button)); + if (buttons == null || buttons.Count == 0) { return; } + + var button = buttons[buttons.Count - 1]; + if (button.TryGetCurrentPattern(InvokePattern.Pattern, out var pattern)) + { + ((InvokePattern)pattern).Invoke(); + } + } + catch (Exception ex) + { + Program.Logger.Warning($"Failed to dismiss notification window: {ex.Message}"); + } + } + + public bool IsClosedNotificationWindow(IntPtr targetHwnd) + { + if (!ClosedNotificationSignatures.TryGetValue(targetHwnd, out var signature)) { return false; } + + var currentSignature = GetNotificationWindowSignature(targetHwnd); + if (string.IsNullOrEmpty(currentSignature)) { return false; } + if (currentSignature == signature) { return true; } + + ClosedNotificationSignatures.Remove(targetHwnd); + return false; + } + + public string GetNotificationWindowSignature(IntPtr targetHwnd) + { + try + { + var root = AutomationElement.FromHandle(targetHwnd); + if (root == null) { return string.Empty; } + + var names = new List(); + CollectAutomationNames(root, names, 0); + return string.Join("|", names.Where((n) => !string.IsNullOrWhiteSpace(n))); + } + catch + { + var length = GetWindowTextLength(targetHwnd); + if (length <= 0) { return string.Empty; } + + var text = new StringBuilder(length + 1); + GetWindowText(targetHwnd, text, text.Capacity); + return text.ToString(); + } + } + + public void CollectAutomationNames(AutomationElement element, List names, int depth) + { + if (depth > 4 || element == null) { return; } + + try + { + var name = element.Current.Name; + if (!string.IsNullOrWhiteSpace(name)) + { + names.Add(name); + } + + var children = element.FindAll(TreeScope.Children, System.Windows.Automation.Condition.TrueCondition); + foreach (AutomationElement child in children) + { + CollectAutomationNames(child, names, depth + 1); + } + } + catch { } + } + + public void StartFallbackNotificationLifetime() + { + var appReference = GetNotificationAppReference(hwnd); + var mode = appReference?.GetStickyMode(Settings) ?? Settings.StickyMode; + var seconds = appReference?.GetStickyDurationSeconds(Settings) ?? Settings.StickyDurationSeconds; + var appName = appReference?.DisplayName ?? "detected notification"; + StartNotificationLifetime(mode, seconds, appName); + Program.Logger.Information($"Fallback notification sticky lifetime for {appName}: {CurrentStickyMode}, {CurrentStickyDurationSeconds} seconds"); + } + + public void StartNotificationLifetime(NotificationStickyMode mode, int seconds, string appName, uint? notificationId = null) + { + CurrentStickyMode = mode; + CurrentStickyDurationSeconds = seconds; + CurrentNotificationStarted = DateTime.Now; + CurrentNotificationClosed = false; + CurrentNotificationId = notificationId; + LastClosedHwnd = IntPtr.Zero; + } + + public AppReference GetNotificationAppReference(IntPtr targetHwnd) + { + var appName = GetNotificationAppName(targetHwnd); + if (!string.IsNullOrWhiteSpace(appName)) + { + var appReference = Settings.AppReferences.Where((r) => + r.ReferenceType == AppReferenceType.AppName && + (string.Equals(r.ID, appName, StringComparison.OrdinalIgnoreCase) || + string.Equals(r.DisplayName, appName, StringComparison.OrdinalIgnoreCase))).FirstOrDefault(); + + if (appReference != null) { return appReference; } + } + + return Settings.AppReferences.Where((r) => r.ID == "Other").FirstOrDefault(); + } + + public string GetNotificationAppName(IntPtr targetHwnd) + { + try + { + var root = AutomationElement.FromHandle(targetHwnd); + if (root == null) { return string.Empty; } + + var textElements = root.FindAll(TreeScope.Descendants, new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Text)); + foreach (AutomationElement textElement in textElements) + { + var name = textElement.Current.Name; + if (!string.IsNullOrWhiteSpace(name)) + { + return name; + } + } + } + catch { } + + return string.Empty; + } } } diff --git a/TopNotify/GUI/MainCommands.cs b/TopNotify/GUI/MainCommands.cs index 733a1b0..6a2f3be 100644 --- a/TopNotify/GUI/MainCommands.cs +++ b/TopNotify/GUI/MainCommands.cs @@ -6,8 +6,10 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; +using System.Drawing.Imaging; using System.Linq; using System.Reflection.Metadata; +using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; using TopNotify.Common; @@ -89,6 +91,220 @@ public static void RequestConfig(WebWindow target) target.SendConfig(); } + [Command("FindInstalledApps")] + public static string FindInstalledApps() + { + try + { + var output = Util.SimpleCMD("powershell -NoProfile -Command \"Get-StartApps | Sort-Object Name | Select-Object Name, AppID | ConvertTo-Json -Compress\"").Trim(); + if (String.IsNullOrWhiteSpace(output)) { return "[]"; } + if (output.StartsWith("{")) { output = "[" + output + "]"; } + return output; + } + catch + { + return "[]"; + } + } + + [Command("FindInstalledAppIcon")] + public static string FindInstalledAppIcon(string name, string appID) + { + var shortcuts = FindStartMenuShortcuts(); + var app = new InstalledAppInfo() + { + Name = name, + AppID = appID + }; + + return FindInstalledAppIcon(app, shortcuts) ?? "/Image/DefaultAppReferenceIcon.svg"; + } + + public static Dictionary FindStartMenuShortcuts() + { + var result = new Dictionary(); + var folders = new List() + { + Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), + Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu) + }; + + foreach (var folder in folders) + { + if (!Directory.Exists(folder)) { continue; } + + var enumerationOptions = new EnumerationOptions() + { + RecurseSubdirectories = true, + IgnoreInaccessible = true + }; + + foreach (var shortcut in Directory.EnumerateFiles(folder, "*.lnk", enumerationOptions)) + { + var key = Path.GetFileNameWithoutExtension(shortcut).ToLowerInvariant(); + if (!result.ContainsKey(key)) + { + result.Add(key, shortcut); + } + } + } + + return result; + } + + public static string FindInstalledAppIcon(InstalledAppInfo app, Dictionary shortcuts) + { + // Get-StartApps returns an AppUserModelID for many packaged and classic + // applications. Asking the AppsFolder shell namespace for its thumbnail + // handles package manifest logos, indirect resources and shell-generated + // icons that Icon.ExtractAssociatedIcon cannot resolve. + var shellIcon = GetShellAppIconDataURL(app.AppID); + if (shellIcon != null) + { + return shellIcon; + } + + var appID = Environment.ExpandEnvironmentVariables(app.AppID ?? ""); + if (File.Exists(appID)) + { + return GetIconDataURL(appID); + } + + var key = (app.Name ?? "").ToLowerInvariant(); + if (shortcuts.ContainsKey(key)) + { + return GetShortcutIconDataURL(shortcuts[key]); + } + + return null; + } + + public static string GetShortcutIconDataURL(string path) + { + try + { + var shellType = Type.GetTypeFromProgID("WScript.Shell"); + if (shellType == null) { return null; } + + dynamic shell = Activator.CreateInstance(shellType); + dynamic shortcut = shell.CreateShortcut(path); + string iconLocation = shortcut.IconLocation; + string targetPath = shortcut.TargetPath; + + if (!String.IsNullOrWhiteSpace(iconLocation)) + { + var iconPath = iconLocation.Split(',')[0].Trim().Trim('"'); + iconPath = Environment.ExpandEnvironmentVariables(iconPath); + if (File.Exists(iconPath)) + { + var result = GetIconDataURL(iconPath); + if (result != null) { return result; } + } + } + + targetPath = Environment.ExpandEnvironmentVariables(targetPath ?? "").Trim().Trim('"'); + if (File.Exists(targetPath)) + { + return GetIconDataURL(targetPath); + } + } + catch + { + } + + return null; + } + + public static string GetIconDataURL(string path) + { + try + { + using var icon = Icon.ExtractAssociatedIcon(path); + if (icon == null) { return null; } + + using var bitmap = icon.ToBitmap(); + using var resized = new Bitmap(bitmap, new Size(32, 32)); + using var memory = new MemoryStream(); + resized.Save(memory, ImageFormat.Png); + return "data:image/png;base64," + Convert.ToBase64String(memory.ToArray()); + } + catch + { + return null; + } + } + + public static string GetShellAppIconDataURL(string appID) + { + if (String.IsNullOrWhiteSpace(appID)) { return null; } + + IShellItemImageFactory imageFactory = null; + IntPtr bitmapHandle = IntPtr.Zero; + try + { + var iid = typeof(IShellItemImageFactory).GUID; + var parsingName = "shell:AppsFolder\\" + appID; + var result = SHCreateItemFromParsingName(parsingName, IntPtr.Zero, ref iid, out imageFactory); + if (result != 0 || imageFactory == null) { return null; } + + imageFactory.GetImage(new NativeSize(32, 32), ShellItemImageFlags.IconOnly | ShellItemImageFlags.BiggerSizeOk, out bitmapHandle); + if (bitmapHandle == IntPtr.Zero) { return null; } + + using var bitmap = Bitmap.FromHbitmap(bitmapHandle); + using var memory = new MemoryStream(); + bitmap.Save(memory, ImageFormat.Png); + return "data:image/png;base64," + Convert.ToBase64String(memory.ToArray()); + } + catch + { + return null; + } + finally + { + if (bitmapHandle != IntPtr.Zero) { DeleteObject(bitmapHandle); } + if (imageFactory != null && Marshal.IsComObject(imageFactory)) { Marshal.ReleaseComObject(imageFactory); } + } + } + + [StructLayout(LayoutKind.Sequential)] + public struct NativeSize + { + public int Width; + public int Height; + + public NativeSize(int width, int height) + { + Width = width; + Height = height; + } + } + + [Flags] + public enum ShellItemImageFlags + { + BiggerSizeOk = 0x1, + IconOnly = 0x4 + } + + [ComImport] + [Guid("bcc18b79-ba16-442f-80c4-8a59c30c463b")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IShellItemImageFactory + { + void GetImage(NativeSize size, ShellItemImageFlags flags, out IntPtr bitmapHandle); + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = true)] + private static extern int SHCreateItemFromParsingName( + string path, + IntPtr bindContext, + ref Guid requestedInterface, + [MarshalAs(UnmanagedType.Interface)] out IShellItemImageFactory shellItem); + + [DllImport("gdi32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DeleteObject(IntPtr objectHandle); + //Called By JavaScript //Write Settings File [Command("WriteConfigFile")] @@ -120,4 +336,11 @@ public static async Task OpenSoundFolder(WebWindow target) Process.Start("explorer.exe", SoundFinder.ImportedSoundFolder); } } + + public class InstalledAppInfo + { + public string Name; + public string AppID; + public string DisplayIcon; + } } diff --git a/TopNotify/TopNotify.csproj b/TopNotify/TopNotify.csproj index 469094b..6c8e2aa 100644 --- a/TopNotify/TopNotify.csproj +++ b/TopNotify/TopNotify.csproj @@ -70,11 +70,16 @@ + + + + + diff --git a/TopNotify/iv2runtime/TopNotify.igniteview b/TopNotify/iv2runtime/TopNotify.igniteview index 2c7448f..3916c34 100644 Binary files a/TopNotify/iv2runtime/TopNotify.igniteview and b/TopNotify/iv2runtime/TopNotify.igniteview differ diff --git a/TopNotify/src-vite/eslint.config.js b/TopNotify/src-vite/eslint.config.js index 5d33242..be58594 100644 --- a/TopNotify/src-vite/eslint.config.js +++ b/TopNotify/src-vite/eslint.config.js @@ -18,7 +18,13 @@ export default defineConfig([ ecmaVersion: 2020, globals: { ...globals.browser, + ChangeSwitch: "readonly", + ChangeValue: "readonly", + Config: "writable", igniteView: "readonly", + rerender: "readonly", + setRerender: "readonly", + UploadConfig: "readonly", }, parserOptions: { ecmaVersion: "latest", @@ -43,4 +49,4 @@ export default defineConfig([ "function-paren-newline": ["error", "multiline-arguments"], }, }, -]); \ No newline at end of file +]); diff --git a/TopNotify/src-vite/src/App.jsx b/TopNotify/src-vite/src/App.jsx index d049e2b..88f1860 100644 --- a/TopNotify/src-vite/src/App.jsx +++ b/TopNotify/src-vite/src/App.jsx @@ -9,6 +9,7 @@ import ManageNotificationSounds from "./NotificationSounds"; import Preview from "./Preview"; import ReadAloud from "./ReadAloud"; import SoundInterceptionToggle from "./SoundInterceptionToggle"; +import StickyNotifications from "./StickyNotifications"; import TestNotification from "./TestNotification"; import NotificationTransparency from "./Transparency"; @@ -16,15 +17,42 @@ window.Config = { Location: -1, Opacity: 0, ReadAloud: false, + StickyMode: 1, + StickyDurationSeconds: 5, AppReferences: [] }; // Called By C#, Sets The window.Config Object To The Saved Config File window.SetConfig = async (config) => { Config = JSON.parse(config); + window.NormalizeConfig(); window.setRerender(rerender + 1); }; +window.NormalizeConfig = () => { + if (Config.StickyMode == undefined) { + Config.StickyMode = 1; + } + + if (Config.StickyDurationSeconds == undefined) { + Config.StickyDurationSeconds = 5; + } + + if (!Config.AppReferences) { + Config.AppReferences = []; + } + + for (let i = 0; i < Config.AppReferences.length; i++) { + if (Config.AppReferences[i].StickyMode == undefined) { + Config.AppReferences[i].StickyMode = 0; + } + + if (Config.AppReferences[i].StickyDurationSeconds == undefined) { + Config.AppReferences[i].StickyDurationSeconds = 5; + } + } +}; + window.UploadConfig = () => { if (Config.Location == -1) { @@ -88,6 +116,8 @@ function App() { + + diff --git a/TopNotify/src-vite/src/CSS/StickyNotifications.css b/TopNotify/src-vite/src/CSS/StickyNotifications.css new file mode 100644 index 0000000..48285d3 --- /dev/null +++ b/TopNotify/src-vite/src/CSS/StickyNotifications.css @@ -0,0 +1,233 @@ +.stickySection { + display: flex; + flex-direction: column; + gap: 10px; +} + +.stickySection .stickyControls { + width: 100%; +} + +.stickySettingsTitle, .stickySection h3 { + font-size: 1.3rem; + font-weight: 500; +} + +.chakra-drawer__body { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden !important; +} + +.stickyAddApp { + display: flex; + flex-direction: column; + gap: 10px; + align-items: stretch; + padding: 10px 0 14px; +} + +.stickyAddApp button { + align-self: flex-start; +} + +.stickyAppPicker { + background-color: var(--col-bg) !important; + height: 80vh; + max-height: 80vh; +} + +.stickyAppPicker > * { + background-color: var(--col-bg) !important; +} + +.stickyAppPicker .chakra-modal__body { + display: flex; + flex-direction: column; + min-height: 0; +} + +.stickySearchBox { + display: grid; + grid-template-columns: 24px 1fr; + gap: 8px; + align-items: center; +} + +.stickySearchBox svg { + width: 20px; + height: 20px; +} + +.stickySearchBox input { + height: 34px; + border-radius: 5px; + background-color: var(--col-tertiary); + border-color: var(--col-border); +} + +.stickySearchResults { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow-y: auto; + border-radius: 5px; + background-color: var(--col-tertiary); + margin-top: 12px; +} + +.stickySearchResult { + display: flex !important; + gap: 10px; + align-items: center; + width: 100% !important; + min-height: 38px !important; + height: 38px !important; + padding: 7px 10px !important; + border-radius: 0 !important; + background: transparent !important; +} + +.stickySearchResult img { + flex: 0 0 24px; + width: 24px; + height: 24px; +} + +.stickySearchResult span { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} + +.stickySearchResult svg { + flex: 0 0 18px; + width: 18px; + height: 18px; + margin: 0; +} + +.stickySearchEmpty { + min-height: 80px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.65; +} + +.stickyAppItem { + display: grid; + grid-template-columns: minmax(0, 1fr) 32px; + grid-template-rows: auto auto; + gap: 10px 12px; + align-items: center; + min-height: 94px; + padding: 10px 6px 10px 0; +} + +.stickyAppIdentity { + display: flex; + grid-column: 1; + grid-row: 1; + gap: 10px; + align-items: center; + min-width: 0; +} + +.stickyAppIdentity img { + flex: 0 0 24px; + width: 24px; + height: 24px; +} + +.stickyAppItem h4 { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stickyAppItem > .iconButton { + grid-column: 2; + grid-row: 2; + align-self: center; + justify-self: end; +} + +.stickyAppItem > .stickyControls { + grid-column: 1; + grid-row: 2; +} + +.stickyAppList { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.stickyAppList::-webkit-scrollbar { + width: 8px; + background-color: rgba(255, 255, 255, 0.08); +} + +.stickyAppList::-webkit-scrollbar-thumb { + min-height: 40px; + border: 1px solid transparent; + border-radius: 6px; + background-clip: padding-box; + background-color: rgba(255, 255, 255, 0.55); +} + +.stickyControls { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + min-width: 0; +} + +.stickyControls > .chakra-select__wrapper { + flex: 0 0 176px; + width: 176px; +} + +.stickyControls > .chakra-select__wrapper .chakra-select__icon-wrapper { + right: 8px; +} + +.stickyControls select { + height: 34px; + width: 176px; + min-width: 176px; + padding-right: 32px; + border-radius: 5px; + background-color: var(--col-tertiary); + border-color: var(--col-border); +} + +.stickySeconds { + width: 92px; + height: 34px; +} + +.stickySeconds.hidden { + display: none; +} + +.stickySeconds input { + height: 34px; + min-width: 0; + padding-left: 8px; + padding-right: 4px; + background-color: var(--col-tertiary); + border-color: var(--col-border); +} + +.stickySection + hr { + margin-top: 14px; +} diff --git a/TopNotify/src-vite/src/StickyNotifications.jsx b/TopNotify/src-vite/src/StickyNotifications.jsx new file mode 100644 index 0000000..73ebe1e --- /dev/null +++ b/TopNotify/src-vite/src/StickyNotifications.jsx @@ -0,0 +1,287 @@ +import { Button, Divider, Drawer, DrawerBody, DrawerContent, DrawerFooter, DrawerHeader, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay, NumberInput, NumberInputField, Select } from "@chakra-ui/react"; +import { Fragment, useEffect, useRef, useState } from "react"; +import React from "react"; +import { TbChevronDown, TbClock, TbPencil, TbPlus, TbSearch, TbX } from "react-icons/tb"; + +import "./CSS/StickyNotifications.css"; + +const StickyMode = { + Default: 0, + WindowsDefault: 1, + Seconds: 2, + Permanent: 3 +}; + +const DefaultAppIcon = "/Image/DefaultAppReferenceIcon.svg"; + +export default function StickyNotifications() { + + let [isOpen, _setIsOpen] = useState(false); + let [isPickerOpen, setIsPickerOpen] = useState(false); + let [appSearch, setAppSearch] = useState(""); + + const installedApps = JSON.parse(igniteView.withReact(React).useCommandResult("FindInstalledApps") || "[]"); + const appReferences = window.Config.AppReferences || []; + const availableApps = installedApps.filter((app) => app.Name && !appReferences.some((appReference) => appReference.ID == app.Name)); + const searchTerm = appSearch.toLowerCase(); + const filteredApps = availableApps.filter((app) => { + return app.Name.toLowerCase().includes(searchTerm) || (app.AppID || "").toLowerCase().includes(searchTerm); + }).slice(0, 80); + + let setIsOpen = (v) => { + if (v && rerender < 0) { return; } + + if (v) { + setTimeout(() => setRerender(-9999999), 0); + } + else { + setTimeout(() => setRerender(2), 0); + } + + _setIsOpen(v); + }; + + let addApp = (app) => { + if (!app) { return; } + + Config.AppReferences.push({ + ReferenceType: 0, + ID: app.Name, + DisplayName: app.Name, + DisplayIcon: app.DisplayIcon || window.installedAppIcons?.[app.AppID] || DefaultAppIcon, + SoundPath: "internal/default", + SoundDisplayName: "Default Sound", + StickyMode: StickyMode.Seconds, + StickyDurationSeconds: Config.StickyDurationSeconds || 5 + }); + + setAppSearch(""); + setIsPickerOpen(false); + UploadConfig(); + }; + + let removeApp = (appReference) => { + Config.AppReferences = Config.AppReferences.filter((reference) => reference.ID != appReference.ID); + UploadConfig(); + }; + + let openPicker = () => { + setAppSearch(""); + setIsPickerOpen(true); + }; + + let closePicker = () => { + setAppSearch(""); + setIsPickerOpen(false); + }; + + return ( + + Notification Sticky Time + setIsOpen(true)}> + + + setIsOpen(false)} + > + + + setIsOpen(false)}> + + + Notification Sticky Time + + + + Default + + + + + + Application Settings + + + Add Application + + + + { + appReferences.filter((appReference) => appReference.ID != "Other").map((appReference, i) => { + return ( + + + + + ); + }) + } + + + + + + + + + + ); +} + +function AddApplicationPicker(props) { + const searchInput = useRef(null); + + useEffect(() => { + if (!props.isOpen) { return; } + setTimeout(() => searchInput.current?.focus(), 0); + }, [props.isOpen]); + + return ( + + + + Add Application + + + + + props.setAppSearch(e.target.value)} placeholder="Search Windows Apps"/> + + + { + props.filteredApps.length > 0 ? + props.filteredApps.map((app, i) => { + return (); + }) : + (No applications found.) + } + + + + + ); +} + +function StickySearchResult(props) { + let [icon, setIcon] = useState(props.app.DisplayIcon || window.installedAppIcons?.[props.app.AppID] || DefaultAppIcon); + + useEffect(() => { + if (!window.installedAppIcons) { + window.installedAppIcons = {}; + } + + if (window.installedAppIcons[props.app.AppID]) { + setIcon(window.installedAppIcons[props.app.AppID]); + return; + } + + let isMounted = true; + igniteView.commandBridge.FindInstalledAppIcon(props.app.Name, props.app.AppID || "").then((result) => { + window.installedAppIcons[props.app.AppID] = result; + props.app.DisplayIcon = result; + if (isMounted) { + setIcon(result); + } + }); + + return () => { + isMounted = false; + }; + }, [props.app]); + + let addApp = () => { + props.app.DisplayIcon = icon; + props.addApp(props.app); + }; + + return ( + + + + + + ); +} + +function StickyAppItem(props) { + let icon = props.appReference.DisplayIcon || DefaultAppIcon; + + return ( + + + + + + + props.removeApp(props.appReference)}> + + ); +} + +function StickyOverflowText(props) { + let Component = props.tag || "span"; + let textRef = useRef(null); + let [title, setTitle] = useState(""); + + let updateTitle = () => { + let element = textRef.current; + setTitle(element && element.scrollWidth > element.clientWidth ? props.text : ""); + }; + + return ( + + {props.text} + + ); +} + +function StickyControls(props) { + + if (props.target.StickyMode == undefined) { + props.target.StickyMode = props.allowDefault ? StickyMode.Default : StickyMode.WindowsDefault; + } + + if (props.target.StickyDurationSeconds == undefined) { + props.target.StickyDurationSeconds = 5; + } + + let setMode = (mode) => { + props.target.StickyMode = parseInt(mode); + UploadConfig(); + }; + + let setDuration = (value) => { + props.target.StickyDurationSeconds = Math.max(1, parseInt(value || "1")); + UploadConfig(); + }; + + return ( + + setMode(e.target.value)}> + { + props.allowDefault && (Default) + } + { + !props.allowDefault && (Windows Default) + } + Custom Seconds + Permanent + + + + + + + + ); +}