SHA256
579 lines
24 KiB
C#
579 lines
24 KiB
C#
using System;
|
|
using System.Diagnostics;
|
|
using System.Runtime.InteropServices;
|
|
using System.Threading;
|
|
using MelonLoader;
|
|
|
|
namespace ADOFAIRenderer.Util
|
|
{
|
|
/// <summary>
|
|
/// Sends native desktop notifications on Windows, macOS, and Linux.
|
|
/// <para>
|
|
/// - <b>Windows</b>: Uses Win32 <c>Shell_NotifyIcon</c> (system tray balloon) via P/Invoke.
|
|
/// No PowerShell, no UWP, no WinRT dependencies.
|
|
/// </para>
|
|
/// <para>
|
|
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class OSNotification
|
|
{
|
|
/// <summary>
|
|
/// Sends a native desktop notification.
|
|
/// If <paramref name="folderPath"/> is provided, Windows and Linux will attempt to open
|
|
/// the folder when the notification is clicked/dismissed.
|
|
/// </summary>
|
|
/// <param name="title">The notification title.</param>
|
|
/// <param name="message">The notification body text.</param>
|
|
/// <param name="folderPath">Optional folder path to open on click (Windows balloon click / Linux action).</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] 알림을 보내지 못했습니다: {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
|
|
{
|
|
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 알림 실패: {ex.Message}");
|
|
}
|
|
})
|
|
{
|
|
IsBackground = true,
|
|
Name = "OSNotification-Win"
|
|
};
|
|
thread.Start();
|
|
}
|
|
|
|
#region Toast via PowerShell
|
|
|
|
/// <summary>
|
|
/// Attempts to show a Windows Toast notification via PowerShell.
|
|
/// Returns true if the process launched successfully, false otherwise.
|
|
/// </summary>
|
|
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 =
|
|
"<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" +
|
|
"<visual><binding template=\"ToastGeneric\">" +
|
|
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
|
"</binding></visual>" +
|
|
"<actions>" +
|
|
"<action content=\"렌더 폴더 열기\" 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))";
|
|
}
|
|
|
|
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 실패 (fallback 사용): {stderr}");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MelonLogger.Warning($"[OSNotification] Toast 실패 (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] 메시지 윈도우를 생성할 수 없습니다.");
|
|
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] 폴더를 열 수 없습니다: {ex.Message}");
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
TranslateMessage(ref msg);
|
|
DispatchMessageW(ref msg);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
closeTimer.Dispose();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Thread.Sleep(500);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MelonLogger.Warning($"[OSNotification] Balloon 알림 실패: {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
|
|
{
|
|
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
|
|
|
|
/// <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
|
|
{
|
|
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=렌더 폴더 열기");
|
|
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] 리눅스 알림 처리 실패: {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 알림 실패: {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
|
|
}
|
|
}
|