Files
ADOFAIRenderer/Util/OS/OSNotification.cs
T
2026-10-15 16:17:13 +09:00

695 lines
32 KiB
C#

using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
using MelonLoader;
namespace ADOFAIRenderer.Util.OS;
/// <summary>
/// Sends native desktop notifications on Windows, macOS, and Linux.
/// <para>
/// - <b>Windows</b>: Toast notification shown by a hidden <c>powershell.exe</c> process
/// (WinRT <c>ToastNotificationManager</c>, script passed with <c>-EncodedCommand</c>).
/// With a folder path, the script also registers the <c>adofai-renderer://</c> URL protocol under
/// <c>HKCU\Software\Classes\adofai-renderer</c> (open command: <c>explorer.exe "&lt;folder&gt;"</c>)
/// 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 <c>Shell_NotifyIcon</c> balloon is shown via P/Invoke.
/// Runs on a dedicated background thread ("OSNotification-Win").
/// </para>
/// <para>
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c> (no folder action), started from the
/// calling thread without waiting.
/// </para>
/// <para>
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) and, when it cannot be started, <c>gdbus</c> calling
/// <c>org.freedesktop.Notifications.Notify</c>. With a folder path, <c>notify-send --action</c> runs on a thread pool task
/// and <c>xdg-open</c> opens the folder when the action is chosen.
/// </para>
/// <para>Other platforms: nothing is shown. Failures are logged as warnings; no exception reaches the caller.</para>
/// </summary>
internal static class OSNotification
{
/// <summary>
/// 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 <paramref name="folderPath"/> 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.
/// </summary>
/// <param name="title">The notification title.</param>
/// <param name="message">The notification body text.</param>
/// <param name="folderPath">
/// Optional folder path to open from the notification (Windows toast click or button / Windows balloon click / Linux action);
/// ignored on macOS.
/// </param>
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)
/// <summary>
/// Windows notification with two strategies:
/// <para>1. <b>Toast</b> via PowerShell — persists in Action Center.</para>
/// <para>2. <b>Shell_NotifyIcon balloon</b> via P/Invoke — fallback if toast fails.</para>
/// </summary>
private static class WindowsNotification
{
/// <summary>
/// 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.
/// </summary>
/// <param name="title">Notification title.</param>
/// <param name="message">Notification body text.</param>
/// <param name="folderPath">Folder to open from the notification, or null.</param>
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
/// <summary>
/// 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.
/// <para>
/// The toast XML uses the <c>ToastGeneric</c> template with the title and message as two text elements; with a folder
/// path it also sets <c>launch</c> and an "Open Render Folder" action to the <c>adofai-renderer://</c> 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 (<c>-EncodedCommand</c>), so no command line quoting is needed.
/// </para>
/// </summary>
/// <param name="title">Notification title (XML-escaped into the toast).</param>
/// <param name="message">Notification body text (XML-escaped into the toast).</param>
/// <param name="folderPath">Folder opened by the protocol handler, or null for a toast without action.</param>
/// <returns>True when PowerShell exited with code 0 within 5 seconds; false otherwise (the stderr text or exception is logged).</returns>
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 =
"<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" +
"<visual><binding template=\"ToastGeneric\">" +
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
"</binding></visual>" +
"<actions>" +
"<action content=\"Open Render Folder\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
"</actions></toast>";
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 =
"<toast><visual><binding template=\"ToastGeneric\">" +
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
"</binding></visual></toast>";
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;
}
/// <summary>
/// Shows a notification area balloon through <c>Shell_NotifyIconW</c>, owned by a hidden message-only window.
/// <para>
/// 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 <c>WM_CLOSE</c>); a balloon click
/// (<c>NIN_BALLOONUSERCLICK</c>) opens the folder through the shell (<c>UseShellExecute</c>) and ends the loop.
/// The icon is deleted and the window destroyed before returning. Errors are logged as warnings.
/// </para>
/// </summary>
/// <param name="title">Balloon title; truncated to 63 characters.</param>
/// <param name="message">Balloon text; truncated to 255 characters.</param>
/// <param name="folderPath">Folder opened on balloon click, or null.</param>
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
/// <summary>
/// macOS notification using <c>osascript -e 'display notification ...'</c>.
/// </summary>
private static class MacNotification
{
/// <summary>
/// Starts <c>osascript -e "display notification ... with title ..."</c> without waiting. Exceptions from starting the
/// process (for example osascript missing) propagate to <see cref="OSNotification.Show"/>.
/// </summary>
/// <param name="title">Notification title.</param>
/// <param name="message">Notification body text.</param>
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);
}
/// <summary>Escapes a value for use inside a double-quoted AppleScript string literal.</summary>
/// <param name="value">Raw text.</param>
/// <returns>The text with each backslash and double quote preceded by a backslash.</returns>
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
/// <summary>
/// Linux notification using <c>notify-send</c> (libnotify CLI).
/// Falls back to <c>gdbus</c> (D-Bus direct call) if notify-send is unavailable.
/// </summary>
private static class LinuxNotification
{
/// <summary>
/// Shows the notification with an "Open Render Folder" action when <paramref name="folderPath"/> is set (thread pool
/// task), otherwise a plain notification started from the calling thread.
/// </summary>
/// <param name="title">Notification summary.</param>
/// <param name="message">Notification body.</param>
/// <param name="folderPath">Folder opened by the action, or null.</param>
public static void Send(string title, string message, string? folderPath)
{
if (!string.IsNullOrEmpty(folderPath))
{
ShowWithAction(title, message, folderPath);
}
else
{
ShowSimple(title, message);
}
}
/// <summary>Starts notify-send; when it cannot be started, starts gdbus. Neither process is waited for.</summary>
/// <param name="title">Notification summary.</param>
/// <param name="message">Notification body.</param>
private static void ShowSimple(string title, string message)
{
// Try notify-send first
if (TryNotifySend(title, message))
return;
// Fallback to gdbus
TryGdbus(title, message);
}
/// <summary>
/// On a thread pool task, runs <c>notify-send -a "ADOFAI Renderer" --action=open_folder=Open Render Folder</c> and waits
/// for it to exit. notify-send prints the key of the chosen action ("open_folder") to standard output; in that case
/// <c>xdg-open &lt;folderPath&gt;</c> is started. When an exception occurs (for example notify-send missing), it is logged
/// and <see cref="ShowSimple"/> shows the notification without the action.
/// </summary>
/// <param name="title">Notification summary.</param>
/// <param name="message">Notification body.</param>
/// <param name="folderPath">Folder passed to xdg-open.</param>
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);
}
});
}
/// <summary>Starts <c>notify-send -a "ADOFAI Renderer" &lt;title&gt; &lt;message&gt;</c> without waiting.</summary>
/// <param name="title">Notification summary.</param>
/// <param name="message">Notification body.</param>
/// <returns>True when the process was started (its exit code is not checked); false when starting it threw.</returns>
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;
}
}
/// <summary>
/// Starts <c>gdbus call --session</c> to <c>org.freedesktop.Notifications.Notify</c> (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
/// </summary>
/// <param name="title">Notification summary.</param>
/// <param name="message">Notification body.</param>
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
/// <summary>
/// Starts a process fire-and-forget. Does not redirect output or wait for exit.
/// </summary>
private static void RunProcess(ProcessStartInfo startInfo)
{
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
}
#endregion
}