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, so every /// toast opens the folder of the most recent notification. /// When PowerShell fails, a Win32 Shell_NotifyIcon balloon is shown via P/Invoke. /// Runs on a dedicated background thread ("OSNotification-Win"). /// /// /// - macOS: Uses osascript with AppleScript's display notification (no folder action), started from the /// calling thread without waiting. /// /// /// - Linux: Uses notify-send (libnotify) and, when it cannot be started, gdbus calling /// org.freedesktop.Notifications.Notify. With a folder path, notify-send --action runs on a thread pool task /// and xdg-open opens the folder when the action is chosen. /// /// Other platforms: nothing is shown. Failures are logged as warnings; no exception reaches the caller. /// internal static class OSNotification { /// /// Sends a native desktop notification and returns without waiting for it to be shown (see the class summary for the /// thread used on each platform). /// If is provided, Windows and Linux offer to open the folder from the notification. /// Exceptions thrown while starting the platform implementation are caught and logged as warnings. /// /// The notification title. /// The notification body text. /// /// Optional folder path to open from the notification (Windows toast click or button / Windows balloon click / Linux action); /// ignored on macOS. /// 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 { /// /// Starts a background thread ("OSNotification-Win") that tries the toast and shows the balloon when the toast fails. /// Returns immediately. Exceptions on the thread are caught and logged. /// /// Notification title. /// Notification body text. /// Folder to open from the notification, or null. 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. /// /// The toast XML uses the ToastGeneric template with the title and message as two text elements; with a folder /// path it also sets launch and an "Open Render Folder" action to the adofai-renderer:// URI with protocol /// activation. The notifier is created with the application ID "ADOFAI Renderer". The script is passed as Base64 of its /// UTF-16LE bytes (-EncodedCommand), so no command line quoting is needed. /// /// /// Notification title (XML-escaped into the toast). /// Notification body text (XML-escaped into the toast). /// Folder opened by the protocol handler, or null for a toast without action. /// True when PowerShell exited with code 0 within 5 seconds; false otherwise (the stderr text or exception is logged). private static bool TrySendToast(string title, string message, string? folderPath) { try { // SecurityElement.Escape replaces < > & " ' with XML entities, so the text also cannot end the // single-quoted PowerShell string that holds the XML. string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title; string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message; string toastXml; string psScript; if (!string.IsNullOrEmpty(folderPath)) { // A single quote is written as two single quotes inside a single-quoted PowerShell string. var escapedFolder = folderPath.Replace("'", "''"); // Script: register HKCU\Software\Classes\adofai-renderer as a URL protocol ("URL Protocol" value, default // value "URL:adofai-renderer Protocol") whose shell\open\command runs explorer.exe on the folder, then load // the WinRT types and show the toast. 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))"; } // -EncodedCommand takes Base64 of the UTF-16LE script text. 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); // ExitCode throws InvalidOperationException while the process is still running (timeout), which the catch // below reports as a failure. 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 // Shell_NotifyIcon messages: add / delete the notification area icon. // Reference: https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-shell_notifyiconw private const int NIM_ADD = 0x00; private const int NIM_DELETE = 0x02; // NOTIFYICONDATAW.uFlags: uCallbackMessage, hIcon, szTip and the balloon fields (szInfo, szInfoTitle, dwInfoFlags) // are valid. NIIF_INFO: information icon in the balloon. // Reference: https://learn.microsoft.com/en-us/windows/win32/api/shellapi/ns-shellapi-notifyicondataw 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; // WM_USER (winuser.h) starts the private window message range; WM_TRAYICON is the application-defined callback // message of the icon. NIN_BALLOONUSERCLICK (shellapi.h, WM_USER + 5) arrives as lParam of the callback message when // the user clicks the balloon. private const int WM_USER = 0x0400; private const int WM_TRAYICON = WM_USER + 1; private const int NIN_BALLOONUSERCLICK = 0x0405; // Window style of the hidden message window (winuser.h), WM_CLOSE message ID (winuser.h), and the application-defined // icon ID (any value unique for the window). private const int WS_OVERLAPPED = 0x00000000; private const uint WM_CLOSE = 0x0010; private const int TRAY_ICON_UID = 29471; // ReSharper restore InconsistentNaming // Adds, modifies or deletes a notification area icon; returns false on failure. [DllImport("shell32.dll", CharSet = CharSet.Unicode)] private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData); // Creates the window that owns the icon and receives its callback messages; returns NULL on failure. [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); // Loads a predefined system icon when hInstance is NULL. // Reference: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-loadiconw [DllImport("user32.dll")] private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName); // Message loop functions of the calling thread: GetMessageW blocks until a message arrives and returns false for WM_QUIT. [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); // Places a message in the window's thread queue and returns without waiting (callable from any thread). [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); // NOTIFYICONDATAW up to dwInfoFlags (without guidItem and hBalloonIcon); cbSize is the size of this layout. // String fields hold at most SizeConst characters including the terminating null. [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; } // Win32 MSG structure filled by GetMessageW. [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; } /// /// Shows a notification area balloon through Shell_NotifyIconW, owned by a hidden message-only window. /// /// Without a folder path the icon stays for 500 ms and is then removed. With a folder path the method runs a message /// loop on the calling thread for up to 12 seconds (a timer posts WM_CLOSE); a balloon click /// (NIN_BALLOONUSERCLICK) opens the folder through the shell (UseShellExecute) and ends the loop. /// The icon is deleted and the window destroyed before returning. Errors are logged as warnings. /// /// /// Balloon title; truncated to 63 characters. /// Balloon text; truncated to 255 characters. /// Folder opened on balloon click, or null. private static void SendBalloon(string title, string message, string? folderPath) { // szInfoTitle holds 64 and szInfo 256 characters including the terminating null. // Reference: https://learn.microsoft.com/en-us/windows/win32/api/shellapi/ns-shellapi-notifyicondataw 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 { // HWND_MESSAGE ((HWND)-3, winuser.h) as parent creates a message-only window of the "Static" class. 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; } // 32512 = IDI_APPLICATION (winuser.h), the default application icon. 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; // uTimeout in milliseconds; deprecated as of Windows Vista (display time follows the system settings). 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) { // No NIM_SETVERSION call: lParam carries the notification event (NIN_*) directly. 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 { /// /// Starts osascript -e "display notification ... with title ..." without waiting. Exceptions from starting the /// process (for example osascript missing) propagate to . /// /// Notification title. /// Notification body text. 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); } /// Escapes a value for use inside a double-quoted AppleScript string literal. /// Raw text. /// The text with each backslash and double quote preceded by a backslash. 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 { /// /// Shows the notification with an "Open Render Folder" action when is set (thread pool /// task), otherwise a plain notification started from the calling thread. /// /// Notification summary. /// Notification body. /// Folder opened by the action, or null. public static void Send(string title, string message, string? folderPath) { if (!string.IsNullOrEmpty(folderPath)) { ShowWithAction(title, message, folderPath); } else { ShowSimple(title, message); } } /// Starts notify-send; when it cannot be started, starts gdbus. Neither process is waited for. /// Notification summary. /// Notification body. private static void ShowSimple(string title, string message) { // Try notify-send first if (TryNotifySend(title, message)) return; // Fallback to gdbus TryGdbus(title, message); } /// /// On a thread pool task, runs notify-send -a "ADOFAI Renderer" --action=open_folder=Open Render Folder and waits /// for it to exit. notify-send prints the key of the chosen action ("open_folder") to standard output; in that case /// xdg-open <folderPath> is started. When an exception occurs (for example notify-send missing), it is logged /// and shows the notification without the action. /// /// Notification summary. /// Notification body. /// Folder passed to xdg-open. 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); } }); } /// Starts notify-send -a "ADOFAI Renderer" <title> <message> without waiting. /// Notification summary. /// Notification body. /// True when the process was started (its exit code is not checked); false when starting it threw. 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; } } /// /// Starts gdbus call --session to org.freedesktop.Notifications.Notify (arguments app_name, replaces_id, /// app_icon, summary, body, actions, hints, expire_timeout) without waiting. Start failures are logged as warnings. /// Reference: https://specifications.freedesktop.org/notification/latest/protocol.html /// /// Notification summary. /// Notification body. 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 }