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();
+ }
+ }
+}