SHA256
695 lines
32 KiB
C#
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 "<folder>"</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 <folderPath></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" <title> <message></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
|
|
} |