copilot-liku-cli 0.0.4 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/QUICKSTART.md +24 -0
- package/README.md +85 -33
- package/package.json +23 -14
- package/scripts/postinstall.js +63 -0
- package/src/cli/commands/window.js +66 -0
- package/src/main/agents/base-agent.js +15 -7
- package/src/main/agents/builder.js +211 -0
- package/src/main/agents/index.js +7 -4
- package/src/main/agents/orchestrator.js +13 -0
- package/src/main/agents/producer.js +891 -0
- package/src/main/agents/researcher.js +78 -0
- package/src/main/agents/state-manager.js +134 -2
- package/src/main/agents/verifier.js +201 -0
- package/src/main/ai-service.js +349 -35
- package/src/main/index.js +680 -110
- package/src/main/inspect-service.js +24 -1
- package/src/main/python-bridge.js +395 -0
- package/src/main/system-automation.js +849 -131
- package/src/main/ui-automation/core/ui-provider.js +99 -0
- package/src/main/ui-automation/core/uia-host.js +214 -0
- package/src/main/ui-automation/index.js +30 -0
- package/src/main/ui-automation/interactions/element-click.js +6 -6
- package/src/main/ui-automation/interactions/high-level.js +28 -6
- package/src/main/ui-automation/interactions/index.js +21 -0
- package/src/main/ui-automation/interactions/pattern-actions.js +236 -0
- package/src/main/ui-automation/window/index.js +6 -0
- package/src/main/ui-automation/window/manager.js +173 -26
- package/src/main/ui-watcher.js +401 -58
- package/src/main/visual-awareness.js +18 -1
- package/src/native/windows-uia/Program.cs +89 -0
- package/src/native/windows-uia/build.ps1 +24 -0
- package/src/native/windows-uia-dotnet/Program.cs +920 -0
- package/src/native/windows-uia-dotnet/WindowsUIA.csproj +11 -0
- package/src/native/windows-uia-dotnet/build.ps1 +24 -0
- package/src/renderer/chat/chat.js +915 -671
- package/src/renderer/chat/index.html +2 -4
- package/src/renderer/chat/preload.js +8 -1
- package/src/renderer/overlay/overlay.js +157 -8
- package/src/renderer/overlay/preload.js +4 -0
- package/src/shared/inspect-types.js +82 -6
- package/ARCHITECTURE.md +0 -411
- package/CONFIGURATION.md +0 -302
- package/CONTRIBUTING.md +0 -225
- package/ELECTRON_README.md +0 -121
- package/PROJECT_STATUS.md +0 -229
- package/TESTING.md +0 -274
package/src/main/ui-watcher.js
CHANGED
|
@@ -16,6 +16,15 @@ const os = require('os');
|
|
|
16
16
|
const path = require('path');
|
|
17
17
|
const fs = require('fs');
|
|
18
18
|
const EventEmitter = require('events');
|
|
19
|
+
const { getSharedUIAHost } = require('./ui-automation/core/uia-host');
|
|
20
|
+
|
|
21
|
+
// Watcher mode state machine
|
|
22
|
+
const MODE = {
|
|
23
|
+
POLLING: 'POLLING',
|
|
24
|
+
STARTING_EVENTS: 'STARTING_EVENTS',
|
|
25
|
+
EVENT_MODE: 'EVENT_MODE',
|
|
26
|
+
FALLBACK: 'FALLBACK' // polling after event failure, auto-retry after 30s
|
|
27
|
+
};
|
|
19
28
|
|
|
20
29
|
class UIWatcher extends EventEmitter {
|
|
21
30
|
constructor(options = {}) {
|
|
@@ -23,8 +32,8 @@ class UIWatcher extends EventEmitter {
|
|
|
23
32
|
|
|
24
33
|
this.options = {
|
|
25
34
|
pollInterval: options.pollInterval || 400, // ms between polls
|
|
26
|
-
focusedWindowOnly: options.focusedWindowOnly ??
|
|
27
|
-
maxElements: options.maxElements ||
|
|
35
|
+
focusedWindowOnly: options.focusedWindowOnly ?? false, // scan all visible windows by default
|
|
36
|
+
maxElements: options.maxElements || 300, // increased limit for desktop scan
|
|
28
37
|
minConfidence: options.minConfidence || 0.3, // filter low-confidence elements
|
|
29
38
|
enabled: false,
|
|
30
39
|
...options
|
|
@@ -55,6 +64,13 @@ class UIWatcher extends EventEmitter {
|
|
|
55
64
|
this.psProcess = null;
|
|
56
65
|
this.psQueue = [];
|
|
57
66
|
this.psReady = false;
|
|
67
|
+
|
|
68
|
+
// Phase 4: event-driven mode
|
|
69
|
+
this._mode = MODE.POLLING;
|
|
70
|
+
this._healthCheckTimer = null;
|
|
71
|
+
this._lastEventTs = 0;
|
|
72
|
+
this._fallbackRetryTimer = null;
|
|
73
|
+
this._uiaEventHandler = null;
|
|
58
74
|
}
|
|
59
75
|
|
|
60
76
|
/**
|
|
@@ -162,6 +178,7 @@ class UIWatcher extends EventEmitter {
|
|
|
162
178
|
|
|
163
179
|
/**
|
|
164
180
|
* Get the currently active/focused window
|
|
181
|
+
* Uses file-based script execution for reliable parsing
|
|
165
182
|
*/
|
|
166
183
|
async getActiveWindow() {
|
|
167
184
|
const script = `
|
|
@@ -194,10 +211,24 @@ $proc = Get-Process -Id $processId -ErrorAction SilentlyContinue
|
|
|
194
211
|
} | ConvertTo-Json -Compress
|
|
195
212
|
`;
|
|
196
213
|
|
|
214
|
+
// Use file-based execution for reliable parsing
|
|
215
|
+
const tempFile = path.join(os.tmpdir(), `liku-activewin-${Date.now()}.ps1`);
|
|
216
|
+
|
|
197
217
|
return new Promise((resolve, reject) => {
|
|
198
|
-
|
|
199
|
-
|
|
218
|
+
// Write script to temp file
|
|
219
|
+
try {
|
|
220
|
+
fs.writeFileSync(tempFile, script, 'utf8');
|
|
221
|
+
} catch (e) {
|
|
222
|
+
resolve(null);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
exec(`powershell -NoProfile -ExecutionPolicy Bypass -File "${tempFile}"`,
|
|
227
|
+
{ encoding: 'utf8', timeout: 3000 },
|
|
200
228
|
(error, stdout, stderr) => {
|
|
229
|
+
// Clean up temp file
|
|
230
|
+
try { fs.unlinkSync(tempFile); } catch {}
|
|
231
|
+
|
|
201
232
|
if (error) {
|
|
202
233
|
resolve(null);
|
|
203
234
|
return;
|
|
@@ -214,14 +245,16 @@ $proc = Get-Process -Id $processId -ErrorAction SilentlyContinue
|
|
|
214
245
|
|
|
215
246
|
/**
|
|
216
247
|
* Detect UI elements using Windows UI Automation
|
|
248
|
+
* Uses file-based script execution for reliable parsing
|
|
217
249
|
*/
|
|
218
250
|
async detectElements(activeWindow) {
|
|
219
251
|
// Build scope filter based on active window
|
|
220
252
|
const windowFilter = this.options.focusedWindowOnly && activeWindow
|
|
221
|
-
? `$targetWindow = "${(activeWindow.title || '').replace(/"/g, '
|
|
253
|
+
? `$targetWindow = "${(activeWindow.title || '').replace(/"/g, '`"')}"`
|
|
222
254
|
: '$targetWindow = ""';
|
|
223
255
|
|
|
224
256
|
const script = `
|
|
257
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
225
258
|
Add-Type -AssemblyName UIAutomationClient
|
|
226
259
|
Add-Type -AssemblyName UIAutomationTypes
|
|
227
260
|
|
|
@@ -229,47 +262,33 @@ ${windowFilter}
|
|
|
229
262
|
$maxElements = ${this.options.maxElements}
|
|
230
263
|
|
|
231
264
|
$root = [System.Windows.Automation.AutomationElement]::RootElement
|
|
232
|
-
|
|
233
|
-
# If targeting specific window, find it first
|
|
234
|
-
if ($targetWindow -ne "") {
|
|
235
|
-
$nameCondition = New-Object System.Windows.Automation.PropertyCondition(
|
|
236
|
-
[System.Windows.Automation.AutomationElement]::NameProperty, $targetWindow
|
|
237
|
-
)
|
|
238
|
-
$targetEl = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $nameCondition)
|
|
239
|
-
if ($targetEl) { $root = $targetEl }
|
|
240
|
-
}
|
|
241
|
-
|
|
242
265
|
$condition = [System.Windows.Automation.Condition]::TrueCondition
|
|
243
|
-
$elements = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)
|
|
244
|
-
|
|
245
266
|
$results = @()
|
|
246
267
|
$count = 0
|
|
247
268
|
|
|
248
|
-
|
|
249
|
-
if ($count -ge $maxElements) { break }
|
|
269
|
+
function Add-Element($el, $rootHwnd) {
|
|
250
270
|
try {
|
|
251
271
|
$rect = $el.Current.BoundingRectangle
|
|
252
|
-
if ($rect.Width -le 0 -or $rect.Height -le 0) {
|
|
253
|
-
if ($rect.X -lt -10000 -or $rect.Y -lt -10000) {
|
|
272
|
+
if ($rect.Width -le 0 -or $rect.Height -le 0) { return $null }
|
|
273
|
+
if ($rect.X -lt -10000 -or $rect.Y -lt -10000) { return $null }
|
|
254
274
|
|
|
255
275
|
$name = $el.Current.Name
|
|
256
|
-
$
|
|
276
|
+
if ($name) { $name = $name -replace '[\\r\\n\\t]', ' ' }
|
|
277
|
+
|
|
278
|
+
$ctrlType = $el.Current.ControlType.ProgrammaticName -replace 'ControlType\\.',''
|
|
257
279
|
$autoId = $el.Current.AutomationId
|
|
258
|
-
$
|
|
259
|
-
$isEnabled = $el.Current.IsEnabled
|
|
280
|
+
if ($autoId) { $autoId = $autoId -replace '[\\r\\n\\t]', ' ' }
|
|
260
281
|
|
|
261
282
|
# Skip elements with no useful identifying info
|
|
262
|
-
if ([string]::IsNullOrWhiteSpace($name) -and [string]::IsNullOrWhiteSpace($autoId)) {
|
|
283
|
+
if ([string]::IsNullOrWhiteSpace($name) -and [string]::IsNullOrWhiteSpace($autoId)) { return $null }
|
|
263
284
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
$results += @{
|
|
268
|
-
id = $id
|
|
285
|
+
return @{
|
|
286
|
+
id = "$ctrlType|$name|$autoId|$([int]$rect.X)|$([int]$rect.Y)"
|
|
269
287
|
name = $name
|
|
270
288
|
type = $ctrlType
|
|
271
289
|
automationId = $autoId
|
|
272
|
-
className = $
|
|
290
|
+
className = $el.Current.ClassName
|
|
291
|
+
windowHandle = $rootHwnd
|
|
273
292
|
bounds = @{
|
|
274
293
|
x = [int]$rect.X
|
|
275
294
|
y = [int]$rect.Y
|
|
@@ -280,28 +299,99 @@ foreach ($el in $elements) {
|
|
|
280
299
|
x = [int]($rect.X + $rect.Width / 2)
|
|
281
300
|
y = [int]($rect.Y + $rect.Height / 2)
|
|
282
301
|
}
|
|
283
|
-
isEnabled = $
|
|
302
|
+
isEnabled = $el.Current.IsEnabled
|
|
303
|
+
}
|
|
304
|
+
} catch { return $null }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if ($targetWindow -ne "") {
|
|
308
|
+
# FOCUSED WINDOW MODE
|
|
309
|
+
$nameCondition = New-Object System.Windows.Automation.PropertyCondition(
|
|
310
|
+
[System.Windows.Automation.AutomationElement]::NameProperty, $targetWindow
|
|
311
|
+
)
|
|
312
|
+
$targetEl = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $nameCondition)
|
|
313
|
+
|
|
314
|
+
if ($targetEl) {
|
|
315
|
+
$targetHwnd = 0
|
|
316
|
+
try { $targetHwnd = $targetEl.Current.NativeWindowHandle } catch {}
|
|
317
|
+
|
|
318
|
+
$elements = $targetEl.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)
|
|
319
|
+
foreach ($el in $elements) {
|
|
320
|
+
if ($count -ge $maxElements) { break }
|
|
321
|
+
$data = Add-Element $el $targetHwnd
|
|
322
|
+
if ($data) { $results += $data; $count++ }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
# GLOBAL DESKTOP MODE (Iterate Windows)
|
|
327
|
+
# Get all top-level windows first
|
|
328
|
+
$windows = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $condition)
|
|
329
|
+
|
|
330
|
+
foreach ($win in $windows) {
|
|
331
|
+
if ($count -ge $maxElements) { break }
|
|
332
|
+
|
|
333
|
+
$winHwnd = 0
|
|
334
|
+
try { $winHwnd = $win.Current.NativeWindowHandle } catch {}
|
|
335
|
+
|
|
336
|
+
# Add window itself
|
|
337
|
+
$winData = Add-Element $win $winHwnd
|
|
338
|
+
if ($winData) { $results += $winData; $count++ }
|
|
339
|
+
|
|
340
|
+
# Only process descendants for visible windows that have size
|
|
341
|
+
if ($winData) {
|
|
342
|
+
# Limit descendants per window to avoid starving other windows
|
|
343
|
+
$winElements = $win.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)
|
|
344
|
+
$winCount = 0
|
|
345
|
+
foreach ($el in $winElements) {
|
|
346
|
+
if ($count -ge $maxElements) { break }
|
|
347
|
+
# Limit per window (e.g. 50% of remaining budget or fixed 50)
|
|
348
|
+
if ($winCount -ge 50) { break }
|
|
349
|
+
|
|
350
|
+
$data = Add-Element $el $winHwnd
|
|
351
|
+
if ($data) {
|
|
352
|
+
$results += $data
|
|
353
|
+
$count++
|
|
354
|
+
$winCount++
|
|
355
|
+
}
|
|
356
|
+
}
|
|
284
357
|
}
|
|
285
|
-
|
|
286
|
-
} catch {}
|
|
358
|
+
}
|
|
287
359
|
}
|
|
288
360
|
|
|
289
361
|
$results | ConvertTo-Json -Depth 4 -Compress
|
|
290
362
|
`;
|
|
291
363
|
|
|
364
|
+
// Use file-based execution for reliable parsing (inline -Command breaks on complex scripts)
|
|
365
|
+
const tempFile = path.join(os.tmpdir(), `liku-detect-${Date.now()}.ps1`);
|
|
366
|
+
|
|
292
367
|
return new Promise((resolve, reject) => {
|
|
293
|
-
|
|
294
|
-
|
|
368
|
+
// Write script to temp file
|
|
369
|
+
try {
|
|
370
|
+
fs.writeFileSync(tempFile, script, 'utf8');
|
|
371
|
+
} catch (e) {
|
|
372
|
+
resolve([]);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
exec(`powershell -NoProfile -ExecutionPolicy Bypass -File "${tempFile}"`,
|
|
377
|
+
{ encoding: 'utf8', timeout: 8000, maxBuffer: 10 * 1024 * 1024 },
|
|
295
378
|
(error, stdout, stderr) => {
|
|
379
|
+
// Clean up temp file
|
|
380
|
+
try { fs.unlinkSync(tempFile); } catch {}
|
|
381
|
+
|
|
296
382
|
if (error) {
|
|
383
|
+
console.error('[UI-WATCHER] PowerShell detection error:', error.message);
|
|
297
384
|
resolve([]);
|
|
298
385
|
return;
|
|
299
386
|
}
|
|
387
|
+
|
|
300
388
|
try {
|
|
301
389
|
let elements = JSON.parse(stdout.trim() || '[]');
|
|
302
390
|
if (!Array.isArray(elements)) elements = elements ? [elements] : [];
|
|
303
391
|
resolve(elements);
|
|
304
392
|
} catch (e) {
|
|
393
|
+
console.error('[UI-WATCHER] JSON Parse failed:', e.message);
|
|
394
|
+
console.error('[UI-WATCHER] STDOUT preview:', stdout.trim().substring(0, 200));
|
|
305
395
|
resolve([]);
|
|
306
396
|
}
|
|
307
397
|
}
|
|
@@ -348,40 +438,46 @@ $results | ConvertTo-Json -Depth 4 -Compress
|
|
|
348
438
|
const { elements, activeWindow, lastUpdate } = this.cache;
|
|
349
439
|
const age = Date.now() - lastUpdate;
|
|
350
440
|
|
|
351
|
-
//
|
|
352
|
-
const byType = {};
|
|
353
|
-
elements.forEach(el => {
|
|
354
|
-
const type = el.type || 'Unknown';
|
|
355
|
-
if (!byType[type]) byType[type] = [];
|
|
356
|
-
byType[type].push(el);
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
// Build context string
|
|
441
|
+
// Build context string with window hierarchy
|
|
360
442
|
let context = `\n## Live UI State (${age}ms ago)\n`;
|
|
361
443
|
|
|
362
444
|
if (activeWindow) {
|
|
363
|
-
context += `**
|
|
364
|
-
context += `**
|
|
445
|
+
context += `**Focused Window**: ${activeWindow.title || 'Unknown'} (${activeWindow.processName})\n`;
|
|
446
|
+
context += `**Cursor**: (${activeWindow.bounds.x}, ${activeWindow.bounds.y}) ${activeWindow.bounds.width}x${activeWindow.bounds.height}\n\n`;
|
|
365
447
|
}
|
|
366
448
|
|
|
367
|
-
|
|
368
|
-
const interactiveTypes = ['Button', 'Edit', 'ComboBox', 'CheckBox', 'RadioButton', 'MenuItem', 'ListItem', 'TabItem', 'Hyperlink'];
|
|
369
|
-
|
|
370
|
-
context += `**Interactive Elements** (${elements.length} total):\n`;
|
|
449
|
+
context += `**Visible Context** (${elements.length} elements detected):\n`;
|
|
371
450
|
|
|
372
451
|
let listed = 0;
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
452
|
+
const limit = 300;
|
|
453
|
+
|
|
454
|
+
// Important interactive types to highlight
|
|
455
|
+
const importantTypes = ['Button', 'Edit', 'ComboBox', 'CheckBox', 'RadioButton', 'MenuItem', 'ListItem', 'TabItem', 'Hyperlink', 'Window'];
|
|
456
|
+
|
|
457
|
+
for (let i = 0; i < elements.length; i++) {
|
|
458
|
+
if (listed >= limit) break;
|
|
459
|
+
|
|
460
|
+
const el = elements[i];
|
|
461
|
+
const name = el.name || el.automationId || '[unnamed]';
|
|
462
|
+
|
|
463
|
+
// Handle Window headers
|
|
464
|
+
if (el.type === 'Window') {
|
|
465
|
+
context += `\n[WIN] **Window**: "${name}" (Handle: ${el.windowHandle || 0})\n`;
|
|
379
466
|
listed++;
|
|
467
|
+
continue;
|
|
380
468
|
}
|
|
469
|
+
|
|
470
|
+
// Skip boring layout elements unless they have a name
|
|
471
|
+
if (!importantTypes.includes(el.type) && !name && name !== '[unnamed]') continue;
|
|
472
|
+
|
|
473
|
+
// Format element line with index for robust referencing
|
|
474
|
+
const status = el.isEnabled ? '' : ' (disabled)';
|
|
475
|
+
context += `- [${i+1}] ${el.type}: "${name}" at (${el.center.x}, ${el.center.y})${status}\n`;
|
|
476
|
+
listed++;
|
|
381
477
|
}
|
|
382
478
|
|
|
383
479
|
if (elements.length > listed) {
|
|
384
|
-
context +=
|
|
480
|
+
context += `\n... and ${elements.length - listed} more elements (showing first ${limit})\n`;
|
|
385
481
|
}
|
|
386
482
|
|
|
387
483
|
return context;
|
|
@@ -465,6 +561,13 @@ $results | ConvertTo-Json -Depth 4 -Compress
|
|
|
465
561
|
};
|
|
466
562
|
}
|
|
467
563
|
|
|
564
|
+
/**
|
|
565
|
+
* Check if watcher is running (alias for isPolling)
|
|
566
|
+
*/
|
|
567
|
+
get isRunning() {
|
|
568
|
+
return this.isPolling;
|
|
569
|
+
}
|
|
570
|
+
|
|
468
571
|
/**
|
|
469
572
|
* Clean up
|
|
470
573
|
*/
|
|
@@ -482,9 +585,249 @@ $results | ConvertTo-Json -Depth 4 -Compress
|
|
|
482
585
|
* Destroy watcher
|
|
483
586
|
*/
|
|
484
587
|
destroy() {
|
|
588
|
+
this.stopEventMode();
|
|
485
589
|
this.stop();
|
|
486
590
|
this.removeAllListeners();
|
|
487
591
|
}
|
|
592
|
+
|
|
593
|
+
// ── Phase 4: Event-driven mode ──────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
/** Current watcher mode */
|
|
596
|
+
get mode() { return this._mode; }
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Switch to event-driven mode — subscribes to .NET UIA events,
|
|
600
|
+
* stops PowerShell polling, sets up health check timer.
|
|
601
|
+
*/
|
|
602
|
+
async startEventMode() {
|
|
603
|
+
if (this._mode === MODE.EVENT_MODE || this._mode === MODE.STARTING_EVENTS) return;
|
|
604
|
+
|
|
605
|
+
console.log('[UI-WATCHER] Switching to EVENT mode');
|
|
606
|
+
this._mode = MODE.STARTING_EVENTS;
|
|
607
|
+
|
|
608
|
+
// Stop polling — events will drive updates
|
|
609
|
+
if (this.pollTimer) {
|
|
610
|
+
clearInterval(this.pollTimer);
|
|
611
|
+
this.pollTimer = null;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
try {
|
|
615
|
+
const host = getSharedUIAHost();
|
|
616
|
+
|
|
617
|
+
// Attach event handler (idempotent — remove first if exists)
|
|
618
|
+
this._detachEventHandler();
|
|
619
|
+
this._uiaEventHandler = (evt) => this._onUiaEvent(evt);
|
|
620
|
+
host.on('uia-event', this._uiaEventHandler);
|
|
621
|
+
|
|
622
|
+
const resp = await host.subscribeEvents();
|
|
623
|
+
|
|
624
|
+
// Seed cache with initial snapshot
|
|
625
|
+
if (resp.initial) {
|
|
626
|
+
const elements = resp.initial.elements || [];
|
|
627
|
+
const activeWindow = resp.initial.activeWindow || null;
|
|
628
|
+
|
|
629
|
+
const diff = this.calculateDiff(elements);
|
|
630
|
+
this.cache = {
|
|
631
|
+
elements,
|
|
632
|
+
activeWindow,
|
|
633
|
+
lastUpdate: Date.now(),
|
|
634
|
+
updateCount: this.cache.updateCount + 1
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
this.emit('poll-complete', {
|
|
638
|
+
elements,
|
|
639
|
+
activeWindow,
|
|
640
|
+
pollTime: 0,
|
|
641
|
+
hasChanges: diff.hasChanges,
|
|
642
|
+
source: 'event-initial'
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
this._mode = MODE.EVENT_MODE;
|
|
647
|
+
this._lastEventTs = Date.now();
|
|
648
|
+
this._startHealthCheck();
|
|
649
|
+
|
|
650
|
+
console.log('[UI-WATCHER] EVENT mode active');
|
|
651
|
+
this.emit('mode-changed', MODE.EVENT_MODE);
|
|
652
|
+
} catch (err) {
|
|
653
|
+
console.error('[UI-WATCHER] Failed to start event mode:', err.message);
|
|
654
|
+
this._mode = MODE.POLLING;
|
|
655
|
+
// Fall back to polling
|
|
656
|
+
this._restartPolling();
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Switch back to polling mode — unsubscribes events, restarts poll timer.
|
|
662
|
+
*/
|
|
663
|
+
async stopEventMode() {
|
|
664
|
+
if (this._mode !== MODE.EVENT_MODE && this._mode !== MODE.STARTING_EVENTS && this._mode !== MODE.FALLBACK) return;
|
|
665
|
+
|
|
666
|
+
console.log('[UI-WATCHER] Switching back to POLLING mode');
|
|
667
|
+
|
|
668
|
+
this._stopHealthCheck();
|
|
669
|
+
this._detachEventHandler();
|
|
670
|
+
|
|
671
|
+
if (this._fallbackRetryTimer) {
|
|
672
|
+
clearTimeout(this._fallbackRetryTimer);
|
|
673
|
+
this._fallbackRetryTimer = null;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
try {
|
|
677
|
+
const host = getSharedUIAHost();
|
|
678
|
+
await host.unsubscribeEvents();
|
|
679
|
+
} catch { /* ignore — host may be dead */ }
|
|
680
|
+
|
|
681
|
+
this._mode = MODE.POLLING;
|
|
682
|
+
|
|
683
|
+
// Restart polling if watcher should be active
|
|
684
|
+
if (this.isPolling || this.options.enabled) {
|
|
685
|
+
this._restartPolling();
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
this.emit('mode-changed', MODE.POLLING);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Handle incoming UIA event from the .NET host */
|
|
692
|
+
_onUiaEvent(evt) {
|
|
693
|
+
this._lastEventTs = Date.now();
|
|
694
|
+
|
|
695
|
+
switch (evt.event) {
|
|
696
|
+
case 'focusChanged': {
|
|
697
|
+
// New window — update active window, await structureChanged for elements
|
|
698
|
+
if (evt.data?.activeWindow) {
|
|
699
|
+
this.cache.activeWindow = evt.data.activeWindow;
|
|
700
|
+
}
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
case 'structureChanged': {
|
|
704
|
+
// Full element refresh
|
|
705
|
+
const elements = evt.data?.elements || [];
|
|
706
|
+
const diff = this.calculateDiff(elements);
|
|
707
|
+
this.cache = {
|
|
708
|
+
elements,
|
|
709
|
+
activeWindow: this.cache.activeWindow,
|
|
710
|
+
lastUpdate: Date.now(),
|
|
711
|
+
updateCount: this.cache.updateCount + 1
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
if (diff.hasChanges) {
|
|
715
|
+
this.emit('ui-changed', {
|
|
716
|
+
added: diff.added,
|
|
717
|
+
removed: diff.removed,
|
|
718
|
+
changed: diff.changed,
|
|
719
|
+
activeWindow: this.cache.activeWindow,
|
|
720
|
+
elementCount: elements.length
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
this.emit('poll-complete', {
|
|
725
|
+
elements,
|
|
726
|
+
activeWindow: this.cache.activeWindow,
|
|
727
|
+
pollTime: 0,
|
|
728
|
+
hasChanges: diff.hasChanges,
|
|
729
|
+
source: 'event-structure'
|
|
730
|
+
});
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
733
|
+
case 'propertyChanged': {
|
|
734
|
+
// Incremental property patches — merge into cache
|
|
735
|
+
const changed = evt.data?.elements || [];
|
|
736
|
+
if (changed.length === 0) break;
|
|
737
|
+
|
|
738
|
+
const map = new Map(this.cache.elements.map(e => [e.id, e]));
|
|
739
|
+
let patchCount = 0;
|
|
740
|
+
|
|
741
|
+
for (const patch of changed) {
|
|
742
|
+
if (map.has(patch.id)) {
|
|
743
|
+
Object.assign(map.get(patch.id), patch);
|
|
744
|
+
patchCount++;
|
|
745
|
+
} else {
|
|
746
|
+
// New element appeared via property event — add it
|
|
747
|
+
map.set(patch.id, patch);
|
|
748
|
+
patchCount++;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (patchCount > 0) {
|
|
753
|
+
const elements = Array.from(map.values());
|
|
754
|
+
this.cache.elements = elements;
|
|
755
|
+
this.cache.lastUpdate = Date.now();
|
|
756
|
+
|
|
757
|
+
this.emit('poll-complete', {
|
|
758
|
+
elements,
|
|
759
|
+
activeWindow: this.cache.activeWindow,
|
|
760
|
+
pollTime: 0,
|
|
761
|
+
hasChanges: true,
|
|
762
|
+
source: 'event-property'
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
break;
|
|
766
|
+
}
|
|
767
|
+
case 'error':
|
|
768
|
+
console.error('[UI-WATCHER] .NET event error:', evt.data?.error);
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Health check: if no events for 10s while in event mode, fall back to polling */
|
|
774
|
+
_startHealthCheck() {
|
|
775
|
+
this._stopHealthCheck();
|
|
776
|
+
this._healthCheckTimer = setInterval(() => {
|
|
777
|
+
if (this._mode !== MODE.EVENT_MODE) return;
|
|
778
|
+
const elapsed = Date.now() - this._lastEventTs;
|
|
779
|
+
if (elapsed > 10000) {
|
|
780
|
+
console.warn('[UI-WATCHER] No events for 10s — falling back to polling');
|
|
781
|
+
this._fallbackToPolling();
|
|
782
|
+
}
|
|
783
|
+
}, 5000);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
_stopHealthCheck() {
|
|
787
|
+
if (this._healthCheckTimer) {
|
|
788
|
+
clearInterval(this._healthCheckTimer);
|
|
789
|
+
this._healthCheckTimer = null;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** Fall back to polling and schedule a retry */
|
|
794
|
+
_fallbackToPolling() {
|
|
795
|
+
this._stopHealthCheck();
|
|
796
|
+
this._mode = MODE.FALLBACK;
|
|
797
|
+
this._restartPolling();
|
|
798
|
+
this.emit('mode-changed', MODE.FALLBACK);
|
|
799
|
+
|
|
800
|
+
// Auto-retry event mode after 30s
|
|
801
|
+
this._fallbackRetryTimer = setTimeout(async () => {
|
|
802
|
+
this._fallbackRetryTimer = null;
|
|
803
|
+
if (this._mode === MODE.FALLBACK) {
|
|
804
|
+
console.log('[UI-WATCHER] Retrying event mode after fallback');
|
|
805
|
+
await this.startEventMode();
|
|
806
|
+
}
|
|
807
|
+
}, 30000);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
_restartPolling() {
|
|
811
|
+
if (this.pollTimer) {
|
|
812
|
+
clearInterval(this.pollTimer);
|
|
813
|
+
this.pollTimer = null;
|
|
814
|
+
}
|
|
815
|
+
this.isPolling = true;
|
|
816
|
+
this.options.enabled = true;
|
|
817
|
+
this.pollTimer = setInterval(() => {
|
|
818
|
+
if (!this.pollInProgress) this.poll();
|
|
819
|
+
}, this.options.pollInterval);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
_detachEventHandler() {
|
|
823
|
+
if (this._uiaEventHandler) {
|
|
824
|
+
try {
|
|
825
|
+
const host = getSharedUIAHost();
|
|
826
|
+
host.removeListener('uia-event', this._uiaEventHandler);
|
|
827
|
+
} catch { /* ignore */ }
|
|
828
|
+
this._uiaEventHandler = null;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
488
831
|
}
|
|
489
832
|
|
|
490
833
|
// Singleton instance
|
|
@@ -7,6 +7,7 @@ const { exec } = require('child_process');
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const os = require('os');
|
|
10
|
+
const { getSharedUIAHost } = require('./ui-automation/core/uia-host');
|
|
10
11
|
|
|
11
12
|
// ===== STATE =====
|
|
12
13
|
let previousScreenshot = null;
|
|
@@ -457,13 +458,29 @@ $elements | ConvertTo-Json -Depth 10
|
|
|
457
458
|
}
|
|
458
459
|
|
|
459
460
|
/**
|
|
460
|
-
* Find UI element at specific coordinates
|
|
461
|
+
* Find UI element at specific coordinates.
|
|
462
|
+
* Fast path: persistent .NET UIA host (~5-20ms).
|
|
463
|
+
* Fallback: PowerShell one-shot (~200-500ms).
|
|
461
464
|
*/
|
|
462
465
|
async function findElementAtPoint(x, y) {
|
|
463
466
|
if (process.platform !== 'win32') {
|
|
464
467
|
return { error: 'UI Automation only available on Windows' };
|
|
465
468
|
}
|
|
466
469
|
|
|
470
|
+
// Fast path — .NET host (persistent process, JSONL protocol)
|
|
471
|
+
try {
|
|
472
|
+
const host = getSharedUIAHost();
|
|
473
|
+
const el = await host.elementFromPoint(x, y);
|
|
474
|
+
return {
|
|
475
|
+
...el,
|
|
476
|
+
queryPoint: { x, y },
|
|
477
|
+
timestamp: Date.now()
|
|
478
|
+
};
|
|
479
|
+
} catch (hostErr) {
|
|
480
|
+
// Fall through to PowerShell path
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Fallback — PowerShell (spawns new process each call)
|
|
467
484
|
const psScript = `
|
|
468
485
|
Add-Type -AssemblyName UIAutomationClient
|
|
469
486
|
Add-Type -AssemblyName UIAutomationTypes
|