diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs index 9618f52..8200297 100644 --- a/Core/RenderManager.cs +++ b/Core/RenderManager.cs @@ -475,7 +475,7 @@ namespace ADOFAIRenderer.Core var renderFPS = Configuration.RenderFPS; var videoFPS = Configuration.VideoFPS; - var selectedCodec = Configuration.VideoConfig?.Codec ?? Codec.H264; + var selectedCodec = Configuration.VideoConfig.Codec; var ext = GetOutputExtension(selectedCodec); var startTime = DateTime.Now; @@ -493,7 +493,7 @@ namespace ADOFAIRenderer.Core Height = Configuration.RenderHeight, FPS = effectiveVideoFps, Codec = selectedCodec, - Mode = Configuration.VideoConfig!.RateControlMode, + Mode = Configuration.VideoConfig.RateControlMode, QualityValue = Configuration.VideoConfig.QP, TargetBitrate = Configuration.VideoConfig.Bitrate, MaxBitrate = Configuration.VideoConfig.MaxBitrate, @@ -754,11 +754,11 @@ namespace ADOFAIRenderer.Core private IEnumerator StopRenderCoroutine() { yield return new WaitUntil(() => endSuccessful); - var file = !string.IsNullOrEmpty(_currentOutPath) ? Path.GetFileName(_currentOutPath) : "동영상"; + var fileString = !string.IsNullOrEmpty(_currentOutPath) ? $"'{Path.GetFileName(_currentOutPath)}'이(가)" : "동영상이"; if (!Application.isFocused) { string renderFolder = Path.Combine(MelonEnvironment.GameRootDirectory, "Render"); - OSNotification.Show("렌더링 완료!", $"'{file}'이(가) 성공적으로 저장되었습니다.", renderFolder); + OSNotification.Show("렌더링 완료!", $"{fileString} 성공적으로 저장되었습니다.", renderFolder); } EndRender(); } diff --git a/Util/OSNotification.cs b/Util/OSNotification.cs index 69543a5..a556081 100644 --- a/Util/OSNotification.cs +++ b/Util/OSNotification.cs @@ -1,31 +1,49 @@ 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. + /// + /// public static class OSNotification { /// - /// Sends a native desktop notification on Windows, macOS, or Linux. - /// If a folder path is provided, it attempts to add an action button to open the folder on Windows and Linux. + /// 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)) { - ShowWindowsNotification(title, message, folderPath); + WindowsNotification.Send(title, message, folderPath); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - ShowMacNotification(title, message); + MacNotification.Send(title, message); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { - ShowLinuxNotification(title, message, folderPath); + LinuxNotification.Send(title, message, folderPath); } } catch (Exception ex) @@ -34,105 +52,405 @@ namespace ADOFAIRenderer.Util } } - private static void ShowWindowsNotification(string title, string message, string? folderPath) + #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 { - // XML-escape the title and message to prevent XML syntax errors (e.g. from '&', '<', '>', '"', '\'') - string xmlTitle = System.Security.SecurityElement.Escape(title)!; - string xmlMessage = System.Security.SecurityElement.Escape(message)!; - - string toastXml; - string psScript; - - if (!string.IsNullOrEmpty(folderPath)) + public static void Send(string title, string message, string? folderPath) { - var nonNullFolder = folderPath; - var escapedFolder = nonNullFolder.Replace("'", "''"); - - // UWP Toast XML using protocol activation. - // Clicking the body (launch) or the button (arguments) will invoke the custom 'adofai-renderer://' protocol. - toastXml = $@" - - - - {xmlTitle} - {xmlMessage} - - - - - - -".Replace("\r\n", "").Replace("\n", "").Trim(); - - // Build a PowerShell script that registers a custom protocol 'adofai-renderer' in HKCU (no admin rights needed), - // mapping it to invoke PowerShell and open the folder, then sends the notification and exits immediately. - psScript = - $"$protocolPath = 'HKCU:\\Software\\Classes\\adofai-renderer';\n" + - $"if (-not (Test-Path $protocolPath)) {{\n" + - $" $null = New-Item -Path $protocolPath -Force;\n" + - $" $null = New-ItemProperty -Path $protocolPath -Name 'URL Protocol' -Value '' -PropertyType String -Force;\n" + - $"}}\n" + - $"Set-Item -Path $protocolPath -Value 'URL:adofai-renderer Protocol' | Out-Null;\n" + - $"$cmdPath = \"$protocolPath\\shell\\open\\command\";\n" + - $"if (-not (Test-Path $cmdPath)) {{ $null = New-Item -Path $cmdPath -Force; }}\n" + - $"$command = 'powershell.exe -NoProfile -NonInteractive -WindowStyle Hidden -Command \"Invoke-Item ''{escapedFolder}''\"';\n" + - $"Set-Item -Path $cmdPath -Value $command | Out-Null;\n" + - $"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, 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 - { - // Simple UWP Toast XML without actions. - toastXml = $@" - - - - {xmlTitle} - {xmlMessage} - - - -".Replace("\r\n", "").Replace("\n", "").Trim(); - - psScript = - $"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, 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))"; + 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(); } - // Encode the script to UTF-16LE Base64 and run it via PowerShell -EncodedCommand. - // This guarantees that any special characters (double quotes, backslashes, ampersands, etc.) in the title/message - // do not break the command-line parsing or cause syntax errors. - byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript); - string base64 = Convert.ToBase64String(bytes); + #region Toast via PowerShell - RunProcess("powershell.exe", $"-NoProfile -NonInteractive -EncodedCommand {base64}"); - } - - private static void ShowMacNotification(string title, string message) - { - string escapedTitle = title.Replace("\"", "\\\""); - string escapedMessage = message.Replace("\"", "\\\""); - - string args = $"-e \"display notification \\\"{escapedMessage}\\\" with title \\\"{escapedTitle}\\\"\""; - RunProcess("osascript", args); - } - - private static void ShowLinuxNotification(string title, string message, string? folderPath) - { - var escapedTitle = title.Replace("\"", "\\\""); - var escapedMessage = message.Replace("\"", "\\\""); - - if (!string.IsNullOrEmpty(folderPath)) + /// + /// 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) { - var nonNullFolder = folderPath; - // Linux notify-send supports action buttons since v1.2. - // It prints the action name to standard output when clicked. - var args = $"-a \"ADOFAI Renderer\" --action=\"open_folder=렌더 폴더 열기\" \"{escapedTitle}\" \"{escapedMessage}\""; - + 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 실패 (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 + + /// + /// 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 @@ -140,50 +458,121 @@ namespace ADOFAIRenderer.Util var startInfo = new ProcessStartInfo { FileName = "notify-send", - Arguments = args, 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(); - // If user clicked the button, open the folder using default file manager + process.WaitForExit(); + if (output == "open_folder") { - RunProcess("xdg-open", $"\"{nonNullFolder.Replace("\"", "\\\"")}\""); + 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); } }); } - else + + private static bool TryNotifySend(string title, string message) { - var args = $"-a \"ADOFAI Renderer\" \"{escapedTitle}\" \"{escapedMessage}\""; - RunProcess("notify-send", args); + 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}"); + } } } - private static void RunProcess(string fileName, string arguments) - { - var startInfo = new ProcessStartInfo - { - FileName = fileName, - Arguments = arguments, - CreateNoWindow = true, // CREATE_NO_WINDOW flag - UseShellExecute = false, // Direct execution - WindowStyle = ProcessWindowStyle.Hidden - }; + #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 } }