Skip to content

Commit 8ad6653

Browse files
committed
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.
1 parent 206d8ff commit 8ad6653

6 files changed

Lines changed: 139 additions & 18 deletions

File tree

UnityCtl.Cli/DialogCommands.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ public static Command CreateCommand()
2828
var infos = dialogs.Select(d => new DialogInfo
2929
{
3030
Title = d.Title,
31-
Buttons = d.Buttons.Select(b => b.Text).ToArray()
31+
Buttons = d.Buttons.Select(b => b.Text).ToArray(),
32+
Description = d.Description,
33+
Progress = d.Progress
3234
}).ToArray();
3335

3436
Console.WriteLine(JsonHelper.Serialize(infos));
@@ -51,6 +53,13 @@ public static Command CreateCommand()
5153
var buttonLabels = dialog.Buttons.Select(b => $"[{b.Text}]");
5254
Console.Write($" {string.Join(" ", buttonLabels)}");
5355
}
56+
if (dialog.Progress.HasValue)
57+
{
58+
var pct = (int)(dialog.Progress.Value * 100);
59+
Console.Write($" ({pct}%)");
60+
}
61+
if (dialog.Description != null)
62+
Console.Write($" - {dialog.Description}");
5463
Console.WriteLine();
5564
}
5665
}

UnityCtl.Cli/DialogDetector.cs

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ internal class DetectedDialog
2121

2222
/// <summary>Platform-specific context needed for clicking buttons (e.g., macOS process name).</summary>
2323
public string? ProcessContext { get; init; }
24+
25+
/// <summary>Description text from static labels (e.g., progress bar description).</summary>
26+
public string? Description { get; init; }
27+
28+
/// <summary>Progress value 0.0-1.0 if this dialog contains a progress bar, null otherwise.</summary>
29+
public float? Progress { get; init; }
2430
}
2531

2632
internal static class DialogDetector
@@ -81,24 +87,33 @@ private static List<DetectedDialog> DetectDialogsWindows(int processId)
8187
if (!Win32.IsWindowVisible(hWnd))
8288
return true;
8389

84-
// Check for dialog class (#32770)
8590
var classNameBuf = new StringBuilder(256);
8691
Win32.GetClassName(hWnd, classNameBuf, classNameBuf.Capacity);
87-
if (classNameBuf.ToString() != "#32770")
92+
var className = classNameBuf.ToString();
93+
94+
// Match dialog windows (#32770) and Unity splash/loading windows
95+
var isDialog = className == "#32770";
96+
var isSplash = className == "UnitySplashWindow";
97+
if (!isDialog && !isSplash)
8898
return true;
8999

90100
// Get title
91101
var titleBuf = new StringBuilder(256);
92102
Win32.GetWindowText(hWnd, titleBuf, titleBuf.Capacity);
93103
var title = titleBuf.ToString();
94104

95-
// Enumerate child buttons
105+
// Enumerate child controls — buttons, progress bars, static text
96106
var buttons = new List<DetectedButton>();
107+
float? progress = null;
108+
string? description = null;
109+
97110
Win32.EnumChildWindows(hWnd, (childHwnd, __) =>
98111
{
99112
var childClassBuf = new StringBuilder(256);
100113
Win32.GetClassName(childHwnd, childClassBuf, childClassBuf.Capacity);
101-
if (childClassBuf.ToString() == "Button")
114+
var childClass = childClassBuf.ToString();
115+
116+
if (childClass == "Button")
102117
{
103118
var btnTextBuf = new StringBuilder(256);
104119
Win32.GetWindowText(childHwnd, btnTextBuf, btnTextBuf.Capacity);
@@ -114,14 +129,38 @@ private static List<DetectedDialog> DetectDialogsWindows(int processId)
114129
});
115130
}
116131
}
132+
else if (childClass == "msctls_progress32")
133+
{
134+
// Read progress position and range
135+
var pos = (int)Win32.SendMessage(childHwnd, Win32.PBM_GETPOS, IntPtr.Zero, IntPtr.Zero);
136+
var rangeHigh = (int)Win32.SendMessage(childHwnd, Win32.PBM_GETRANGE, IntPtr.Zero, IntPtr.Zero);
137+
if (rangeHigh <= 0)
138+
rangeHigh = 100; // default range
139+
progress = Math.Clamp((float)pos / rangeHigh, 0f, 1f);
140+
}
141+
else if (childClass == "Static")
142+
{
143+
var textBuf = new StringBuilder(512);
144+
Win32.GetWindowText(childHwnd, textBuf, textBuf.Capacity);
145+
var text = textBuf.ToString();
146+
if (!string.IsNullOrWhiteSpace(text))
147+
{
148+
// Keep the longest static text as the description
149+
if (description == null || text.Length > description.Length)
150+
description = text;
151+
}
152+
}
153+
117154
return true;
118155
}, IntPtr.Zero);
119156

120157
dialogs.Add(new DetectedDialog
121158
{
122159
Title = title,
123160
Buttons = buttons,
124-
NativeHandle = hWnd
161+
NativeHandle = hWnd,
162+
Description = description,
163+
Progress = progress
125164
});
126165

127166
return true; // continue looking for more dialogs
@@ -548,6 +587,8 @@ private static class Win32
548587
{
549588
public const uint BM_CLICK = 0x00F5;
550589
public const uint WM_COMMAND = 0x0111;
590+
public const uint PBM_GETPOS = 0x0408;
591+
public const uint PBM_GETRANGE = 0x0407;
551592

552593
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
553594

UnityCtl.Cli/StatusCommand.cs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ public static Command CreateCommand()
9494
UnityConnectedToBridge = unityConnected
9595
};
9696

97-
// Detect popup dialogs if Unity is running
97+
// Detect popup dialogs (including progress bars) if Unity is running
9898
DialogInfo[]? detectedDialogs = null;
9999
if (result.UnityEditorRunning)
100100
{
@@ -108,7 +108,9 @@ public static Command CreateCommand()
108108
detectedDialogs = dialogs.Select(d => new DialogInfo
109109
{
110110
Title = d.Title,
111-
Buttons = d.Buttons.Select(b => b.Text).ToArray()
111+
Buttons = d.Buttons.Select(b => b.Text).ToArray(),
112+
Description = d.Description,
113+
Progress = d.Progress
112114
}).ToArray();
113115
}
114116
}
@@ -214,7 +216,7 @@ private static void PrintHumanReadableStatus(ProjectStatusResult status, string?
214216
}
215217
}
216218

217-
// Popup dialogs
219+
// Popup dialogs (including progress bars)
218220
if (dialogs != null && dialogs.Length > 0)
219221
{
220222
Console.WriteLine();
@@ -230,9 +232,17 @@ private static void PrintHumanReadableStatus(ProjectStatusResult status, string?
230232
var buttonLabels = dialog.Buttons.Select(b => $"[{b}]");
231233
Console.Write($" {string.Join(" ", buttonLabels)}");
232234
}
235+
if (dialog.Progress.HasValue)
236+
{
237+
var pct = (int)(dialog.Progress.Value * 100);
238+
Console.Write($" ({pct}%)");
239+
}
240+
if (dialog.Description != null)
241+
Console.Write($" - {dialog.Description}");
233242
Console.WriteLine();
234243
}
235-
Console.WriteLine(" Use 'unityctl dialog dismiss' to dismiss");
244+
if (dialogs.Any(d => d.Buttons.Length > 0))
245+
Console.WriteLine(" Use 'unityctl dialog dismiss' to dismiss");
236246
}
237247

238248
// Version information

UnityCtl.Protocol/DTOs.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,12 @@ public class DialogInfo
322322

323323
[JsonProperty("buttons")]
324324
public required string[] Buttons { get; init; }
325+
326+
[JsonProperty("description")]
327+
public string? Description { get; init; }
328+
329+
[JsonProperty("progress")]
330+
public float? Progress { get; init; }
325331
}
326332

327333
/// <summary>
1.5 KB
Binary file not shown.

docs/dialog-detection.md

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
# Dialog Detection
22

3-
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:
3+
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:
44

55
- The UnityCtl plugin cannot process any RPC commands
66
- `unityctl wait` hangs indefinitely
77
- The bridge reports Unity as connected, but all commands time out (504)
88

9-
The `dialog` command detects these popups from the CLI side (no bridge or plugin needed) and can dismiss them programmatically.
9+
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.
1010

1111
## Commands
1212

@@ -26,6 +26,29 @@ $ unityctl dialog list --json
2626
[{"title":"Enter Safe Mode?","buttons":["Enter Safe Mode","Ignore","Quit"]}]
2727
```
2828

29+
Progress bars show additional fields:
30+
31+
```
32+
$ unityctl dialog list
33+
Detected 1 dialog(s):
34+
35+
"Building Player (busy for 35s)..." [Cancel] [Skip Transcoding] (100%) - Write asset files
36+
```
37+
38+
```
39+
$ unityctl dialog list --json
40+
[{"title":"Building Player (busy for 35s)...","buttons":["Cancel","Skip Transcoding"],"description":"Write asset files","progress":1.0}]
41+
```
42+
43+
During editor startup, the loading splash is detected as a button-less dialog with progress:
44+
45+
```
46+
$ unityctl dialog list
47+
Detected 1 dialog(s):
48+
49+
"Opening project..." (46%)
50+
```
51+
2952
### `unityctl dialog dismiss`
3053

3154
Dismisses the first detected dialog by clicking a button.
@@ -47,14 +70,22 @@ Popups: [!] 1 dialog detected
4770
Use 'unityctl dialog dismiss' to dismiss
4871
```
4972

73+
Progress bars also appear in status output:
74+
75+
```
76+
Popups: [!] 1 dialog detected
77+
"Compiling Scripts (busy for 23s)..." [Cancel] [Skip Transcoding] (10%) - Compiling C# (UnityEngine.UI)
78+
Use 'unityctl dialog dismiss' to dismiss
79+
```
80+
5081
## Architecture
5182

5283
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.
5384

5485
The flow is:
5586
1. Find the Unity process for the project (`FindUnityProcessForProject`)
56-
2. Use platform-specific APIs to enumerate that process's dialog windows
57-
3. Extract window titles and button labels
87+
2. Use platform-specific APIs to enumerate that process's dialog and splash windows
88+
3. Extract window titles, button labels, progress bar values, and description text
5889
4. Optionally click a button to dismiss
5990

6091
## Platform Support
@@ -63,11 +94,22 @@ The flow is:
6394

6495
Uses Win32 P/Invoke APIs to detect and interact with dialogs:
6596

66-
- **Detection**: `EnumWindows` to find visible windows belonging to the Unity PID, filtering for the `#32770` dialog window class
97+
- **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)
6798
- **Button enumeration**: `EnumChildWindows` to find child controls with the `Button` class, reading text via `GetWindowText`
6899
- **Button text cleanup**: Win32 button text includes `&` accelerator prefixes (e.g., `&OK`, `&Cancel`) — these are stripped automatically
100+
- **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
101+
- **Description text**: Reads `Static` child controls for description labels (e.g., "Compiling C# (UnityEngine.UI)")
69102
- **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
70103

104+
**Window classes detected:**
105+
106+
| Class | What | Examples |
107+
|-------|------|----------|
108+
| `#32770` | Standard Win32 dialog | Safe Mode prompt, `EditorUtility.DisplayDialog`, `DisplayProgressBar`, `DisplayCancelableProgressBar` |
109+
| `UnitySplashWindow` | Unity startup/loading splash | "Opening project..." with progress bar during editor launch |
110+
111+
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.
112+
71113
**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.
72114

73115
**No special permissions required.** Works from any terminal, SSH session, or CI environment.
@@ -184,17 +226,30 @@ Key details:
184226

185227
### `DialogDetector.cs`
186228

187-
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.
229+
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).
188230

189231
### `DialogCommands.cs`
190232

191233
Two subcommands under `unityctl dialog`:
192-
- `list` — enumerates dialogs, outputs human-readable or JSON
234+
- `list` — enumerates dialogs, outputs human-readable or JSON. Shows progress percentage and description when present.
193235
- `dismiss --button <text>` — clicks the named button (case-insensitive), defaults to first button
194236

195237
### `StatusCommand.cs`
196238

197-
If Unity is running, calls `DialogDetector.DetectDialogs` and includes any detected dialogs in both human-readable and JSON output.
239+
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.
240+
241+
### `DialogInfo` (Protocol DTO)
242+
243+
```json
244+
{
245+
"title": "Building Player (busy for 35s)...",
246+
"buttons": ["Cancel", "Skip Transcoding"],
247+
"description": "Write asset files",
248+
"progress": 1.0
249+
}
250+
```
251+
252+
The `description` and `progress` fields are nullable — omitted from JSON when not present (e.g., for plain button dialogs like Safe Mode prompts).
198253

199254
### Subprocess timeout
200255

0 commit comments

Comments
 (0)