From a72c210075e296b734022d925044d2aefbb8571e Mon Sep 17 00:00:00 2001 From: Martin Vagstad Date: Tue, 3 Mar 2026 21:31:20 +0100 Subject: [PATCH 1/5] Add dialog detection and remove safe mode suppression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add cross-platform detection and dismissal of Unity Editor modal dialogs (Windows Win32 API, macOS AppleScript, Linux xdotool/pyatspi). Dialogs block Unity's main thread, causing all RPC commands to hang with no feedback — this feature lets agents detect and dismiss them. - New `dialog list` and `dialog dismiss` commands - `status` command now surfaces detected popups with a yellow warning - DialogInfo DTO for JSON output - EditorCommands process helpers made internal for reuse Remove SuppressSafeModeDialog — it silently forced Unity into Safe Mode on compilation errors, which prevents the plugin from connecting. The dialog system is strictly better: agents can detect the "Enter Safe Mode?" dialog and click "Ignore" to stay in normal mode. --- UnityCtl.Cli/DialogCommands.cs | 176 ++++++ UnityCtl.Cli/DialogDetector.cs | 578 ++++++++++++++++++ UnityCtl.Cli/EditorCommands.cs | 97 +-- UnityCtl.Cli/Program.cs | 1 + UnityCtl.Cli/StatusCommand.cs | 49 +- UnityCtl.Protocol/DTOs.cs | 9 + .../Plugins/UnityCtl.Protocol.dll | Bin 35328 -> 35840 bytes 7 files changed, 814 insertions(+), 96 deletions(-) create mode 100644 UnityCtl.Cli/DialogCommands.cs create mode 100644 UnityCtl.Cli/DialogDetector.cs diff --git a/UnityCtl.Cli/DialogCommands.cs b/UnityCtl.Cli/DialogCommands.cs new file mode 100644 index 0000000..b23308c --- /dev/null +++ b/UnityCtl.Cli/DialogCommands.cs @@ -0,0 +1,176 @@ +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() + }).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)}"); + } + 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..25a976c --- /dev/null +++ b/UnityCtl.Cli/DialogDetector.cs @@ -0,0 +1,578 @@ +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; } +} + +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; + + // Check for dialog class (#32770) + var classNameBuf = new StringBuilder(256); + Win32.GetClassName(hWnd, classNameBuf, classNameBuf.Capacity); + if (classNameBuf.ToString() != "#32770") + return true; + + // Get title + var titleBuf = new StringBuilder(256); + Win32.GetWindowText(hWnd, titleBuf, titleBuf.Capacity); + var title = titleBuf.ToString(); + + // Enumerate child buttons + var buttons = new List(); + Win32.EnumChildWindows(hWnd, (childHwnd, __) => + { + var childClassBuf = new StringBuilder(256); + Win32.GetClassName(childHwnd, childClassBuf, childClassBuf.Capacity); + if (childClassBuf.ToString() == "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 + }); + } + } + return true; + }, IntPtr.Zero); + + dialogs.Add(new DetectedDialog + { + Title = title, + Buttons = buttons, + NativeHandle = hWnd + }); + + 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 and buttons for a process by PID. + // Returns lines like: DIALOG:windowTitle|btn1,btn2,btn3 + 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 """" + 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 + end if + end repeat + end try + if btns is not """" then + set output to output & ""DIALOG:"" & wName & ""|"" & btns & 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); + var pipeIdx = payload.IndexOf('|'); + if (pipeIdx < 0) continue; + + var title = payload.Substring(0, pipeIdx); + var buttonNames = payload.Substring(pipeIdx + 1).Split(',', StringSplitOptions.RemoveEmptyEntries); + + var buttons = new List(); + foreach (var name in buttonNames) + { + var trimmed = name.Trim(); + if (trimmed.Length > 0) + { + buttons.Add(new DetectedButton + { + Text = trimmed, + NativeHandle = trimmed // macOS uses button name for clicking + }); + } + } + + dialogs.Add(new DetectedDialog + { + Title = title, + Buttons = buttons, + NativeHandle = title, // window name for clicking + ProcessContext = processName + }); + } + + 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 via pyatspi + var buttons = GetButtonsPyatspi(processId, windowName); + + dialogs.Add(new DetectedDialog + { + Title = windowName, + Buttons = buttons, + NativeHandle = windowId + }); + } + + // Filter to only windows that have buttons (likely dialogs) + // 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) { hasPyatspi = true; break; } + } + + if (hasPyatspi) + { + dialogs.RemoveAll(d => d.Buttons.Count == 0); + } + + return dialogs; + } + + [SupportedOSPlatform("linux")] + private static List DetectDialogsLinuxPyatspi(int processId) + { + // Full pyatspi-based detection for Wayland or when xdotool is unavailable + 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: + role = frame.getRole() + if role not in (pyatspi.ROLE_DIALOG, pyatspi.ROLE_ALERT): + continue + title = frame.name or '' + btns = [] + for i in range(frame.childCount): + child = frame.getChildAtIndex(i) + if child and child.getRole() == pyatspi.ROLE_PUSH_BUTTON: + btns.append(child.name or '') + print(f'DIALOG:{{title}}|{{"","".join(btns)}}') + except: pass +"; + + var result = RunProcess("python3", "-c -", pyScript); + if (result == null) + return []; + + var dialogs = new List(); + foreach (var line in result.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + if (!line.StartsWith("DIALOG:")) continue; + + var payload = line.Substring(7); + var pipeIdx = payload.IndexOf('|'); + if (pipeIdx < 0) continue; + + var title = payload.Substring(0, pipeIdx); + var buttonNames = payload.Substring(pipeIdx + 1).Split(',', StringSplitOptions.RemoveEmptyEntries); + + var buttons = new List(); + foreach (var name in buttonNames) + { + var trimmed = name.Trim(); + if (trimmed.Length > 0) + { + buttons.Add(new DetectedButton + { + Text = trimmed, + NativeHandle = trimmed + }); + } + } + + dialogs.Add(new DetectedDialog + { + Title = title, + Buttons = buttons, + NativeHandle = title + }); + } + + return dialogs; + } + + [SupportedOSPlatform("linux")] + private static List GetButtonsPyatspi(int processId, string windowName) + { + var escapedName = windowName.Replace("'", "\\'"); + 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}': + for i in range(frame.childCount): + child = frame.getChildAtIndex(i) + if child and child.getRole() == pyatspi.ROLE_PUSH_BUTTON: + print(child.name or '') + except: pass +"; + + var result = RunProcess("python3", "-c -", pyScript); + var buttons = new List(); + if (result == null) + return buttons; + + foreach (var line in result.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var name = line.Trim(); + if (name.Length > 0) + { + buttons.Add(new DetectedButton + { + Text = name, + NativeHandle = name + }); + } + } + + return buttons; + } + + [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. + /// + 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(); + } + + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(10_000); + + 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 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..40c12ad 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,29 @@ public static Command CreateCommand() UnityConnectedToBridge = unityConnected }; + // Detect popup dialogs 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() + }).ToArray(); + } + } + } + if (json) { - // Include version info in JSON output + // Include version info and dialogs in JSON output var jsonResult = new { result.ProjectPath, @@ -107,6 +128,7 @@ public static Command CreateCommand() result.BridgePort, result.BridgePid, result.UnityConnectedToBridge, + Dialogs = detectedDialogs, Versions = health != null ? new { Cli = VersionInfo.Version, @@ -118,14 +140,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 +214,27 @@ private static void PrintHumanReadableStatus(ProjectStatusResult status, string? } } + // Popup dialogs + 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)}"); + } + Console.WriteLine(); + } + 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..557ee37 100644 --- a/UnityCtl.Protocol/DTOs.cs +++ b/UnityCtl.Protocol/DTOs.cs @@ -315,6 +315,15 @@ 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; } +} + /// /// 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 a4a9c1d397a4f5293d2478dbb98857e13c537020..ff4c5505664c9c8158bf3878e6c21c447acfd67f 100644 GIT binary patch delta 12220 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 e> Date: Tue, 3 Mar 2026 21:51:13 +0100 Subject: [PATCH 2/5] Add timeout to subprocess calls in DialogDetector osascript on macOS can hang indefinitely if the calling process lacks Accessibility permissions. Use async ReadToEnd + WaitForExit(5s) with kill on timeout to prevent blocking the parent command. --- UnityCtl.Cli/DialogDetector.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/UnityCtl.Cli/DialogDetector.cs b/UnityCtl.Cli/DialogDetector.cs index 25a976c..998c067 100644 --- a/UnityCtl.Cli/DialogDetector.cs +++ b/UnityCtl.Cli/DialogDetector.cs @@ -474,6 +474,7 @@ private static bool ClickButtonLinux(DetectedDialog dialog, string buttonText) /// /// 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) { @@ -519,8 +520,18 @@ private static bool ClickButtonLinux(DetectedDialog dialog, string buttonText) process.StandardInput.Close(); } - var output = process.StandardOutput.ReadToEnd(); - process.WaitForExit(10_000); + // 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; } From 206d8ffdc8c9c99f545b9d628da91c257d38b6ff Mon Sep 17 00:00:00 2001 From: Martin Vagstad Date: Tue, 3 Mar 2026 22:38:55 +0100 Subject: [PATCH 3/5] Add dialog detection documentation Covers platform support (Windows/macOS/Linux), Safe Mode behavior, agent workflow patterns, and implementation details. --- docs/dialog-detection.md | 201 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/dialog-detection.md diff --git a/docs/dialog-detection.md b/docs/dialog-detection.md new file mode 100644 index 0000000..62ae5d9 --- /dev/null +++ b/docs/dialog-detection.md @@ -0,0 +1,201 @@ +# Dialog Detection + +Unity Editor shows modal dialog popups in various situations — compilation errors triggering Safe Mode, license issues, import failures, etc. These dialogs 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. + +## 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"]}] +``` + +### `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 +``` + +## 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 windows +3. Extract window titles and button labels +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 +- **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 +- **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 + +**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 button controls +- **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 within dialog/alert windows +- **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. + +### `DialogCommands.cs` + +Two subcommands under `unityctl dialog`: +- `list` — enumerates dialogs, outputs human-readable or JSON +- `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 in both human-readable and JSON output. + +### 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. From 8ad66535e4467622e7d4a3a03a3cc3dad2847b25 Mon Sep 17 00:00:00 2001 From: Martin Vagstad <martin@dirtybit.no> Date: Wed, 4 Mar 2026 20:04:07 +0100 Subject: [PATCH 4/5] Add progress bar detection to dialog system Extend dialog detection to also detect Unity progress bars and the startup splash window. Progress bars (EditorUtility.DisplayProgressBar, DisplayCancelableProgressBar) and the UnitySplashWindow are now treated as dialogs with optional progress/description fields. On Windows, detects msctls_progress32 child controls (reading position via PBM_GETPOS/PBM_GETRANGE) and Static text labels for descriptions. Also matches UnitySplashWindow class in addition to #32770 dialogs. DialogInfo DTO gains nullable description and progress fields, omitted from JSON when not present for backwards compatibility. --- UnityCtl.Cli/DialogCommands.cs | 11 ++- UnityCtl.Cli/DialogDetector.cs | 51 +++++++++++-- UnityCtl.Cli/StatusCommand.cs | 18 ++++- UnityCtl.Protocol/DTOs.cs | 6 ++ .../Plugins/UnityCtl.Protocol.dll | Bin 35840 -> 37376 bytes docs/dialog-detection.md | 71 ++++++++++++++++-- 6 files changed, 139 insertions(+), 18 deletions(-) diff --git a/UnityCtl.Cli/DialogCommands.cs b/UnityCtl.Cli/DialogCommands.cs index b23308c..48f580b 100644 --- a/UnityCtl.Cli/DialogCommands.cs +++ b/UnityCtl.Cli/DialogCommands.cs @@ -28,7 +28,9 @@ public static Command CreateCommand() var infos = dialogs.Select(d => new DialogInfo { Title = d.Title, - Buttons = d.Buttons.Select(b => b.Text).ToArray() + Buttons = d.Buttons.Select(b => b.Text).ToArray(), + Description = d.Description, + Progress = d.Progress }).ToArray(); Console.WriteLine(JsonHelper.Serialize(infos)); @@ -51,6 +53,13 @@ public static Command CreateCommand() 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(); } } diff --git a/UnityCtl.Cli/DialogDetector.cs b/UnityCtl.Cli/DialogDetector.cs index 998c067..a1416e6 100644 --- a/UnityCtl.Cli/DialogDetector.cs +++ b/UnityCtl.Cli/DialogDetector.cs @@ -21,6 +21,12 @@ internal class DetectedDialog /// <summary>Platform-specific context needed for clicking buttons (e.g., macOS process name).</summary> public string? ProcessContext { get; init; } + + /// <summary>Description text from static labels (e.g., progress bar description).</summary> + public string? Description { get; init; } + + /// <summary>Progress value 0.0-1.0 if this dialog contains a progress bar, null otherwise.</summary> + public float? Progress { get; init; } } internal static class DialogDetector @@ -81,10 +87,14 @@ private static List<DetectedDialog> DetectDialogsWindows(int processId) if (!Win32.IsWindowVisible(hWnd)) return true; - // Check for dialog class (#32770) var classNameBuf = new StringBuilder(256); Win32.GetClassName(hWnd, classNameBuf, classNameBuf.Capacity); - if (classNameBuf.ToString() != "#32770") + 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 @@ -92,13 +102,18 @@ private static List<DetectedDialog> DetectDialogsWindows(int processId) Win32.GetWindowText(hWnd, titleBuf, titleBuf.Capacity); var title = titleBuf.ToString(); - // Enumerate child buttons + // Enumerate child controls — buttons, progress bars, static text var buttons = new List<DetectedButton>(); + float? progress = null; + string? description = null; + Win32.EnumChildWindows(hWnd, (childHwnd, __) => { var childClassBuf = new StringBuilder(256); Win32.GetClassName(childHwnd, childClassBuf, childClassBuf.Capacity); - if (childClassBuf.ToString() == "Button") + var childClass = childClassBuf.ToString(); + + if (childClass == "Button") { var btnTextBuf = new StringBuilder(256); Win32.GetWindowText(childHwnd, btnTextBuf, btnTextBuf.Capacity); @@ -114,6 +129,28 @@ private static List<DetectedDialog> DetectDialogsWindows(int processId) }); } } + 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); @@ -121,7 +158,9 @@ private static List<DetectedDialog> DetectDialogsWindows(int processId) { Title = title, Buttons = buttons, - NativeHandle = hWnd + NativeHandle = hWnd, + Description = description, + Progress = progress }); return true; // continue looking for more dialogs @@ -548,6 +587,8 @@ 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); diff --git a/UnityCtl.Cli/StatusCommand.cs b/UnityCtl.Cli/StatusCommand.cs index 40c12ad..f45093d 100644 --- a/UnityCtl.Cli/StatusCommand.cs +++ b/UnityCtl.Cli/StatusCommand.cs @@ -94,7 +94,7 @@ public static Command CreateCommand() UnityConnectedToBridge = unityConnected }; - // Detect popup dialogs if Unity is running + // Detect popup dialogs (including progress bars) if Unity is running DialogInfo[]? detectedDialogs = null; if (result.UnityEditorRunning) { @@ -108,7 +108,9 @@ public static Command CreateCommand() detectedDialogs = dialogs.Select(d => new DialogInfo { Title = d.Title, - Buttons = d.Buttons.Select(b => b.Text).ToArray() + Buttons = d.Buttons.Select(b => b.Text).ToArray(), + Description = d.Description, + Progress = d.Progress }).ToArray(); } } @@ -214,7 +216,7 @@ private static void PrintHumanReadableStatus(ProjectStatusResult status, string? } } - // Popup dialogs + // Popup dialogs (including progress bars) if (dialogs != null && dialogs.Length > 0) { Console.WriteLine(); @@ -230,9 +232,17 @@ private static void PrintHumanReadableStatus(ProjectStatusResult status, string? 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(); } - Console.WriteLine(" Use 'unityctl dialog dismiss' to dismiss"); + if (dialogs.Any(d => d.Buttons.Length > 0)) + Console.WriteLine(" Use 'unityctl dialog dismiss' to dismiss"); } // Version information diff --git a/UnityCtl.Protocol/DTOs.cs b/UnityCtl.Protocol/DTOs.cs index 557ee37..abbc27b 100644 --- a/UnityCtl.Protocol/DTOs.cs +++ b/UnityCtl.Protocol/DTOs.cs @@ -322,6 +322,12 @@ public class DialogInfo [JsonProperty("buttons")] public required string[] Buttons { get; init; } + + [JsonProperty("description")] + public string? Description { get; init; } + + [JsonProperty("progress")] + public float? Progress { get; init; } } /// <summary> diff --git a/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll b/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll index ff4c5505664c9c8158bf3878e6c21c447acfd67f..bbd2d67f0b629c4d3b1ceb5b11e6e719096326d3 100644 GIT binary patch literal 37376 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%0D%D%bH$$a)R(c<G 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 S%lMi9=dS*rhyVZ8!2bfd{Ghi0 literal 35840 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%0AvD-DHPuAsDDI{4HZ&IRy3l<B>^O*8U{s?qG2 z<mo1pL$7zF!J6pC@f4q^GD>B6qtHaDXM*Utrp{1fxa+Ddrh(#U+B{Xlpn4;GUYYbZ z^T4(7U>~z)l1}^Bn{l9KeF?2FZGzqUw4f>A4s+Z{U(o9@8%<bE`pTo^b&fv0W&}6@ z8hJ>cO&1?|X3;d|wvmUWOoG_xQ<|nl@B{)M%BFwYJ55cD^elUPWp6}tqGOL6bDAq< zcss-NO>|iAS@4V=>)ps^n8uLDZmeX>>lmoUJ8on9z4s8Tsg9IB;%Z{YBQARX&UDS> znCArTSTfH^u^Q2TaI1v<na}XBo^d-4k38bo%8`6#Ix?=bIA;80yevVxj?CK|?DgJ? zF_KuvY44cZoGhiz*XTalm2@y*yz#{cGU(F!%&`d&55U;|ff0(+P+Iibw+vVWJi=fr zq0>Ax$QdRB@arVUe6k)KWowlVkMfyRVTK+4++IsOv8=0WEpBE=cM^Rq8s+V2O@ZH9 z9>&Xx|MU6(v<CFAe+k|(mGsyGa!Y{sw<pTgZWVqm9TMr(ZG!!D`c46LG2W2e0$h&w zGB@I!cD<|v-wIp-T0dXS`t>_{z?@BP^bM`^EuV#N-6LdvmVt5LRj;>J;Mzqm7+ZP$ zfzxSxX5x3N;!SR7&9zbHWh)Kji&;;@se_nOurk+16Fm*mW)aG5{+)ueWz+%iB&0PL zl;%JHcNiew$m?dk@YC})a}qXs>a1spF{IU~5<ENGhN~t$GaEg9Ye78N1LAWs-wyUJ zTD2T|QEL%gAY%@|XBm9y73*G}cVoK1XAR!vcSdSt87wo;7wl;p?8fKUh&O2UqHr`; zyaCU@B<R)2*mGdbYY~nlPQ{OYYn`l-a^$V8pqI}Hd`D1{(TM+io{gwAJ^I1l!9<H= z0|`Id=|yXy7bP`!4|X62;Z)YfJl9*fwxZ_g#jKpEt^Z2i%UFk|K00wsqvz?PrT9zh z4p&yTPCXyvYjCvw@Q0>Z*Ue}xJzMQYi<)!b_fBK#9M$l@{~5$e122pC+qwVu-TjY; J|F>%3e*txli0uFX diff --git a/docs/dialog-detection.md b/docs/dialog-detection.md index 62ae5d9..0c1349c 100644 --- a/docs/dialog-detection.md +++ b/docs/dialog-detection.md @@ -1,12 +1,12 @@ # Dialog Detection -Unity Editor shows modal dialog popups in various situations — compilation errors triggering Safe Mode, license issues, import failures, etc. These dialogs block Unity's main thread, which means: +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. +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 @@ -26,6 +26,29 @@ $ 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. @@ -47,14 +70,22 @@ Popups: [!] 1 dialog detected 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 windows -3. Extract window titles and button labels +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 @@ -63,11 +94,22 @@ The flow is: 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 +- **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. @@ -184,17 +226,30 @@ Key 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. +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 +- `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 in both human-readable and JSON output. +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 From 0e8726448c9a16fa226ceb3d9b968e35f7c7615a Mon Sep 17 00:00:00 2001 From: Martin Vagstad <martin@dirtybit.no> Date: Wed, 4 Mar 2026 20:15:54 +0100 Subject: [PATCH 5/5] Add cross-platform progress bar detection (macOS/Linux) macOS: Read progress indicators and static text via AppleScript System Events API. Windows with buttons or progress indicators are now detected (previously only windows with buttons were included). Linux: Read ROLE_PROGRESS_BAR via pyatspi queryValue() and ROLE_LABEL for descriptions. The pyatspi-only path now detects any window with a progress bar regardless of role (not just ROLE_DIALOG and ROLE_ALERT). GetButtonsPyatspi replaced with GetWindowInfoPyatspi returning buttons, progress, and description as a tuple. --- UnityCtl.Cli/DialogDetector.cs | 262 ++++++++++++++++++++++++++------- docs/dialog-detection.md | 9 +- 2 files changed, 213 insertions(+), 58 deletions(-) diff --git a/UnityCtl.Cli/DialogDetector.cs b/UnityCtl.Cli/DialogDetector.cs index a1416e6..3b83fc9 100644 --- a/UnityCtl.Cli/DialogDetector.cs +++ b/UnityCtl.Cli/DialogDetector.cs @@ -207,8 +207,10 @@ private static bool ClickButtonWindows(DetectedDialog dialog, string buttonText) [SupportedOSPlatform("macos")] private static List<DetectedDialog> DetectDialogsMacOS(int processId) { - // AppleScript to enumerate windows and buttons for a process by PID. - // Returns lines like: DIALOG:windowTitle|btn1,btn2,btn3 + // 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} @@ -219,17 +221,44 @@ 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 - if btns is not """" then - set output to output & ""DIALOG:"" & wName & ""|"" & btns & linefeed + 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 @@ -253,32 +282,55 @@ end repeat continue; var payload = line.Substring(7); - var pipeIdx = payload.IndexOf('|'); - if (pipeIdx < 0) continue; + // Format: title|buttons|progress|description + var parts = payload.Split('|'); + if (parts.Length < 1) continue; - var title = payload.Substring(0, pipeIdx); - var buttonNames = payload.Substring(pipeIdx + 1).Split(',', StringSplitOptions.RemoveEmptyEntries); + var title = parts[0]; var buttons = new List<DetectedButton>(); - foreach (var name in buttonNames) + if (parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1])) { - var trimmed = name.Trim(); - if (trimmed.Length > 0) + foreach (var name in parts[1].Split(',', StringSplitOptions.RemoveEmptyEntries)) { - buttons.Add(new DetectedButton + var trimmed = name.Trim(); + if (trimmed.Length > 0) { - Text = trimmed, - NativeHandle = trimmed // macOS uses button name for clicking - }); + 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 + ProcessContext = processName, + Description = description, + Progress = progress }); } @@ -346,28 +398,30 @@ private static List<DetectedDialog> DetectDialogsLinux(int processId) windowName = windowName.Trim(); if (windowName.Length == 0) continue; - // Try to get buttons via pyatspi - var buttons = GetButtonsPyatspi(processId, windowName); + // Try to get buttons, progress, and description via pyatspi + var windowInfo = GetWindowInfoPyatspi(processId, windowName); dialogs.Add(new DetectedDialog { Title = windowName, - Buttons = buttons, - NativeHandle = windowId + Buttons = windowInfo.Buttons, + NativeHandle = windowId, + Description = windowInfo.Description, + Progress = windowInfo.Progress }); } - // Filter to only windows that have buttons (likely dialogs) + // 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) { hasPyatspi = true; break; } + if (d.Buttons.Count > 0 || d.Progress.HasValue) { hasPyatspi = true; break; } } if (hasPyatspi) { - dialogs.RemoveAll(d => d.Buttons.Count == 0); + dialogs.RemoveAll(d => d.Buttons.Count == 0 && !d.Progress.HasValue); } return dialogs; @@ -376,9 +430,38 @@ private static List<DetectedDialog> DetectDialogsLinux(int processId) [SupportedOSPlatform("linux")] private static List<DetectedDialog> DetectDialogsLinuxPyatspi(int processId) { - // Full pyatspi-based detection for Wayland or when xdotool is unavailable + // 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 @@ -386,15 +469,12 @@ private static List<DetectedDialog> DetectDialogsLinuxPyatspi(int processId) for frame in app: try: role = frame.getRole() - if role not in (pyatspi.ROLE_DIALOG, pyatspi.ROLE_ALERT): - continue title = frame.name or '' - btns = [] - for i in range(frame.childCount): - child = frame.getChildAtIndex(i) - if child and child.getRole() == pyatspi.ROLE_PUSH_BUTTON: - btns.append(child.name or '') - print(f'DIALOG:{{title}}|{{"","".join(btns)}}') + 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 "; @@ -402,37 +482,64 @@ private static List<DetectedDialog> DetectDialogsLinuxPyatspi(int processId) if (result == null) return []; + return ParseLinuxDialogOutput(result); + } + + [SupportedOSPlatform("linux")] + private static List<DetectedDialog> ParseLinuxDialogOutput(string output) + { var dialogs = new List<DetectedDialog>(); - foreach (var line in result.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) { if (!line.StartsWith("DIALOG:")) continue; var payload = line.Substring(7); - var pipeIdx = payload.IndexOf('|'); - if (pipeIdx < 0) continue; + // Format: title|buttons|progress|description + var parts = payload.Split('|'); + if (parts.Length < 1) continue; - var title = payload.Substring(0, pipeIdx); - var buttonNames = payload.Substring(pipeIdx + 1).Split(',', StringSplitOptions.RemoveEmptyEntries); + var title = parts[0]; var buttons = new List<DetectedButton>(); - foreach (var name in buttonNames) + if (parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1])) { - var trimmed = name.Trim(); - if (trimmed.Length > 0) + foreach (var name in parts[1].Split(',', StringSplitOptions.RemoveEmptyEntries)) { - buttons.Add(new DetectedButton + var trimmed = name.Trim(); + if (trimmed.Length > 0) { - Text = trimmed, - NativeHandle = trimmed - }); + 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 + NativeHandle = title, + Description = description, + Progress = progress }); } @@ -440,9 +547,10 @@ private static List<DetectedDialog> DetectDialogsLinuxPyatspi(int processId) } [SupportedOSPlatform("linux")] - private static List<DetectedButton> GetButtonsPyatspi(int processId, string windowName) + private static (List<DetectedButton> 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): @@ -452,32 +560,74 @@ private static List<DetectedButton> GetButtonsPyatspi(int processId, string wind for frame in app: try: if frame.name == '{escapedName}': + btns, desc = [], '' for i in range(frame.childCount): child = frame.getChildAtIndex(i) - if child and child.getRole() == pyatspi.ROLE_PUSH_BUTTON: - print(child.name or '') + 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<DetectedButton>(); + float? progress = null; + string? description = null; + if (result == null) - return buttons; + return (buttons, progress, description); foreach (var line in result.Split('\n', StringSplitOptions.RemoveEmptyEntries)) { - var name = line.Trim(); - if (name.Length > 0) + if (line.StartsWith("BUTTONS:")) { - buttons.Add(new DetectedButton + foreach (var name in line.Substring(8).Split(',', StringSplitOptions.RemoveEmptyEntries)) { - Text = name, - NativeHandle = name - }); + 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; + return (buttons, progress, description); } [SupportedOSPlatform("linux")] diff --git a/docs/dialog-detection.md b/docs/dialog-detection.md index 0c1349c..042571d 100644 --- a/docs/dialog-detection.md +++ b/docs/dialog-detection.md @@ -118,7 +118,9 @@ Both classes can contain `msctls_progress32` progress bar controls and `Static` Uses AppleScript via `osascript` to interact with System Events: -- **Detection**: Enumerates windows of the Unity process by PID, looking for windows that have button controls +- **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 "<name>" of window "<title>" of process "<name>"` - **Script delivery**: Scripts are piped via stdin to avoid shell quoting issues @@ -147,7 +149,10 @@ The fundamental macOS limitation is that **any API that can read window content 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 within dialog/alert windows +- **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**: