diff --git a/UnityCtl.Cli/DialogCommands.cs b/UnityCtl.Cli/DialogCommands.cs new file mode 100644 index 0000000..48f580b --- /dev/null +++ b/UnityCtl.Cli/DialogCommands.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Linq; +using UnityCtl.Protocol; + +namespace UnityCtl.Cli; + +public static class DialogCommands +{ + public static Command CreateCommand() + { + var dialogCommand = new Command("dialog", "Detect and dismiss Unity Editor popup dialogs"); + + // dialog list + var listCommand = new Command("list", "List detected popup dialogs"); + listCommand.SetHandler((InvocationContext context) => + { + var projectPath = ContextHelper.GetProjectPath(context); + var json = ContextHelper.GetJson(context); + + var dialogs = FindDialogs(context, projectPath); + if (dialogs == null) return; // error already printed + + if (json) + { + var infos = dialogs.Select(d => new DialogInfo + { + Title = d.Title, + Buttons = d.Buttons.Select(b => b.Text).ToArray(), + Description = d.Description, + Progress = d.Progress + }).ToArray(); + + Console.WriteLine(JsonHelper.Serialize(infos)); + } + else + { + if (dialogs.Count == 0) + { + Console.WriteLine("No popup dialogs detected."); + } + else + { + Console.WriteLine($"Detected {dialogs.Count} dialog(s):"); + Console.WriteLine(); + foreach (var dialog in dialogs) + { + Console.Write($" \"{dialog.Title}\""); + if (dialog.Buttons.Count > 0) + { + var buttonLabels = dialog.Buttons.Select(b => $"[{b.Text}]"); + Console.Write($" {string.Join(" ", buttonLabels)}"); + } + if (dialog.Progress.HasValue) + { + var pct = (int)(dialog.Progress.Value * 100); + Console.Write($" ({pct}%)"); + } + if (dialog.Description != null) + Console.Write($" - {dialog.Description}"); + Console.WriteLine(); + } + } + } + }); + + // dialog dismiss + var dismissCommand = new Command("dismiss", "Dismiss a popup dialog by clicking a button"); + + var buttonOption = new Option( + "--button", + "Button text to click (case-insensitive). If not specified, clicks the first button."); + + dismissCommand.AddOption(buttonOption); + dismissCommand.SetHandler((InvocationContext context) => + { + var projectPath = ContextHelper.GetProjectPath(context); + var json = ContextHelper.GetJson(context); + var buttonText = context.ParseResult.GetValueForOption(buttonOption); + + var dialogs = FindDialogs(context, projectPath); + if (dialogs == null) return; + + if (dialogs.Count == 0) + { + if (json) + { + Console.WriteLine(JsonHelper.Serialize(new { success = false, error = "No popup dialogs detected" })); + } + else + { + Console.Error.WriteLine("No popup dialogs detected."); + } + context.ExitCode = 1; + return; + } + + var dialog = dialogs[0]; + if (dialog.Buttons.Count == 0) + { + if (json) + { + Console.WriteLine(JsonHelper.Serialize(new { success = false, error = "Dialog has no buttons" })); + } + else + { + Console.Error.WriteLine($"Dialog \"{dialog.Title}\" has no detectable buttons."); + } + context.ExitCode = 1; + return; + } + + // Resolve which button to click + var targetButton = buttonText ?? dialog.Buttons[0].Text; + + var clicked = DialogDetector.ClickButton(dialog, targetButton); + if (clicked) + { + if (json) + { + Console.WriteLine(JsonHelper.Serialize(new + { + success = true, + dialog = dialog.Title, + button = targetButton + })); + } + else + { + Console.WriteLine($"Clicked \"{targetButton}\" on \"{dialog.Title}\""); + } + } + else + { + if (json) + { + Console.WriteLine(JsonHelper.Serialize(new + { + success = false, + error = $"Button \"{targetButton}\" not found", + availableButtons = dialog.Buttons.Select(b => b.Text).ToArray() + })); + } + else + { + Console.Error.WriteLine($"Button \"{targetButton}\" not found on \"{dialog.Title}\"."); + Console.Error.Write(" Available: "); + Console.Error.WriteLine(string.Join(", ", dialog.Buttons.Select(b => b.Text))); + } + context.ExitCode = 1; + } + }); + + dialogCommand.AddCommand(listCommand); + dialogCommand.AddCommand(dismissCommand); + return dialogCommand; + } + + private static List? FindDialogs(InvocationContext context, string? projectPath) + { + var projectRoot = projectPath != null + ? System.IO.Path.GetFullPath(projectPath) + : ProjectLocator.FindProjectRoot(); + + if (projectRoot == null) + { + Console.Error.WriteLine("Error: Not in a Unity project."); + Console.Error.WriteLine(" Use --project to specify project root"); + context.ExitCode = 1; + return null; + } + + var unityProcess = EditorCommands.FindUnityProcessForProject(projectRoot); + if (unityProcess == null) + { + Console.Error.WriteLine("Error: No Unity Editor found running for this project."); + context.ExitCode = 1; + return null; + } + + return DialogDetector.DetectDialogs(unityProcess.Id); + } +} diff --git a/UnityCtl.Cli/DialogDetector.cs b/UnityCtl.Cli/DialogDetector.cs new file mode 100644 index 0000000..3b83fc9 --- /dev/null +++ b/UnityCtl.Cli/DialogDetector.cs @@ -0,0 +1,780 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; + +namespace UnityCtl.Cli; + +internal class DetectedButton +{ + public required string Text { get; init; } + public required object NativeHandle { get; init; } +} + +internal class DetectedDialog +{ + public required string Title { get; init; } + public required List Buttons { get; init; } + public required object NativeHandle { get; init; } + + /// Platform-specific context needed for clicking buttons (e.g., macOS process name). + public string? ProcessContext { get; init; } + + /// Description text from static labels (e.g., progress bar description). + public string? Description { get; init; } + + /// Progress value 0.0-1.0 if this dialog contains a progress bar, null otherwise. + public float? Progress { get; init; } +} + +internal static class DialogDetector +{ + public static List DetectDialogs(int processId) + { + try + { + if (OperatingSystem.IsWindows()) + return DetectDialogsWindows(processId); + if (OperatingSystem.IsMacOS()) + return DetectDialogsMacOS(processId); + if (OperatingSystem.IsLinux()) + return DetectDialogsLinux(processId); + } + catch + { + // Best-effort — never fail the parent command + } + + return []; + } + + public static bool ClickButton(DetectedDialog dialog, string buttonText) + { + try + { + if (OperatingSystem.IsWindows()) + return ClickButtonWindows(dialog, buttonText); + if (OperatingSystem.IsMacOS()) + return ClickButtonMacOS(dialog, buttonText); + if (OperatingSystem.IsLinux()) + return ClickButtonLinux(dialog, buttonText); + } + catch + { + // Best-effort + } + + return false; + } + + // ─── Windows ──────────────────────────────────────────────────────── + + [SupportedOSPlatform("windows")] + private static List DetectDialogsWindows(int processId) + { + var dialogs = new List(); + + Win32.EnumWindows((hWnd, _) => + { + // Check if window belongs to our process + Win32.GetWindowThreadProcessId(hWnd, out var windowPid); + if (windowPid != processId) + return true; // continue + + // Must be visible + if (!Win32.IsWindowVisible(hWnd)) + return true; + + var classNameBuf = new StringBuilder(256); + Win32.GetClassName(hWnd, classNameBuf, classNameBuf.Capacity); + var className = classNameBuf.ToString(); + + // Match dialog windows (#32770) and Unity splash/loading windows + var isDialog = className == "#32770"; + var isSplash = className == "UnitySplashWindow"; + if (!isDialog && !isSplash) + return true; + + // Get title + var titleBuf = new StringBuilder(256); + Win32.GetWindowText(hWnd, titleBuf, titleBuf.Capacity); + var title = titleBuf.ToString(); + + // Enumerate child controls — buttons, progress bars, static text + var buttons = new List(); + float? progress = null; + string? description = null; + + Win32.EnumChildWindows(hWnd, (childHwnd, __) => + { + var childClassBuf = new StringBuilder(256); + Win32.GetClassName(childHwnd, childClassBuf, childClassBuf.Capacity); + var childClass = childClassBuf.ToString(); + + if (childClass == "Button") + { + var btnTextBuf = new StringBuilder(256); + Win32.GetWindowText(childHwnd, btnTextBuf, btnTextBuf.Capacity); + var btnText = btnTextBuf.ToString(); + // Strip Win32 accelerator prefix (&) — e.g. "&OK" → "OK" + btnText = btnText.Replace("&", ""); + if (!string.IsNullOrWhiteSpace(btnText)) + { + buttons.Add(new DetectedButton + { + Text = btnText, + NativeHandle = childHwnd + }); + } + } + else if (childClass == "msctls_progress32") + { + // Read progress position and range + var pos = (int)Win32.SendMessage(childHwnd, Win32.PBM_GETPOS, IntPtr.Zero, IntPtr.Zero); + var rangeHigh = (int)Win32.SendMessage(childHwnd, Win32.PBM_GETRANGE, IntPtr.Zero, IntPtr.Zero); + if (rangeHigh <= 0) + rangeHigh = 100; // default range + progress = Math.Clamp((float)pos / rangeHigh, 0f, 1f); + } + else if (childClass == "Static") + { + var textBuf = new StringBuilder(512); + Win32.GetWindowText(childHwnd, textBuf, textBuf.Capacity); + var text = textBuf.ToString(); + if (!string.IsNullOrWhiteSpace(text)) + { + // Keep the longest static text as the description + if (description == null || text.Length > description.Length) + description = text; + } + } + + return true; + }, IntPtr.Zero); + + dialogs.Add(new DetectedDialog + { + Title = title, + Buttons = buttons, + NativeHandle = hWnd, + Description = description, + Progress = progress + }); + + return true; // continue looking for more dialogs + }, IntPtr.Zero); + + return dialogs; + } + + [SupportedOSPlatform("windows")] + private static bool ClickButtonWindows(DetectedDialog dialog, string buttonText) + { + foreach (var button in dialog.Buttons) + { + if (string.Equals(button.Text, buttonText, StringComparison.OrdinalIgnoreCase)) + { + var btnHwnd = (IntPtr)button.NativeHandle; + var dlgHwnd = (IntPtr)dialog.NativeHandle; + + // Bring the dialog to the foreground so it processes messages + Win32.SetForegroundWindow(dlgHwnd); + + // Get the button's control ID and send WM_COMMAND to the dialog. + // This is more reliable than BM_CLICK across processes because + // it mimics exactly what the dialog's message loop expects. + var ctrlId = Win32.GetDlgCtrlID(btnHwnd); + if (ctrlId != 0) + { + // WM_COMMAND: HIWORD(wParam)=BN_CLICKED(0), LOWORD(wParam)=controlId, lParam=button hwnd + var wParam = (IntPtr)ctrlId; // BN_CLICKED is 0, so high word is 0 + Win32.SendMessage(dlgHwnd, Win32.WM_COMMAND, wParam, btnHwnd); + return true; + } + + // Fallback: direct BM_CLICK on the button + Win32.SendMessage(btnHwnd, Win32.BM_CLICK, IntPtr.Zero, IntPtr.Zero); + return true; + } + } + return false; + } + + // ─── macOS ────────────────────────────────────────────────────────── + + [SupportedOSPlatform("macos")] + private static List DetectDialogsMacOS(int processId) + { + // AppleScript to enumerate windows with buttons, progress indicators, and static text. + // Returns lines like: + // DIALOG:windowTitle|btn1,btn2|progressValue|description + // Progress value is empty if no progress indicator found. + var script = $@" +tell application ""System Events"" + set unityProcs to every process whose unix id is {processId} + if (count of unityProcs) is 0 then return """" + set unityProc to item 1 of unityProcs + set procName to name of unityProc + set output to """" + repeat with w in (every window of unityProc) + set wName to name of w + set btns to """" + set prog to """" + set desc to """" + set hasContent to false + try + repeat with b in (every button of w) + set bName to name of b + if bName is not missing value then + if btns is not """" then set btns to btns & "","" + set btns to btns & bName + set hasContent to true + end if + end repeat + end try + try + set progIndicators to every progress indicator of w + if (count of progIndicators) > 0 then + set progVal to value of item 1 of progIndicators + if progVal is not missing value then + set prog to (progVal as text) + set hasContent to true + end if + end if + end try + try + set staticTexts to every static text of w + if (count of staticTexts) > 0 then + repeat with st in staticTexts + set stVal to value of st + if stVal is not missing value and stVal is not """" then + if (count of stVal) > (count of desc) then + set desc to stVal + end if + end if + end repeat + end if + end try + if hasContent then + set output to output & ""DIALOG:"" & wName & ""|"" & btns & ""|"" & prog & ""|"" & desc & linefeed + end if + end repeat + return ""PROC:"" & procName & linefeed & output +end tell"; + + var result = RunProcess("osascript", $"-e '{script}'", script); + if (string.IsNullOrWhiteSpace(result)) + return []; + + var dialogs = new List(); + string? processName = null; + foreach (var line in result.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + if (line.StartsWith("PROC:")) + { + processName = line.Substring(5).Trim(); + continue; + } + + if (!line.StartsWith("DIALOG:")) + continue; + + var payload = line.Substring(7); + // Format: title|buttons|progress|description + var parts = payload.Split('|'); + if (parts.Length < 1) continue; + + var title = parts[0]; + + var buttons = new List(); + if (parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1])) + { + foreach (var name in parts[1].Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = name.Trim(); + if (trimmed.Length > 0) + { + buttons.Add(new DetectedButton + { + Text = trimmed, + NativeHandle = trimmed // macOS uses button name for clicking + }); + } + } + } + + float? progress = null; + if (parts.Length > 2 && !string.IsNullOrWhiteSpace(parts[2])) + { + if (float.TryParse(parts[2], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var progVal)) + { + // AppleScript progress indicator value is typically 0-100 + if (progVal > 1f) progVal /= 100f; + progress = Math.Clamp(progVal, 0f, 1f); + } + } + + string? description = null; + if (parts.Length > 3 && !string.IsNullOrWhiteSpace(parts[3])) + { + description = parts[3].Trim(); + } + + dialogs.Add(new DetectedDialog + { + Title = title, + Buttons = buttons, + NativeHandle = title, // window name for clicking + ProcessContext = processName, + Description = description, + Progress = progress + }); + } + + return dialogs; + } + + [SupportedOSPlatform("macos")] + private static bool ClickButtonMacOS(DetectedDialog dialog, string buttonText) + { + var processName = dialog.ProcessContext; + if (processName == null) + return false; + + // Find the matching button (case-insensitive) + DetectedButton? target = null; + foreach (var button in dialog.Buttons) + { + if (string.Equals(button.Text, buttonText, StringComparison.OrdinalIgnoreCase)) + { + target = button; + break; + } + } + if (target == null) + return false; + + var windowName = (string)dialog.NativeHandle; + var escapedProcess = processName.Replace("\"", "\\\""); + var escapedWindow = windowName.Replace("\"", "\\\""); + var escapedButton = target.Text.Replace("\"", "\\\""); + + var script = $@" +tell application ""System Events"" + tell process ""{escapedProcess}"" + click button ""{escapedButton}"" of window ""{escapedWindow}"" + end tell +end tell"; + + var result = RunProcess("osascript", $"-e '{script}'", script); + return result != null; + } + + // ─── Linux ────────────────────────────────────────────────────────── + + [SupportedOSPlatform("linux")] + private static List DetectDialogsLinux(int processId) + { + var dialogs = new List(); + + // Try xdotool first for window discovery + var windowIds = RunProcess("xdotool", $"search --pid {processId}"); + if (windowIds == null) + { + // xdotool not available (possibly Wayland) — try pyatspi only + return DetectDialogsLinuxPyatspi(processId); + } + + foreach (var line in windowIds.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var windowId = line.Trim(); + if (windowId.Length == 0) continue; + + var windowName = RunProcess("xdotool", $"getwindowname {windowId}"); + if (windowName == null) continue; + windowName = windowName.Trim(); + if (windowName.Length == 0) continue; + + // Try to get buttons, progress, and description via pyatspi + var windowInfo = GetWindowInfoPyatspi(processId, windowName); + + dialogs.Add(new DetectedDialog + { + Title = windowName, + Buttons = windowInfo.Buttons, + NativeHandle = windowId, + Description = windowInfo.Description, + Progress = windowInfo.Progress + }); + } + + // Filter to only windows that have buttons or progress (likely dialogs/progress bars) + // If pyatspi isn't available, keep all windows (titles are still useful) + var hasPyatspi = false; + foreach (var d in dialogs) + { + if (d.Buttons.Count > 0 || d.Progress.HasValue) { hasPyatspi = true; break; } + } + + if (hasPyatspi) + { + dialogs.RemoveAll(d => d.Buttons.Count == 0 && !d.Progress.HasValue); + } + + return dialogs; + } + + [SupportedOSPlatform("linux")] + private static List DetectDialogsLinuxPyatspi(int processId) + { + // Full pyatspi-based detection for Wayland or when xdotool is unavailable. + // Looks for dialogs, alerts, and frames/windows that contain progress bars. + // Output format: DIALOG:title|btn1,btn2|progressValue|description + var pyScript = $@" +import pyatspi, sys + +def scan_children(frame): + btns, prog, desc = [], '', '' + for i in range(frame.childCount): + try: + child = frame.getChildAtIndex(i) + if not child: continue + role = child.getRole() + if role == pyatspi.ROLE_PUSH_BUTTON: + btns.append(child.name or '') + elif role == pyatspi.ROLE_PROGRESS_BAR: + try: + val = child.queryValue() + cur = val.currentValue + mx = val.maximumValue + if mx > 0: + prog = str(cur / mx) + else: + prog = str(cur) + except: pass + elif role == pyatspi.ROLE_LABEL: + name = child.name or '' + if len(name) > len(desc): + desc = name + except: pass + return btns, prog, desc + +for app in pyatspi.Registry.getDesktop(0): + try: + if app.get_process_id() != {processId}: continue + except: continue + for frame in app: + try: + role = frame.getRole() + title = frame.name or '' + btns, prog, desc = scan_children(frame) + is_dialog = role in (pyatspi.ROLE_DIALOG, pyatspi.ROLE_ALERT) + has_progress = prog != '' + if is_dialog or has_progress: + print(f'DIALOG:{{title}}|{{"","".join(btns)}}|{{prog}}|{{desc}}') + except: pass +"; + + var result = RunProcess("python3", "-c -", pyScript); + if (result == null) + return []; + + return ParseLinuxDialogOutput(result); + } + + [SupportedOSPlatform("linux")] + private static List ParseLinuxDialogOutput(string output) + { + var dialogs = new List(); + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + if (!line.StartsWith("DIALOG:")) continue; + + var payload = line.Substring(7); + // Format: title|buttons|progress|description + var parts = payload.Split('|'); + if (parts.Length < 1) continue; + + var title = parts[0]; + + var buttons = new List(); + if (parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1])) + { + foreach (var name in parts[1].Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = name.Trim(); + if (trimmed.Length > 0) + { + buttons.Add(new DetectedButton + { + Text = trimmed, + NativeHandle = trimmed + }); + } + } + } + + float? progress = null; + if (parts.Length > 2 && !string.IsNullOrWhiteSpace(parts[2])) + { + if (float.TryParse(parts[2], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var progVal)) + { + progress = Math.Clamp(progVal, 0f, 1f); + } + } + + string? description = null; + if (parts.Length > 3 && !string.IsNullOrWhiteSpace(parts[3])) + { + description = parts[3].Trim(); + } + + dialogs.Add(new DetectedDialog + { + Title = title, + Buttons = buttons, + NativeHandle = title, + Description = description, + Progress = progress + }); + } + + return dialogs; + } + + [SupportedOSPlatform("linux")] + private static (List Buttons, float? Progress, string? Description) GetWindowInfoPyatspi(int processId, string windowName) + { + var escapedName = windowName.Replace("'", "\\'"); + // Output format: BUTTONS:btn1,btn2\nPROGRESS:value\nDESCRIPTION:text + var pyScript = $@" +import pyatspi, sys +for app in pyatspi.Registry.getDesktop(0): + try: + if app.get_process_id() != {processId}: continue + except: continue + for frame in app: + try: + if frame.name == '{escapedName}': + btns, desc = [], '' + for i in range(frame.childCount): + child = frame.getChildAtIndex(i) + if not child: continue + role = child.getRole() + if role == pyatspi.ROLE_PUSH_BUTTON: + btns.append(child.name or '') + elif role == pyatspi.ROLE_PROGRESS_BAR: + try: + val = child.queryValue() + cur = val.currentValue + mx = val.maximumValue + if mx > 0: + print(f'PROGRESS:{{cur / mx}}') + else: + print(f'PROGRESS:{{cur}}') + except: pass + elif role == pyatspi.ROLE_LABEL: + name = child.name or '' + if len(name) > len(desc): + desc = name + if btns: + print(f'BUTTONS:{{"","".join(btns)}}') + if desc: + print(f'DESCRIPTION:{{desc}}') + except: pass +"; + + var result = RunProcess("python3", "-c -", pyScript); + var buttons = new List(); + float? progress = null; + string? description = null; + + if (result == null) + return (buttons, progress, description); + + foreach (var line in result.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + if (line.StartsWith("BUTTONS:")) + { + foreach (var name in line.Substring(8).Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = name.Trim(); + if (trimmed.Length > 0) + { + buttons.Add(new DetectedButton + { + Text = trimmed, + NativeHandle = trimmed + }); + } + } + } + else if (line.StartsWith("PROGRESS:")) + { + if (float.TryParse(line.Substring(9), System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var progVal)) + { + progress = Math.Clamp(progVal, 0f, 1f); + } + } + else if (line.StartsWith("DESCRIPTION:")) + { + description = line.Substring(12).Trim(); + } + } + + return (buttons, progress, description); + } + + [SupportedOSPlatform("linux")] + private static bool ClickButtonLinux(DetectedDialog dialog, string buttonText) + { + var escapedWindow = dialog.Title.Replace("'", "\\'"); + var escapedButton = buttonText.Replace("'", "\\'"); + + var pyScript = $@" +import pyatspi, sys +for app in pyatspi.Registry.getDesktop(0): + for frame in app: + try: + if frame.name == '{escapedWindow}': + for i in range(frame.childCount): + child = frame.getChildAtIndex(i) + if child and child.getRole() == pyatspi.ROLE_PUSH_BUTTON: + if child.name and child.name.lower() == '{escapedButton.ToLowerInvariant()}': + action = child.queryAction() + action.doAction(0) + print('OK') + sys.exit(0) + except: pass +print('NOTFOUND') +"; + + var result = RunProcess("python3", "-c -", pyScript); + return result != null && result.Trim().StartsWith("OK"); + } + + // ─── Helpers ──────────────────────────────────────────────────────── + + /// + /// Run a process and return stdout, or null on failure. + /// When stdinContent is provided and args ends with "-", pipes content to stdin. + /// Kills the process after 5 seconds to avoid hanging on permission prompts (e.g. macOS Accessibility). + /// + private static string? RunProcess(string fileName, string arguments, string? stdinContent = null) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + if (stdinContent != null) + { + startInfo.RedirectStandardInput = true; + // For osascript, pass script via stdin instead of args (avoids shell quoting issues) + if (fileName == "osascript") + { + // No arguments — script comes from stdin + } + else if (arguments.EndsWith("-")) + { + startInfo.Arguments = arguments.Substring(0, arguments.Length - 1).TrimEnd(); + } + else + { + startInfo.Arguments = arguments; + } + } + else + { + startInfo.Arguments = arguments; + } + + using var process = Process.Start(startInfo); + if (process == null) return null; + + if (stdinContent != null) + { + process.StandardInput.Write(stdinContent); + process.StandardInput.Close(); + } + + // Use async read + WaitForExit with timeout to avoid blocking forever + // when the child process hangs (e.g. macOS Accessibility permission prompt) + var outputTask = process.StandardOutput.ReadToEndAsync(); + if (!process.WaitForExit(5_000)) + { + try { process.Kill(); } catch { } + return null; + } + + // Process exited within timeout — get the output + outputTask.Wait(1_000); + var output = outputTask.IsCompleted ? outputTask.Result : null; + + return process.ExitCode == 0 ? output : null; + } + catch + { + return null; + } + } + + // ─── Win32 P/Invoke ───────────────────────────────────────────────── + + [SupportedOSPlatform("windows")] + private static class Win32 + { + public const uint BM_CLICK = 0x00F5; + public const uint WM_COMMAND = 0x0111; + public const uint PBM_GETPOS = 0x0408; + public const uint PBM_GETRANGE = 0x0407; + + public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool EnumChildWindows(IntPtr hWndParent, EnumWindowsProc lpEnumFunc, IntPtr lParam); + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + public static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out int lpdwProcessId); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool IsWindowVisible(IntPtr hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern int GetDlgCtrlID(IntPtr hWnd); + } +} diff --git a/UnityCtl.Cli/EditorCommands.cs b/UnityCtl.Cli/EditorCommands.cs index e9c93da..a182f2f 100644 --- a/UnityCtl.Cli/EditorCommands.cs +++ b/UnityCtl.Cli/EditorCommands.cs @@ -128,10 +128,7 @@ private static async Task RunEditorAsync(InvocationContext context, string? proj } } - // 5. Suppress the "Enter Safe Mode?" dialog so Unity doesn't block on compilation errors - SuppressSafeModeDialog(); - - // 6. Launch Unity + // 5. Launch Unity Console.WriteLine("Launching Unity Editor..."); var startInfo = new ProcessStartInfo @@ -235,7 +232,7 @@ private static Task StopEditorAsync(InvocationContext context, string? projectPa /// /// Find the Unity process running for a specific project by checking command line arguments. /// - private static Process? FindUnityProcessForProject(string projectRoot) + internal static Process? FindUnityProcessForProject(string projectRoot) { // Normalize the project path for comparison var normalizedProjectRoot = System.IO.Path.GetFullPath(projectRoot).TrimEnd('\\', '/'); @@ -280,7 +277,7 @@ private static Task StopEditorAsync(InvocationContext context, string? projectPa /// /// Extract the project path from Unity's command line arguments. /// - private static string? ExtractProjectPath(string commandLine) + internal static string? ExtractProjectPath(string commandLine) { // Look for -projectPath "path" or -projectPath path var marker = "-projectPath"; @@ -308,7 +305,7 @@ private static Task StopEditorAsync(InvocationContext context, string? projectPa /// /// Get the command line for a process. Platform-specific implementation. /// - private static string? GetProcessCommandLine(Process process) + internal static string? GetProcessCommandLine(Process process) { if (OperatingSystem.IsWindows()) { @@ -386,90 +383,4 @@ private static Task StopEditorAsync(InvocationContext context, string? projectPa return null; } - /// - /// Suppress Unity's "Enter Safe Mode?" dialog so compilation errors don't block automated workflows. - /// Sets the EnterSafeModeDialog EditorPref to false in platform-specific storage. - /// - private static void SuppressSafeModeDialog() - { - try - { - if (OperatingSystem.IsWindows()) - SuppressSafeModeDialogWindows(); - else if (OperatingSystem.IsMacOS()) - SuppressSafeModeDialogMacOS(); - else if (OperatingSystem.IsLinux()) - SuppressSafeModeDialogLinux(); - } - catch - { - // Best-effort — don't fail the launch if we can't set the pref - } - } - - [System.Runtime.Versioning.SupportedOSPlatform("windows")] - private static void SuppressSafeModeDialogWindows() - { - using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey( - @"Software\Unity Technologies\Unity Editor 5.x", writable: true); - key?.SetValue("EnterSafeModeDialog", 0, Microsoft.Win32.RegistryValueKind.DWord); - } - - private static void SuppressSafeModeDialogMacOS() - { - var startInfo = new ProcessStartInfo - { - FileName = "defaults", - Arguments = "write com.unity3d.UnityEditor5.x EnterSafeModeDialog -bool false", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = Process.Start(startInfo); - process?.WaitForExit(5000); - } - - private static void SuppressSafeModeDialogLinux() - { - var prefsPath = System.IO.Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "unity3d", "prefs"); - - const string key = "EnterSafeModeDialog"; - const string desiredLine = "EnterSafeModeDialog=False"; - - if (System.IO.File.Exists(prefsPath)) - { - var lines = System.IO.File.ReadAllLines(prefsPath); - var found = false; - for (var i = 0; i < lines.Length; i++) - { - if (lines[i].StartsWith(key + "=", StringComparison.Ordinal) || - lines[i].StartsWith(key + " =", StringComparison.Ordinal)) - { - lines[i] = desiredLine; - found = true; - break; - } - } - - if (found) - { - System.IO.File.WriteAllLines(prefsPath, lines); - } - else - { - System.IO.File.AppendAllText(prefsPath, Environment.NewLine + desiredLine + Environment.NewLine); - } - } - else - { - var dir = System.IO.Path.GetDirectoryName(prefsPath); - if (dir != null) - System.IO.Directory.CreateDirectory(dir); - System.IO.File.WriteAllText(prefsPath, desiredLine + Environment.NewLine); - } - } } diff --git a/UnityCtl.Cli/Program.cs b/UnityCtl.Cli/Program.cs index 3ff9d0a..df5545f 100644 --- a/UnityCtl.Cli/Program.cs +++ b/UnityCtl.Cli/Program.cs @@ -40,6 +40,7 @@ // Add subcommands - Bridge & Editor rootCommand.AddCommand(BridgeCommands.CreateCommand()); rootCommand.AddCommand(EditorCommands.CreateCommand()); +rootCommand.AddCommand(DialogCommands.CreateCommand()); // Add subcommands - Unity Operations rootCommand.AddCommand(SceneCommands.CreateCommand()); diff --git a/UnityCtl.Cli/StatusCommand.cs b/UnityCtl.Cli/StatusCommand.cs index 1b04e42..f45093d 100644 --- a/UnityCtl.Cli/StatusCommand.cs +++ b/UnityCtl.Cli/StatusCommand.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.CommandLine.Invocation; using System.Diagnostics; +using System.Linq; using System.Threading.Tasks; using UnityCtl.Protocol; @@ -93,9 +94,31 @@ public static Command CreateCommand() UnityConnectedToBridge = unityConnected }; + // Detect popup dialogs (including progress bars) if Unity is running + DialogInfo[]? detectedDialogs = null; + if (result.UnityEditorRunning) + { + var unityProcess = EditorCommands.FindUnityProcessForProject( + System.IO.Path.GetFullPath(projectRoot)); + if (unityProcess != null) + { + var dialogs = DialogDetector.DetectDialogs(unityProcess.Id); + if (dialogs.Count > 0) + { + detectedDialogs = dialogs.Select(d => new DialogInfo + { + Title = d.Title, + Buttons = d.Buttons.Select(b => b.Text).ToArray(), + Description = d.Description, + Progress = d.Progress + }).ToArray(); + } + } + } + if (json) { - // Include version info in JSON output + // Include version info and dialogs in JSON output var jsonResult = new { result.ProjectPath, @@ -107,6 +130,7 @@ public static Command CreateCommand() result.BridgePort, result.BridgePid, result.UnityConnectedToBridge, + Dialogs = detectedDialogs, Versions = health != null ? new { Cli = VersionInfo.Version, @@ -118,14 +142,14 @@ public static Command CreateCommand() } else { - PrintHumanReadableStatus(result, unityStatus.Message, health); + PrintHumanReadableStatus(result, unityStatus.Message, health, detectedDialogs); } }); return statusCommand; } - private static void PrintHumanReadableStatus(ProjectStatusResult status, string? unityStatusMessage, HealthResult? health) + private static void PrintHumanReadableStatus(ProjectStatusResult status, string? unityStatusMessage, HealthResult? health, DialogInfo[]? dialogs = null) { Console.WriteLine("Project Status:"); Console.WriteLine($" Path: {status.ProjectPath}"); @@ -192,6 +216,35 @@ private static void PrintHumanReadableStatus(ProjectStatusResult status, string? } } + // Popup dialogs (including progress bars) + if (dialogs != null && dialogs.Length > 0) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write($"Popups: [!] {dialogs.Length} dialog{(dialogs.Length > 1 ? "s" : "")} detected"); + Console.ResetColor(); + Console.WriteLine(); + foreach (var dialog in dialogs) + { + Console.Write($" \"{dialog.Title}\""); + if (dialog.Buttons.Length > 0) + { + var buttonLabels = dialog.Buttons.Select(b => $"[{b}]"); + Console.Write($" {string.Join(" ", buttonLabels)}"); + } + if (dialog.Progress.HasValue) + { + var pct = (int)(dialog.Progress.Value * 100); + Console.Write($" ({pct}%)"); + } + if (dialog.Description != null) + Console.Write($" - {dialog.Description}"); + Console.WriteLine(); + } + if (dialogs.Any(d => d.Buttons.Length > 0)) + Console.WriteLine(" Use 'unityctl dialog dismiss' to dismiss"); + } + // Version information if (health != null) { diff --git a/UnityCtl.Protocol/DTOs.cs b/UnityCtl.Protocol/DTOs.cs index 5a1e630..abbc27b 100644 --- a/UnityCtl.Protocol/DTOs.cs +++ b/UnityCtl.Protocol/DTOs.cs @@ -315,6 +315,21 @@ public class ProjectStatusResult public required bool UnityConnectedToBridge { get; init; } } +public class DialogInfo +{ + [JsonProperty("title")] + public required string Title { get; init; } + + [JsonProperty("buttons")] + public required string[] Buttons { get; init; } + + [JsonProperty("description")] + public string? Description { get; init; } + + [JsonProperty("progress")] + public float? Progress { get; init; } +} + /// /// Unified log entry that can come from either editor.log or console (Debug.Log) /// diff --git a/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll b/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll index a4a9c1d..bbd2d67 100644 Binary files a/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll and b/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll differ diff --git a/docs/dialog-detection.md b/docs/dialog-detection.md new file mode 100644 index 0000000..042571d --- /dev/null +++ b/docs/dialog-detection.md @@ -0,0 +1,261 @@ +# Dialog Detection + +Unity Editor shows modal dialog popups and progress bars in various situations — compilation errors triggering Safe Mode, license issues, import failures, asset imports, builds, editor startup, etc. These block Unity's main thread, which means: + +- The UnityCtl plugin cannot process any RPC commands +- `unityctl wait` hangs indefinitely +- The bridge reports Unity as connected, but all commands time out (504) + +The `dialog` command detects these popups from the CLI side (no bridge or plugin needed) and can dismiss them programmatically. Progress bars (e.g., asset imports, builds, editor startup) are treated as dialogs — they may have no buttons but include progress percentage and description text. + +## Commands + +### `unityctl dialog list` + +Lists all detected popup dialogs for the running Unity Editor. + +``` +$ unityctl dialog list +Detected 1 dialog(s): + + "Enter Safe Mode?" [Enter Safe Mode] [Ignore] [Quit] +``` + +``` +$ unityctl dialog list --json +[{"title":"Enter Safe Mode?","buttons":["Enter Safe Mode","Ignore","Quit"]}] +``` + +Progress bars show additional fields: + +``` +$ unityctl dialog list +Detected 1 dialog(s): + + "Building Player (busy for 35s)..." [Cancel] [Skip Transcoding] (100%) - Write asset files +``` + +``` +$ unityctl dialog list --json +[{"title":"Building Player (busy for 35s)...","buttons":["Cancel","Skip Transcoding"],"description":"Write asset files","progress":1.0}] +``` + +During editor startup, the loading splash is detected as a button-less dialog with progress: + +``` +$ unityctl dialog list +Detected 1 dialog(s): + + "Opening project..." (46%) +``` + +### `unityctl dialog dismiss` + +Dismisses the first detected dialog by clicking a button. + +``` +$ unityctl dialog dismiss --button "Ignore" +Clicked "Ignore" on "Enter Safe Mode?" +``` + +If `--button` is omitted, clicks the first button. Button matching is case-insensitive. + +### `unityctl status` + +The `status` command also reports detected dialogs: + +``` +Popups: [!] 1 dialog detected + "Enter Safe Mode?" [Enter Safe Mode] [Ignore] [Quit] + Use 'unityctl dialog dismiss' to dismiss +``` + +Progress bars also appear in status output: + +``` +Popups: [!] 1 dialog detected + "Compiling Scripts (busy for 23s)..." [Cancel] [Skip Transcoding] (10%) - Compiling C# (UnityEngine.UI) + Use 'unityctl dialog dismiss' to dismiss +``` + +## Architecture + +Dialog detection is purely client-side — it runs in the CLI process using OS-level window APIs. This is necessary because dialogs block Unity's main thread, so the plugin can't respond to bridge commands. + +The flow is: +1. Find the Unity process for the project (`FindUnityProcessForProject`) +2. Use platform-specific APIs to enumerate that process's dialog and splash windows +3. Extract window titles, button labels, progress bar values, and description text +4. Optionally click a button to dismiss + +## Platform Support + +### Windows (fully working) + +Uses Win32 P/Invoke APIs to detect and interact with dialogs: + +- **Detection**: `EnumWindows` to find visible windows belonging to the Unity PID, filtering for the `#32770` dialog window class and `UnitySplashWindow` (Unity's startup/loading window) +- **Button enumeration**: `EnumChildWindows` to find child controls with the `Button` class, reading text via `GetWindowText` +- **Button text cleanup**: Win32 button text includes `&` accelerator prefixes (e.g., `&OK`, `&Cancel`) — these are stripped automatically +- **Progress bars**: Detects `msctls_progress32` child controls, reads position via `SendMessage(PBM_GETPOS)` and range via `SendMessage(PBM_GETRANGE)`, normalizes to 0.0-1.0 +- **Description text**: Reads `Static` child controls for description labels (e.g., "Compiling C# (UnityEngine.UI)") +- **Clicking**: `SendMessage(WM_COMMAND)` to the parent dialog with the button's control ID (via `GetDlgCtrlID`). `SetForegroundWindow` is called first to ensure the dialog processes messages + +**Window classes detected:** + +| Class | What | Examples | +|-------|------|----------| +| `#32770` | Standard Win32 dialog | Safe Mode prompt, `EditorUtility.DisplayDialog`, `DisplayProgressBar`, `DisplayCancelableProgressBar` | +| `UnitySplashWindow` | Unity startup/loading splash | "Opening project..." with progress bar during editor launch | + +Both classes can contain `msctls_progress32` progress bar controls and `Static` text labels. The `UnitySplashWindow` typically has no buttons (or an empty-text button), while `#32770` dialogs from `DisplayCancelableProgressBar` include Cancel and Skip Transcoding buttons. + +**Why `SendMessage(WM_COMMAND)` instead of `PostMessage(BM_CLICK)`**: During testing, `PostMessage(BM_CLICK)` proved unreliable across processes — the dismiss would report success but the dialog wouldn't actually close. `SendMessage(WM_COMMAND)` mimics exactly what the dialog's own message loop does when a button is clicked, making it reliable cross-process. Falls back to `SendMessage(BM_CLICK)` if control ID lookup fails. + +**No special permissions required.** Works from any terminal, SSH session, or CI environment. + +### macOS (limited) + +Uses AppleScript via `osascript` to interact with System Events: + +- **Detection**: Enumerates windows of the Unity process by PID, looking for windows that have buttons or progress indicators +- **Progress bars**: Reads `progress indicator` elements and their `value` property, normalizes to 0.0-1.0 +- **Description text**: Reads `static text` elements, keeps the longest as the description +- **Clicking**: Uses `click button "" of window "" of process "<name>"` +- **Script delivery**: Scripts are piped via stdin to avoid shell quoting issues + +**Accessibility permission required.** macOS requires the calling process to have Accessibility access (System Settings > Privacy & Security > Accessibility). This means: + +- From **Terminal.app** or **iTerm2**: Works after a one-time permission grant for the terminal app +- From **SSH sessions**: Does not work because `sshd` (`/usr/libexec/sshd-keygen-wrapper`) lacks Accessibility permission, and the permission prompt cannot be shown remotely. The `osascript` call hangs indefinitely until killed by the 5-second subprocess timeout. Detection returns an empty list gracefully (no crash, no hang). + +To enable over SSH, you would need to physically grant Accessibility access to the SSH daemon on the Mac. In managed environments, Accessibility permissions can be pre-provisioned via MDM/PPPC profiles. + +**Alternative APIs investigated and ruled out:** + +| API | Issue | +|-----|-------| +| AXUIElement (Swift) | Same Accessibility permission requirement (it's what osascript uses under the hood) | +| JXA (`osascript -l JavaScript`) | Same osascript binary, same permission model | +| CGWindowListCopyWindowInfo | Can count windows (no permission), but can't read titles (needs Screen Recording) or buttons | +| NSWorkspace / AppKit | Can't enumerate windows of other processes; not daemon-safe | +| lsappinfo | App-level only, no window information | +| CGSConnection | Private API, fragile, no button-level interaction | + +The fundamental macOS limitation is that **any API that can read window content or interact with UI elements requires Accessibility permissions**. There is no workaround. The current approach is the right one — it works from local terminals and degrades gracefully over SSH. + +### Linux (best-effort) + +Uses a combination of tools: + +- **Window listing**: `xdotool search --pid <PID>` (X11 only) + `xdotool getwindowname` for titles +- **Button enumeration**: `python3` with `pyatspi` (AT-SPI2 accessibility framework) to enumerate `ROLE_PUSH_BUTTON` controls +- **Progress bars**: Reads `ROLE_PROGRESS_BAR` elements via `queryValue()`, normalizes `currentValue / maximumValue` to 0.0-1.0 +- **Description text**: Reads `ROLE_LABEL` elements, keeps the longest as the description +- **Role matching**: pyatspi-only path detects `ROLE_DIALOG`, `ROLE_ALERT`, and any window with a progress bar (regardless of role) +- **Clicking**: `pyatspi` `doAction(0)` on the target button + +**Fallback behavior**: +- If `xdotool` is not installed or on Wayland: falls back to pyatspi-only detection +- If `pyatspi` is not installed: returns dialogs with titles but empty button lists (titles are still useful for status reporting) +- If neither is available: returns empty list silently + +**No special permissions required** on most Linux desktop environments. + +## Unity Safe Mode + +### What it is + +When Unity detects compilation errors on startup, it can enter **Safe Mode** — a restricted state that prevents most editor operations. The `EnterSafeModeDialog` EditorPref controls the behavior: + +| Value | Behavior | +|-------|----------| +| **1** (default) | Shows "Enter Safe Mode?" dialog with 3 buttons: **Enter Safe Mode**, **Ignore**, **Quit** | +| **0** | Skips the dialog and **automatically enters safe mode** | + +### Why suppressing the dialog is counterproductive + +Setting `EnterSafeModeDialog = 0` causes Unity to enter safe mode automatically without showing the dialog. This is **bad for automated workflows** because: + +1. In safe mode, Unity restricts domain reloading and assembly loading +2. The UnityCtl plugin cannot load in safe mode +3. The bridge never gets a WebSocket connection from Unity +4. All RPC commands hang or fail, `unityctl wait` times out + +### Correct approach for automation + +**Let the dialog appear** (keep `EnterSafeModeDialog = 1`, which is the default) and have the agent click **"Ignore"** via `unityctl dialog dismiss --button "Ignore"`. This keeps Unity in normal mode where the plugin connects and RPC works, even with compilation errors present. + +The typical agent workflow for handling startup with broken scripts: + +```bash +# Launch Unity +unityctl editor run --project ./my-project + +# Wait for connection — if this times out, check for dialogs +unityctl wait --timeout 60 || { + # Check if a dialog is blocking + unityctl dialog list + # Dismiss the safe mode dialog by clicking "Ignore" + unityctl dialog dismiss --button "Ignore" + # Wait again now that the dialog is gone + unityctl wait --timeout 60 +} +``` + +### Registry location (Windows) + +The `EnterSafeModeDialog` preference is stored in the Windows registry: + +- Key: `HKCU\Software\Unity Technologies\Unity Editor 5.x` +- Value name: `EnterSafeModeDialog_h2431637559` (the suffix is a DJB2 hash) + +## Triggering Test Dialogs + +`EditorUtility.DisplayDialog` blocks Unity's main thread. To spawn a dialog for testing without blocking the script eval RPC call, schedule it for the next editor frame: + +```csharp +// Via unityctl script eval +unityctl script eval "EditorApplication.CallbackFunction show = null; \ + show = () => { EditorApplication.update -= show; \ + EditorUtility.DisplayDialog(\"Test\", \"Hello\", \"OK\", \"Cancel\"); }; \ + EditorApplication.update += show; return \"scheduled\";" +``` + +Key details: +- Must use `EditorApplication.CallbackFunction` as the delegate type (not `System.Action` — Unity's delegate type is different) +- Must use `EditorApplication.update`, not `EditorApplication.delayCall` (delayCall does not work reliably for this) +- The delegate must unregister itself on first invocation to avoid repeated dialog spawning + +## Implementation Details + +### `DialogDetector.cs` + +Single static class with platform dispatch (`DetectDialogs`, `ClickButton`). Best-effort on all platforms — if detection fails (missing tools, no permissions), returns empty list silently. Never fails the parent command. Returns `DetectedDialog` objects with optional `Description` (from static text labels) and `Progress` (0.0-1.0 from progress bar controls). + +### `DialogCommands.cs` + +Two subcommands under `unityctl dialog`: +- `list` — enumerates dialogs, outputs human-readable or JSON. Shows progress percentage and description when present. +- `dismiss --button <text>` — clicks the named button (case-insensitive), defaults to first button + +### `StatusCommand.cs` + +If Unity is running, calls `DialogDetector.DetectDialogs` and includes any detected dialogs (including progress bars) in both human-readable and JSON output. The "Use 'unityctl dialog dismiss' to dismiss" hint is only shown when at least one dialog has buttons. + +### `DialogInfo` (Protocol DTO) + +```json +{ + "title": "Building Player (busy for 35s)...", + "buttons": ["Cancel", "Skip Transcoding"], + "description": "Write asset files", + "progress": 1.0 +} +``` + +The `description` and `progress` fields are nullable — omitted from JSON when not present (e.g., for plain button dialogs like Safe Mode prompts). + +### Subprocess timeout + +All external process calls (`osascript`, `xdotool`, `python3`) use a 5-second timeout with `process.Kill()` on expiry. This prevents the CLI from hanging when macOS Accessibility prompts block `osascript` indefinitely.