diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs index 03e0298..811ee44 100644 --- a/Core/RenderManager.cs +++ b/Core/RenderManager.cs @@ -720,6 +720,12 @@ namespace ADOFAIRenderer.Core private IEnumerator StopRenderCoroutine() { yield return new WaitUntil(() => endSuccessful); + var file = !string.IsNullOrEmpty(_currentOutPath) ? Path.GetFileName(_currentOutPath) : "동영상"; + if (!Application.isFocused) + { + string renderFolder = Path.Combine(MelonEnvironment.GameRootDirectory, "Render"); + OSNotification.Show("렌더링 완료!", $"'{file}'이(가) 성공적으로 저장되었습니다.", renderFolder); + } EndRender(); } diff --git a/Util/OSNotification.cs b/Util/OSNotification.cs new file mode 100644 index 0000000..69543a5 --- /dev/null +++ b/Util/OSNotification.cs @@ -0,0 +1,189 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using MelonLoader; + +namespace ADOFAIRenderer.Util +{ + 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. + /// + public static void Show(string title, string message, string? folderPath = null) + { + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + ShowWindowsNotification(title, message, folderPath); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + ShowMacNotification(title, message); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + ShowLinuxNotification(title, message, folderPath); + } + } + catch (Exception ex) + { + MelonLogger.Warning($"[OSNotification] 알림을 보내지 못했습니다: {ex.Message}"); + } + } + + private static void ShowWindowsNotification(string title, string message, string? folderPath) + { + // 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)) + { + 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))"; + } + + // 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); + + 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)) + { + 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}\""; + + System.Threading.Tasks.Task.Run(() => + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = "notify-send", + Arguments = args, + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + WindowStyle = ProcessWindowStyle.Hidden + }; + + 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 + if (output == "open_folder") + { + RunProcess("xdg-open", $"\"{nonNullFolder.Replace("\"", "\\\"")}\""); + } + } + catch (Exception ex) + { + MelonLogger.Warning($"[OSNotification] 리눅스 알림 처리 실패: {ex.Message}"); + } + }); + } + else + { + var args = $"-a \"ADOFAI Renderer\" \"{escapedTitle}\" \"{escapedMessage}\""; + RunProcess("notify-send", args); + } + } + + 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 + }; + + using var process = new Process(); + process.StartInfo = startInfo; + process.Start(); + } + } +}