diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs index 89b965e..e41526b 100644 --- a/Core/RenderManager.cs +++ b/Core/RenderManager.cs @@ -14,6 +14,7 @@ using ADOFAIRenderer.UI.Window; using ADOFAIRenderer.Util; using ADOFAIRenderer.Util.Audio; using ADOFAIRenderer.Util.FFmpeg; +using ADOFAIRenderer.Util.OS; using FFmpeg.AutoGen; using HarmonyLib; using MelonLoader; diff --git a/UI/Window/RenderConfigWindow.cs b/UI/Window/RenderConfigWindow.cs index 2a0e17d..6666ef3 100644 --- a/UI/Window/RenderConfigWindow.cs +++ b/UI/Window/RenderConfigWindow.cs @@ -7,7 +7,7 @@ using ADOFAIRenderer.Core; using ADOFAIRenderer.Core.Data; using ADOFAIRenderer.Data; using ADOFAIRenderer.Localization; -using ADOFAIRenderer.Util; +using ADOFAIRenderer.Util.OS; using MelonLoader; using O5Kit.Control; using O5Kit.Core; diff --git a/Util/GPUUtil.cs b/Util/GPUUtil.cs index fc174fb..586dcba 100644 --- a/Util/GPUUtil.cs +++ b/Util/GPUUtil.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text.RegularExpressions; using ADOFAIRenderer.Data; using ADOFAIRenderer.Util.FFmpeg; +using ADOFAIRenderer.Util.OS; using MelonLoader; using UnityEngine; diff --git a/Util/FolderDialogUtil.cs b/Util/OS/FolderDialogUtil.cs similarity index 99% rename from Util/FolderDialogUtil.cs rename to Util/OS/FolderDialogUtil.cs index d3b8133..ccae4fc 100644 --- a/Util/FolderDialogUtil.cs +++ b/Util/OS/FolderDialogUtil.cs @@ -8,7 +8,7 @@ using System.Threading; using System.Threading.Tasks; using MelonLoader; -namespace ADOFAIRenderer.Util; +namespace ADOFAIRenderer.Util.OS; [SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "UnusedMember.Local")] diff --git a/Util/OS/OSNotification.cs b/Util/OS/OSNotification.cs new file mode 100644 index 0000000..7632e9a --- /dev/null +++ b/Util/OS/OSNotification.cs @@ -0,0 +1,577 @@ +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: Uses Win32 Shell_NotifyIcon (system tray balloon) via P/Invoke. +/// No PowerShell, no UWP, no WinRT dependencies. +/// +/// +/// - 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. + /// Returns true if the process launched successfully, false otherwise. + /// + 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 +} \ No newline at end of file diff --git a/Util/OS/RegistryUtil.cs b/Util/OS/RegistryUtil.cs new file mode 100644 index 0000000..91ecfc6 --- /dev/null +++ b/Util/OS/RegistryUtil.cs @@ -0,0 +1,129 @@ +using System; + +namespace ADOFAIRenderer.Util.OS; + +internal static class RegistryUtil +{ + private static readonly Type? RegistryKeyType; + private static readonly Type? RegistryHiveType; + private static readonly Type? RegistryViewType; + private static readonly System.Reflection.MethodInfo? OpenBaseKeyMethod; + private static readonly System.Reflection.MethodInfo? OpenSubKeyMethod; + private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod; + private static readonly System.Reflection.MethodInfo? GetValueMethod; + + static RegistryUtil() + { + try + { + RegistryKeyType = Type.GetType("Microsoft.Win32.RegistryKey"); + RegistryHiveType = Type.GetType("Microsoft.Win32.RegistryHive"); + RegistryViewType = Type.GetType("Microsoft.Win32.RegistryView"); + + if (RegistryKeyType == null || RegistryHiveType == null || RegistryViewType == null) + { + var mscorlib = typeof(object).Assembly; + RegistryKeyType = mscorlib.GetType("Microsoft.Win32.RegistryKey"); + RegistryHiveType = mscorlib.GetType("Microsoft.Win32.RegistryHive"); + RegistryViewType = mscorlib.GetType("Microsoft.Win32.RegistryView"); + } + + if (RegistryKeyType != null) + { + OpenBaseKeyMethod = RegistryKeyType.GetMethod("OpenBaseKey", new[] { RegistryHiveType!, RegistryViewType! }); + OpenSubKeyMethod = RegistryKeyType.GetMethod("OpenSubKey", new[] { typeof(string) }); + GetSubKeyNamesMethod = RegistryKeyType.GetMethod("GetSubKeyNames"); + GetValueMethod = RegistryKeyType.GetMethod("GetValue", new[] { typeof(string) }); + } + } + catch + { + // ignored + } + } + + private static void CloseKey(object? key) + { + if (key == null || RegistryKeyType == null) return; + try + { + if (key is IDisposable disposable) + { + disposable.Dispose(); + } + else + { + var closeMethod = RegistryKeyType.GetMethod("Close"); + closeMethod?.Invoke(key, null); + } + } + catch + { + // ignored + } + } + + // ReSharper disable once InconsistentNaming + public static string[]? GetHKLMSubKeyNames(string parentKeyPath) + { + if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null) + return null; + + try + { + object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002); + object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256); + + object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 }); + if (baseKey == null) return null; + + object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { parentKeyPath }); + if (key == null) + { + CloseKey(baseKey); + return null; + } + + string[]? subkeyNames = GetSubKeyNamesMethod.Invoke(key, null) as string[]; + CloseKey(key); + CloseKey(baseKey); + return subkeyNames; + } + catch + { + return null; + } + } + + // ReSharper disable once InconsistentNaming + public static string? GetHKLMValue(string keyPath, string valueName) + { + if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null) + return null; + + try + { + object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002); + object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256); + + object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 }); + if (baseKey == null) return null; + + object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { keyPath }); + if (key == null) + { + CloseKey(baseKey); + return null; + } + + string? value = GetValueMethod.Invoke(key, new object[] { valueName }) as string; + CloseKey(key); + CloseKey(baseKey); + return value; + } + catch + { + return null; + } + } +} \ No newline at end of file diff --git a/Util/OSNotification.cs b/Util/OSNotification.cs deleted file mode 100644 index 346ad37..0000000 --- a/Util/OSNotification.cs +++ /dev/null @@ -1,578 +0,0 @@ -using System; -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Threading; -using MelonLoader; - -namespace ADOFAIRenderer.Util -{ - /// - /// Sends native desktop notifications on Windows, macOS, and Linux. - /// - /// - Windows: Uses Win32 Shell_NotifyIcon (system tray balloon) via P/Invoke. - /// No PowerShell, no UWP, no WinRT dependencies. - /// - /// - /// - 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. - /// Returns true if the process launched successfully, false otherwise. - /// - 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 - } -} diff --git a/Util/RegistryUtil.cs b/Util/RegistryUtil.cs deleted file mode 100644 index 1b0f201..0000000 --- a/Util/RegistryUtil.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System; - -namespace ADOFAIRenderer.Util -{ - internal static class RegistryUtil - { - private static readonly Type? RegistryKeyType; - private static readonly Type? RegistryHiveType; - private static readonly Type? RegistryViewType; - private static readonly System.Reflection.MethodInfo? OpenBaseKeyMethod; - private static readonly System.Reflection.MethodInfo? OpenSubKeyMethod; - private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod; - private static readonly System.Reflection.MethodInfo? GetValueMethod; - - static RegistryUtil() - { - try - { - RegistryKeyType = Type.GetType("Microsoft.Win32.RegistryKey"); - RegistryHiveType = Type.GetType("Microsoft.Win32.RegistryHive"); - RegistryViewType = Type.GetType("Microsoft.Win32.RegistryView"); - - if (RegistryKeyType == null || RegistryHiveType == null || RegistryViewType == null) - { - var mscorlib = typeof(object).Assembly; - RegistryKeyType = mscorlib.GetType("Microsoft.Win32.RegistryKey"); - RegistryHiveType = mscorlib.GetType("Microsoft.Win32.RegistryHive"); - RegistryViewType = mscorlib.GetType("Microsoft.Win32.RegistryView"); - } - - if (RegistryKeyType != null) - { - OpenBaseKeyMethod = RegistryKeyType.GetMethod("OpenBaseKey", new[] { RegistryHiveType!, RegistryViewType! }); - OpenSubKeyMethod = RegistryKeyType.GetMethod("OpenSubKey", new[] { typeof(string) }); - GetSubKeyNamesMethod = RegistryKeyType.GetMethod("GetSubKeyNames"); - GetValueMethod = RegistryKeyType.GetMethod("GetValue", new[] { typeof(string) }); - } - } - catch - { - // ignored - } - } - - private static void CloseKey(object? key) - { - if (key == null || RegistryKeyType == null) return; - try - { - if (key is IDisposable disposable) - { - disposable.Dispose(); - } - else - { - var closeMethod = RegistryKeyType.GetMethod("Close"); - closeMethod?.Invoke(key, null); - } - } - catch - { - // ignored - } - } - - // ReSharper disable once InconsistentNaming - public static string[]? GetHKLMSubKeyNames(string parentKeyPath) - { - if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null) - return null; - - try - { - object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002); - object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256); - - object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 }); - if (baseKey == null) return null; - - object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { parentKeyPath }); - if (key == null) - { - CloseKey(baseKey); - return null; - } - - string[]? subkeyNames = GetSubKeyNamesMethod.Invoke(key, null) as string[]; - CloseKey(key); - CloseKey(baseKey); - return subkeyNames; - } - catch - { - return null; - } - } - - // ReSharper disable once InconsistentNaming - public static string? GetHKLMValue(string keyPath, string valueName) - { - if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null) - return null; - - try - { - object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002); - object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256); - - object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 }); - if (baseKey == null) return null; - - object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { keyPath }); - if (key == null) - { - CloseKey(baseKey); - return null; - } - - string? value = GetValueMethod.Invoke(key, new object[] { valueName }) as string; - CloseKey(key); - CloseKey(baseKey); - return value; - } - catch - { - return null; - } - } - } -}