using System; using System.Diagnostics; using System.Runtime.InteropServices; using System.Threading; using MelonLoader; namespace ADOFAIRenderer.Util.OS; /// /// Sends native desktop notifications on Windows, macOS, and Linux. /// /// - Windows: Toast notification shown by a hidden powershell.exe process /// (WinRT ToastNotificationManager, script passed with -EncodedCommand). /// With a folder path, the script also registers the adofai-renderer:// URL protocol under /// HKCU\Software\Classes\adofai-renderer (open command: explorer.exe "<folder>") /// so the toast button opens the folder; the key is overwritten on each notification and stays registered. /// When PowerShell fails, a Win32 Shell_NotifyIcon balloon is shown via P/Invoke. /// /// /// - macOS: Uses osascript with AppleScript's display notification. /// /// /// - Linux: Uses notify-send (libnotify) with xdg-open for folder actions. /// /// internal static class OSNotification { /// /// Sends a native desktop notification. /// If is provided, Windows and Linux will attempt to open /// the folder when the notification is clicked/dismissed. /// /// The notification title. /// The notification body text. /// Optional folder path to open on click (Windows balloon click / Linux action). public static void Show(string title, string message, string? folderPath = null) { try { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { WindowsNotification.Send(title, message, folderPath); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { MacNotification.Send(title, message); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { LinuxNotification.Send(title, message, folderPath); } } catch (Exception ex) { MelonLogger.Warning($"[OSNotification] Failed to send notification: {ex.Message}"); } } #region Windows — Toast (primary) + Shell_NotifyIcon (fallback) /// /// Windows notification with two strategies: /// 1. Toast via PowerShell — persists in Action Center. /// 2. Shell_NotifyIcon balloon via P/Invoke — fallback if toast fails. /// private static class WindowsNotification { public static void Send(string title, string message, string? folderPath) { var thread = new Thread(() => { try { if (!TrySendToast(title, message, folderPath)) { SendBalloon(title, message, folderPath); } } catch (Exception ex) { MelonLogger.Warning($"[OSNotification] Windows notification failed: {ex.Message}"); } }) { IsBackground = true, Name = "OSNotification-Win" }; thread.Start(); } #region Toast via PowerShell /// /// Attempts to show a Windows Toast notification via PowerShell. /// With a folder path, registers the adofai-renderer:// protocol in HKCU first (see class summary). /// Waits up to 5 seconds for the process. Returns true when it exits with code 0, /// false when it exits with another code, does not exit within 5 seconds, or fails to start. /// private static bool TrySendToast(string title, string message, string? folderPath) { try { string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title; string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message; string toastXml; string psScript; if (!string.IsNullOrEmpty(folderPath)) { var escapedFolder = folderPath.Replace("'", "''"); toastXml = "" + "" + $"{xmlTitle}{xmlMessage}" + "" + "" + "" + ""; psScript = "$protocolPath = 'HKCU:\\Software\\Classes\\adofai-renderer'\n" + "$null = New-Item -Path $protocolPath -Force\n" + "$null = New-ItemProperty -Path $protocolPath -Name 'URL Protocol' -Value '' -PropertyType String -Force\n" + "Set-Item -Path $protocolPath -Value 'URL:adofai-renderer Protocol' | Out-Null\n" + "$cmdPath = \"$protocolPath\\shell\\open\\command\"\n" + "$null = New-Item -Path $cmdPath -Force\n" + $"Set-Item -Path $cmdPath -Value 'explorer.exe \"{escapedFolder}\"' | Out-Null\n" + "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" + "[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" + "$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" + $"$xml.LoadXml('{toastXml}')\n" + "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))"; } else { toastXml = "" + $"{xmlTitle}{xmlMessage}" + ""; psScript = "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" + "[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" + "$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" + $"$xml.LoadXml('{toastXml}')\n" + "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))"; } byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript); string base64 = Convert.ToBase64String(bytes); var startInfo = new ProcessStartInfo { FileName = "powershell.exe", Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}", CreateNoWindow = true, UseShellExecute = false, RedirectStandardError = true, WindowStyle = ProcessWindowStyle.Hidden }; using var process = new Process(); process.StartInfo = startInfo; process.Start(); process.WaitForExit(5000); // If PowerShell exited with error, treat as failure if (process.ExitCode != 0) { string stderr = process.StandardError.ReadToEnd().Trim(); if (!string.IsNullOrEmpty(stderr)) MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {stderr}"); return false; } return true; } catch (Exception ex) { MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {ex.Message}"); return false; } } #endregion #region Balloon via Shell_NotifyIcon // ReSharper disable InconsistentNaming private const int NIM_ADD = 0x00; private const int NIM_DELETE = 0x02; private const int NIF_MESSAGE = 0x01; private const int NIF_ICON = 0x02; private const int NIF_TIP = 0x04; private const int NIF_INFO = 0x10; private const int NIIF_INFO = 0x01; private const int WM_USER = 0x0400; private const int WM_TRAYICON = WM_USER + 1; private const int NIN_BALLOONUSERCLICK = 0x0405; private const int WS_OVERLAPPED = 0x00000000; private const uint WM_CLOSE = 0x0010; private const int TRAY_ICON_UID = 29471; // ReSharper restore InconsistentNaming [DllImport("shell32.dll", CharSet = CharSet.Unicode)] private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData); [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr CreateWindowExW( int dwExStyle, string lpClassName, string lpWindowName, int dwStyle, int x, int y, int nWidth, int nHeight, IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool DestroyWindow(IntPtr hWnd); [DllImport("user32.dll")] private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetMessageW(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax); [DllImport("user32.dll")] private static extern IntPtr TranslateMessage(ref Msg lpMsg); [DllImport("user32.dll")] private static extern IntPtr DispatchMessageW(ref Msg lpMsg); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private struct NotifyIconDataW { public int cbSize; public IntPtr hWnd; public int uID; public int uFlags; public int uCallbackMessage; public IntPtr hIcon; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string szTip; public int dwState; public int dwStateMask; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string szInfo; public int uTimeoutOrVersion; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string szInfoTitle; public int dwInfoFlags; } [StructLayout(LayoutKind.Sequential)] private struct Msg { public IntPtr hwnd; public uint message; public IntPtr wParam; public IntPtr lParam; public uint time; public int pt_x; public int pt_y; } private static void SendBalloon(string title, string message, string? folderPath) { if (title.Length > 63) title = title.Substring(0, 63); if (message.Length > 255) message = message.Substring(0, 255); IntPtr hWnd = IntPtr.Zero; var nid = new NotifyIconDataW(); try { var hwndMessage = new IntPtr(-3); hWnd = CreateWindowExW( 0, "Static", "ADOFAIRendererNotify", WS_OVERLAPPED, 0, 0, 0, 0, hwndMessage, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero); if (hWnd == IntPtr.Zero) { MelonLogger.Warning("[OSNotification] Cannot create message window."); return; } IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512); nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW)); nid.hWnd = hWnd; nid.uID = TRAY_ICON_UID; nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_INFO; nid.uCallbackMessage = WM_TRAYICON; nid.hIcon = hIcon; nid.szTip = "ADOFAI Renderer"; nid.szInfo = message; nid.szInfoTitle = title; nid.dwInfoFlags = NIIF_INFO; nid.uTimeoutOrVersion = 4; Shell_NotifyIconW(NIM_ADD, ref nid); if (!string.IsNullOrEmpty(folderPath)) { var capturedFolder = folderPath; var closeTimer = new System.Threading.Timer(_ => { try { PostMessageW(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); } catch { /* ignored */ } }, null, 12000, Timeout.Infinite); try { while (GetMessageW(out var msg, IntPtr.Zero, 0, 0)) { if (msg.message == WM_CLOSE) break; if (msg.message == WM_TRAYICON) { int notifyMsg = (int)(long)msg.lParam; if (notifyMsg == NIN_BALLOONUSERCLICK) { try { var psi = new ProcessStartInfo(); psi.FileName = capturedFolder; psi.UseShellExecute = true; Process.Start(psi); } catch (Exception ex) { MelonLogger.Warning( $"[OSNotification] Cannot open folder: {ex.Message}"); } break; } } TranslateMessage(ref msg); DispatchMessageW(ref msg); } } finally { closeTimer.Dispose(); } } else { Thread.Sleep(500); } } catch (Exception ex) { MelonLogger.Warning($"[OSNotification] Balloon notification failed: {ex.Message}"); } finally { if (hWnd != IntPtr.Zero) { nid.uFlags = 0; Shell_NotifyIconW(NIM_DELETE, ref nid); DestroyWindow(hWnd); } } } #endregion } #endregion #region macOS — osascript /// /// macOS notification using osascript -e 'display notification ...'. /// private static class MacNotification { public static void Send(string title, string message) { // AppleScript string escaping: backslash and double-quote string escapedTitle = EscapeAppleScript(title); string escapedMessage = EscapeAppleScript(message); // Use ProcessStartInfo with ArgumentList to avoid shell-level escaping issues. // Each argument is passed directly to the process without shell interpretation. var script = $"display notification \"{escapedMessage}\" with title \"{escapedTitle}\""; var startInfo = new ProcessStartInfo { FileName = "osascript", CreateNoWindow = true, UseShellExecute = false, WindowStyle = ProcessWindowStyle.Hidden }; startInfo.ArgumentList.Add("-e"); startInfo.ArgumentList.Add(script); RunProcess(startInfo); } private static string EscapeAppleScript(string value) { // In AppleScript string literals, escape backslash and double-quote return value .Replace("\\", "\\\\") .Replace("\"", "\\\""); } } #endregion #region Linux — notify-send / gdbus /// /// Linux notification using notify-send (libnotify CLI). /// Falls back to gdbus (D-Bus direct call) if notify-send is unavailable. /// private static class LinuxNotification { public static void Send(string title, string message, string? folderPath) { if (!string.IsNullOrEmpty(folderPath)) { ShowWithAction(title, message, folderPath); } else { ShowSimple(title, message); } } private static void ShowSimple(string title, string message) { // Try notify-send first if (TryNotifySend(title, message)) return; // Fallback to gdbus TryGdbus(title, message); } private static void ShowWithAction(string title, string message, string folderPath) { // Run on a background thread because notify-send with --action blocks // until the notification is dismissed or the action is clicked. System.Threading.Tasks.Task.Run(() => { try { var startInfo = new ProcessStartInfo { FileName = "notify-send", CreateNoWindow = true, UseShellExecute = false, RedirectStandardOutput = true, WindowStyle = ProcessWindowStyle.Hidden }; startInfo.ArgumentList.Add("-a"); startInfo.ArgumentList.Add("ADOFAI Renderer"); startInfo.ArgumentList.Add("--action=open_folder=Open Render Folder"); startInfo.ArgumentList.Add(title); startInfo.ArgumentList.Add(message); using var process = new Process(); process.StartInfo = startInfo; process.Start(); string output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(); if (output == "open_folder") { var openInfo = new ProcessStartInfo { FileName = "xdg-open", CreateNoWindow = true, UseShellExecute = false, WindowStyle = ProcessWindowStyle.Hidden }; openInfo.ArgumentList.Add(folderPath); RunProcess(openInfo); } } catch (Exception ex) { MelonLogger.Warning($"[OSNotification] Linux notification processing failed: {ex.Message}"); // Fallback: show without action ShowSimple(title, message); } }); } private static bool TryNotifySend(string title, string message) { try { var startInfo = new ProcessStartInfo { FileName = "notify-send", CreateNoWindow = true, UseShellExecute = false, WindowStyle = ProcessWindowStyle.Hidden }; startInfo.ArgumentList.Add("-a"); startInfo.ArgumentList.Add("ADOFAI Renderer"); startInfo.ArgumentList.Add(title); startInfo.ArgumentList.Add(message); RunProcess(startInfo); return true; } catch { return false; } } private static void TryGdbus(string title, string message) { try { // Direct D-Bus call to org.freedesktop.Notifications var startInfo = new ProcessStartInfo { FileName = "gdbus", CreateNoWindow = true, UseShellExecute = false, WindowStyle = ProcessWindowStyle.Hidden }; startInfo.ArgumentList.Add("call"); startInfo.ArgumentList.Add("--session"); startInfo.ArgumentList.Add("--dest=org.freedesktop.Notifications"); startInfo.ArgumentList.Add("--object-path=/org/freedesktop/Notifications"); startInfo.ArgumentList.Add("--method=org.freedesktop.Notifications.Notify"); startInfo.ArgumentList.Add("ADOFAI Renderer"); // app_name startInfo.ArgumentList.Add("0"); // replaces_id startInfo.ArgumentList.Add(""); // app_icon startInfo.ArgumentList.Add(title); // summary startInfo.ArgumentList.Add(message); // body startInfo.ArgumentList.Add("[]"); // actions startInfo.ArgumentList.Add("{}"); // hints startInfo.ArgumentList.Add("-1"); // expire_timeout RunProcess(startInfo); } catch (Exception ex) { MelonLogger.Warning($"[OSNotification] gdbus notification failed: {ex.Message}"); } } } #endregion #region Shared utility /// /// Starts a process fire-and-forget. Does not redirect output or wait for exit. /// private static void RunProcess(ProcessStartInfo startInfo) { using var process = new Process(); process.StartInfo = startInfo; process.Start(); } #endregion }