copilot-liku-cli 0.0.4 → 0.0.9
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 +12 -4
- package/src/main/agents/orchestrator.js +40 -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/trace-writer.js +83 -0
- package/src/main/agents/verifier.js +201 -0
- package/src/main/ai-service.js +673 -66
- package/src/main/index.js +682 -110
- package/src/main/inspect-service.js +24 -1
- package/src/main/python-bridge.js +395 -0
- package/src/main/system-automation.js +934 -133
- 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 +420 -56
- 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 +943 -671
- package/src/renderer/chat/index.html +39 -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,24 @@ 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
|
+
};
|
|
28
|
+
|
|
29
|
+
// Sensitive process denylist — when the active window belongs to one of these,
|
|
30
|
+
// omit element names/text from AI context to prevent prompt leakage.
|
|
31
|
+
const REDACTED_PROCESSES = new Set([
|
|
32
|
+
'keepassxc', 'keepass', '1password', 'bitwarden', 'lastpass', 'dashlane',
|
|
33
|
+
'enpass', 'roboform', 'nordpass', // password managers
|
|
34
|
+
'mstsc', 'vmconnect', 'putty', 'winscp', // remote/admin tools
|
|
35
|
+
'powershell_ise', // admin consoles
|
|
36
|
+
]);
|
|
19
37
|
|
|
20
38
|
class UIWatcher extends EventEmitter {
|
|
21
39
|
constructor(options = {}) {
|
|
@@ -23,8 +41,8 @@ class UIWatcher extends EventEmitter {
|
|
|
23
41
|
|
|
24
42
|
this.options = {
|
|
25
43
|
pollInterval: options.pollInterval || 400, // ms between polls
|
|
26
|
-
focusedWindowOnly: options.focusedWindowOnly ??
|
|
27
|
-
maxElements: options.maxElements ||
|
|
44
|
+
focusedWindowOnly: options.focusedWindowOnly ?? false, // scan all visible windows by default
|
|
45
|
+
maxElements: options.maxElements || 300, // increased limit for desktop scan
|
|
28
46
|
minConfidence: options.minConfidence || 0.3, // filter low-confidence elements
|
|
29
47
|
enabled: false,
|
|
30
48
|
...options
|
|
@@ -55,6 +73,13 @@ class UIWatcher extends EventEmitter {
|
|
|
55
73
|
this.psProcess = null;
|
|
56
74
|
this.psQueue = [];
|
|
57
75
|
this.psReady = false;
|
|
76
|
+
|
|
77
|
+
// Phase 4: event-driven mode
|
|
78
|
+
this._mode = MODE.POLLING;
|
|
79
|
+
this._healthCheckTimer = null;
|
|
80
|
+
this._lastEventTs = 0;
|
|
81
|
+
this._fallbackRetryTimer = null;
|
|
82
|
+
this._uiaEventHandler = null;
|
|
58
83
|
}
|
|
59
84
|
|
|
60
85
|
/**
|
|
@@ -162,6 +187,7 @@ class UIWatcher extends EventEmitter {
|
|
|
162
187
|
|
|
163
188
|
/**
|
|
164
189
|
* Get the currently active/focused window
|
|
190
|
+
* Uses file-based script execution for reliable parsing
|
|
165
191
|
*/
|
|
166
192
|
async getActiveWindow() {
|
|
167
193
|
const script = `
|
|
@@ -194,10 +220,24 @@ $proc = Get-Process -Id $processId -ErrorAction SilentlyContinue
|
|
|
194
220
|
} | ConvertTo-Json -Compress
|
|
195
221
|
`;
|
|
196
222
|
|
|
223
|
+
// Use file-based execution for reliable parsing
|
|
224
|
+
const tempFile = path.join(os.tmpdir(), `liku-activewin-${Date.now()}.ps1`);
|
|
225
|
+
|
|
197
226
|
return new Promise((resolve, reject) => {
|
|
198
|
-
|
|
199
|
-
|
|
227
|
+
// Write script to temp file
|
|
228
|
+
try {
|
|
229
|
+
fs.writeFileSync(tempFile, script, 'utf8');
|
|
230
|
+
} catch (e) {
|
|
231
|
+
resolve(null);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
exec(`powershell -NoProfile -ExecutionPolicy Bypass -File "${tempFile}"`,
|
|
236
|
+
{ encoding: 'utf8', timeout: 3000 },
|
|
200
237
|
(error, stdout, stderr) => {
|
|
238
|
+
// Clean up temp file
|
|
239
|
+
try { fs.unlinkSync(tempFile); } catch {}
|
|
240
|
+
|
|
201
241
|
if (error) {
|
|
202
242
|
resolve(null);
|
|
203
243
|
return;
|
|
@@ -214,14 +254,16 @@ $proc = Get-Process -Id $processId -ErrorAction SilentlyContinue
|
|
|
214
254
|
|
|
215
255
|
/**
|
|
216
256
|
* Detect UI elements using Windows UI Automation
|
|
257
|
+
* Uses file-based script execution for reliable parsing
|
|
217
258
|
*/
|
|
218
259
|
async detectElements(activeWindow) {
|
|
219
260
|
// Build scope filter based on active window
|
|
220
261
|
const windowFilter = this.options.focusedWindowOnly && activeWindow
|
|
221
|
-
? `$targetWindow = "${(activeWindow.title || '').replace(/"/g, '
|
|
262
|
+
? `$targetWindow = "${(activeWindow.title || '').replace(/"/g, '`"')}"`
|
|
222
263
|
: '$targetWindow = ""';
|
|
223
264
|
|
|
224
265
|
const script = `
|
|
266
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
225
267
|
Add-Type -AssemblyName UIAutomationClient
|
|
226
268
|
Add-Type -AssemblyName UIAutomationTypes
|
|
227
269
|
|
|
@@ -229,47 +271,33 @@ ${windowFilter}
|
|
|
229
271
|
$maxElements = ${this.options.maxElements}
|
|
230
272
|
|
|
231
273
|
$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
274
|
$condition = [System.Windows.Automation.Condition]::TrueCondition
|
|
243
|
-
$elements = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)
|
|
244
|
-
|
|
245
275
|
$results = @()
|
|
246
276
|
$count = 0
|
|
247
277
|
|
|
248
|
-
|
|
249
|
-
if ($count -ge $maxElements) { break }
|
|
278
|
+
function Add-Element($el, $rootHwnd) {
|
|
250
279
|
try {
|
|
251
280
|
$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) {
|
|
281
|
+
if ($rect.Width -le 0 -or $rect.Height -le 0) { return $null }
|
|
282
|
+
if ($rect.X -lt -10000 -or $rect.Y -lt -10000) { return $null }
|
|
254
283
|
|
|
255
284
|
$name = $el.Current.Name
|
|
256
|
-
$
|
|
285
|
+
if ($name) { $name = $name -replace '[\\r\\n\\t]', ' ' }
|
|
286
|
+
|
|
287
|
+
$ctrlType = $el.Current.ControlType.ProgrammaticName -replace 'ControlType\\.',''
|
|
257
288
|
$autoId = $el.Current.AutomationId
|
|
258
|
-
$
|
|
259
|
-
$isEnabled = $el.Current.IsEnabled
|
|
289
|
+
if ($autoId) { $autoId = $autoId -replace '[\\r\\n\\t]', ' ' }
|
|
260
290
|
|
|
261
291
|
# Skip elements with no useful identifying info
|
|
262
|
-
if ([string]::IsNullOrWhiteSpace($name) -and [string]::IsNullOrWhiteSpace($autoId)) {
|
|
263
|
-
|
|
264
|
-
# Generate a unique ID
|
|
265
|
-
$id = "$ctrlType|$name|$autoId|$([int]$rect.X)|$([int]$rect.Y)"
|
|
292
|
+
if ([string]::IsNullOrWhiteSpace($name) -and [string]::IsNullOrWhiteSpace($autoId)) { return $null }
|
|
266
293
|
|
|
267
|
-
|
|
268
|
-
id = $
|
|
294
|
+
return @{
|
|
295
|
+
id = "$ctrlType|$name|$autoId|$([int]$rect.X)|$([int]$rect.Y)"
|
|
269
296
|
name = $name
|
|
270
297
|
type = $ctrlType
|
|
271
298
|
automationId = $autoId
|
|
272
|
-
className = $
|
|
299
|
+
className = $el.Current.ClassName
|
|
300
|
+
windowHandle = $rootHwnd
|
|
273
301
|
bounds = @{
|
|
274
302
|
x = [int]$rect.X
|
|
275
303
|
y = [int]$rect.Y
|
|
@@ -280,28 +308,99 @@ foreach ($el in $elements) {
|
|
|
280
308
|
x = [int]($rect.X + $rect.Width / 2)
|
|
281
309
|
y = [int]($rect.Y + $rect.Height / 2)
|
|
282
310
|
}
|
|
283
|
-
isEnabled = $
|
|
311
|
+
isEnabled = $el.Current.IsEnabled
|
|
312
|
+
}
|
|
313
|
+
} catch { return $null }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if ($targetWindow -ne "") {
|
|
317
|
+
# FOCUSED WINDOW MODE
|
|
318
|
+
$nameCondition = New-Object System.Windows.Automation.PropertyCondition(
|
|
319
|
+
[System.Windows.Automation.AutomationElement]::NameProperty, $targetWindow
|
|
320
|
+
)
|
|
321
|
+
$targetEl = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $nameCondition)
|
|
322
|
+
|
|
323
|
+
if ($targetEl) {
|
|
324
|
+
$targetHwnd = 0
|
|
325
|
+
try { $targetHwnd = $targetEl.Current.NativeWindowHandle } catch {}
|
|
326
|
+
|
|
327
|
+
$elements = $targetEl.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)
|
|
328
|
+
foreach ($el in $elements) {
|
|
329
|
+
if ($count -ge $maxElements) { break }
|
|
330
|
+
$data = Add-Element $el $targetHwnd
|
|
331
|
+
if ($data) { $results += $data; $count++ }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
# GLOBAL DESKTOP MODE (Iterate Windows)
|
|
336
|
+
# Get all top-level windows first
|
|
337
|
+
$windows = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $condition)
|
|
338
|
+
|
|
339
|
+
foreach ($win in $windows) {
|
|
340
|
+
if ($count -ge $maxElements) { break }
|
|
341
|
+
|
|
342
|
+
$winHwnd = 0
|
|
343
|
+
try { $winHwnd = $win.Current.NativeWindowHandle } catch {}
|
|
344
|
+
|
|
345
|
+
# Add window itself
|
|
346
|
+
$winData = Add-Element $win $winHwnd
|
|
347
|
+
if ($winData) { $results += $winData; $count++ }
|
|
348
|
+
|
|
349
|
+
# Only process descendants for visible windows that have size
|
|
350
|
+
if ($winData) {
|
|
351
|
+
# Limit descendants per window to avoid starving other windows
|
|
352
|
+
$winElements = $win.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)
|
|
353
|
+
$winCount = 0
|
|
354
|
+
foreach ($el in $winElements) {
|
|
355
|
+
if ($count -ge $maxElements) { break }
|
|
356
|
+
# Limit per window (e.g. 50% of remaining budget or fixed 50)
|
|
357
|
+
if ($winCount -ge 50) { break }
|
|
358
|
+
|
|
359
|
+
$data = Add-Element $el $winHwnd
|
|
360
|
+
if ($data) {
|
|
361
|
+
$results += $data
|
|
362
|
+
$count++
|
|
363
|
+
$winCount++
|
|
364
|
+
}
|
|
365
|
+
}
|
|
284
366
|
}
|
|
285
|
-
|
|
286
|
-
} catch {}
|
|
367
|
+
}
|
|
287
368
|
}
|
|
288
369
|
|
|
289
370
|
$results | ConvertTo-Json -Depth 4 -Compress
|
|
290
371
|
`;
|
|
291
372
|
|
|
373
|
+
// Use file-based execution for reliable parsing (inline -Command breaks on complex scripts)
|
|
374
|
+
const tempFile = path.join(os.tmpdir(), `liku-detect-${Date.now()}.ps1`);
|
|
375
|
+
|
|
292
376
|
return new Promise((resolve, reject) => {
|
|
293
|
-
|
|
294
|
-
|
|
377
|
+
// Write script to temp file
|
|
378
|
+
try {
|
|
379
|
+
fs.writeFileSync(tempFile, script, 'utf8');
|
|
380
|
+
} catch (e) {
|
|
381
|
+
resolve([]);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
exec(`powershell -NoProfile -ExecutionPolicy Bypass -File "${tempFile}"`,
|
|
386
|
+
{ encoding: 'utf8', timeout: 8000, maxBuffer: 10 * 1024 * 1024 },
|
|
295
387
|
(error, stdout, stderr) => {
|
|
388
|
+
// Clean up temp file
|
|
389
|
+
try { fs.unlinkSync(tempFile); } catch {}
|
|
390
|
+
|
|
296
391
|
if (error) {
|
|
392
|
+
console.error('[UI-WATCHER] PowerShell detection error:', error.message);
|
|
297
393
|
resolve([]);
|
|
298
394
|
return;
|
|
299
395
|
}
|
|
396
|
+
|
|
300
397
|
try {
|
|
301
398
|
let elements = JSON.parse(stdout.trim() || '[]');
|
|
302
399
|
if (!Array.isArray(elements)) elements = elements ? [elements] : [];
|
|
303
400
|
resolve(elements);
|
|
304
401
|
} catch (e) {
|
|
402
|
+
console.error('[UI-WATCHER] JSON Parse failed:', e.message);
|
|
403
|
+
console.error('[UI-WATCHER] STDOUT preview:', stdout.trim().substring(0, 200));
|
|
305
404
|
resolve([]);
|
|
306
405
|
}
|
|
307
406
|
}
|
|
@@ -348,40 +447,58 @@ $results | ConvertTo-Json -Depth 4 -Compress
|
|
|
348
447
|
const { elements, activeWindow, lastUpdate } = this.cache;
|
|
349
448
|
const age = Date.now() - lastUpdate;
|
|
350
449
|
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (!byType[type]) byType[type] = [];
|
|
356
|
-
byType[type].push(el);
|
|
357
|
-
});
|
|
450
|
+
// Redaction: if the focused window belongs to a sensitive process,
|
|
451
|
+
// suppress element names to avoid leaking passwords/secrets to the LLM.
|
|
452
|
+
const processLower = (activeWindow?.processName || '').toLowerCase();
|
|
453
|
+
const redacted = REDACTED_PROCESSES.has(processLower);
|
|
358
454
|
|
|
359
|
-
// Build context string
|
|
455
|
+
// Build context string with window hierarchy
|
|
360
456
|
let context = `\n## Live UI State (${age}ms ago)\n`;
|
|
361
457
|
|
|
362
458
|
if (activeWindow) {
|
|
363
|
-
|
|
364
|
-
context += `**Window
|
|
459
|
+
const title = redacted ? '[REDACTED — sensitive application]' : (activeWindow.title || 'Unknown');
|
|
460
|
+
context += `**Focused Window**: ${title} (${activeWindow.processName})\n`;
|
|
461
|
+
context += `**Cursor**: (${activeWindow.bounds.x}, ${activeWindow.bounds.y}) ${activeWindow.bounds.width}x${activeWindow.bounds.height}\n\n`;
|
|
365
462
|
}
|
|
366
463
|
|
|
367
|
-
|
|
368
|
-
|
|
464
|
+
if (redacted) {
|
|
465
|
+
context += `**⚠ Privacy mode active** — element names hidden because the focused application handles sensitive data.\n`;
|
|
466
|
+
context += `You can still take screenshots or wait for the user to switch windows.\n`;
|
|
467
|
+
return context;
|
|
468
|
+
}
|
|
369
469
|
|
|
370
|
-
context += `**
|
|
470
|
+
context += `**Visible Context** (${elements.length} elements detected):\n`;
|
|
371
471
|
|
|
372
472
|
let listed = 0;
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
473
|
+
const limit = 300;
|
|
474
|
+
|
|
475
|
+
// Important interactive types to highlight
|
|
476
|
+
const importantTypes = ['Button', 'Edit', 'ComboBox', 'CheckBox', 'RadioButton', 'MenuItem', 'ListItem', 'TabItem', 'Hyperlink', 'Window'];
|
|
477
|
+
|
|
478
|
+
for (let i = 0; i < elements.length; i++) {
|
|
479
|
+
if (listed >= limit) break;
|
|
480
|
+
|
|
481
|
+
const el = elements[i];
|
|
482
|
+
const name = el.name || el.automationId || '[unnamed]';
|
|
483
|
+
|
|
484
|
+
// Handle Window headers
|
|
485
|
+
if (el.type === 'Window') {
|
|
486
|
+
context += `\n[WIN] **Window**: "${name}" (Handle: ${el.windowHandle || 0})\n`;
|
|
379
487
|
listed++;
|
|
488
|
+
continue;
|
|
380
489
|
}
|
|
490
|
+
|
|
491
|
+
// Skip boring layout elements unless they have a name
|
|
492
|
+
if (!importantTypes.includes(el.type) && !name && name !== '[unnamed]') continue;
|
|
493
|
+
|
|
494
|
+
// Format element line with index for robust referencing
|
|
495
|
+
const status = el.isEnabled ? '' : ' (disabled)';
|
|
496
|
+
context += `- [${i+1}] ${el.type}: "${name}" at (${el.center.x}, ${el.center.y})${status}\n`;
|
|
497
|
+
listed++;
|
|
381
498
|
}
|
|
382
499
|
|
|
383
500
|
if (elements.length > listed) {
|
|
384
|
-
context +=
|
|
501
|
+
context += `\n... and ${elements.length - listed} more elements (showing first ${limit})\n`;
|
|
385
502
|
}
|
|
386
503
|
|
|
387
504
|
return context;
|
|
@@ -465,6 +582,13 @@ $results | ConvertTo-Json -Depth 4 -Compress
|
|
|
465
582
|
};
|
|
466
583
|
}
|
|
467
584
|
|
|
585
|
+
/**
|
|
586
|
+
* Check if watcher is running (alias for isPolling)
|
|
587
|
+
*/
|
|
588
|
+
get isRunning() {
|
|
589
|
+
return this.isPolling;
|
|
590
|
+
}
|
|
591
|
+
|
|
468
592
|
/**
|
|
469
593
|
* Clean up
|
|
470
594
|
*/
|
|
@@ -482,9 +606,249 @@ $results | ConvertTo-Json -Depth 4 -Compress
|
|
|
482
606
|
* Destroy watcher
|
|
483
607
|
*/
|
|
484
608
|
destroy() {
|
|
609
|
+
this.stopEventMode();
|
|
485
610
|
this.stop();
|
|
486
611
|
this.removeAllListeners();
|
|
487
612
|
}
|
|
613
|
+
|
|
614
|
+
// ── Phase 4: Event-driven mode ──────────────────────────────────────
|
|
615
|
+
|
|
616
|
+
/** Current watcher mode */
|
|
617
|
+
get mode() { return this._mode; }
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Switch to event-driven mode — subscribes to .NET UIA events,
|
|
621
|
+
* stops PowerShell polling, sets up health check timer.
|
|
622
|
+
*/
|
|
623
|
+
async startEventMode() {
|
|
624
|
+
if (this._mode === MODE.EVENT_MODE || this._mode === MODE.STARTING_EVENTS) return;
|
|
625
|
+
|
|
626
|
+
console.log('[UI-WATCHER] Switching to EVENT mode');
|
|
627
|
+
this._mode = MODE.STARTING_EVENTS;
|
|
628
|
+
|
|
629
|
+
// Stop polling — events will drive updates
|
|
630
|
+
if (this.pollTimer) {
|
|
631
|
+
clearInterval(this.pollTimer);
|
|
632
|
+
this.pollTimer = null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
try {
|
|
636
|
+
const host = getSharedUIAHost();
|
|
637
|
+
|
|
638
|
+
// Attach event handler (idempotent — remove first if exists)
|
|
639
|
+
this._detachEventHandler();
|
|
640
|
+
this._uiaEventHandler = (evt) => this._onUiaEvent(evt);
|
|
641
|
+
host.on('uia-event', this._uiaEventHandler);
|
|
642
|
+
|
|
643
|
+
const resp = await host.subscribeEvents();
|
|
644
|
+
|
|
645
|
+
// Seed cache with initial snapshot
|
|
646
|
+
if (resp.initial) {
|
|
647
|
+
const elements = resp.initial.elements || [];
|
|
648
|
+
const activeWindow = resp.initial.activeWindow || null;
|
|
649
|
+
|
|
650
|
+
const diff = this.calculateDiff(elements);
|
|
651
|
+
this.cache = {
|
|
652
|
+
elements,
|
|
653
|
+
activeWindow,
|
|
654
|
+
lastUpdate: Date.now(),
|
|
655
|
+
updateCount: this.cache.updateCount + 1
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
this.emit('poll-complete', {
|
|
659
|
+
elements,
|
|
660
|
+
activeWindow,
|
|
661
|
+
pollTime: 0,
|
|
662
|
+
hasChanges: diff.hasChanges,
|
|
663
|
+
source: 'event-initial'
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
this._mode = MODE.EVENT_MODE;
|
|
668
|
+
this._lastEventTs = Date.now();
|
|
669
|
+
this._startHealthCheck();
|
|
670
|
+
|
|
671
|
+
console.log('[UI-WATCHER] EVENT mode active');
|
|
672
|
+
this.emit('mode-changed', MODE.EVENT_MODE);
|
|
673
|
+
} catch (err) {
|
|
674
|
+
console.error('[UI-WATCHER] Failed to start event mode:', err.message);
|
|
675
|
+
this._mode = MODE.POLLING;
|
|
676
|
+
// Fall back to polling
|
|
677
|
+
this._restartPolling();
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Switch back to polling mode — unsubscribes events, restarts poll timer.
|
|
683
|
+
*/
|
|
684
|
+
async stopEventMode() {
|
|
685
|
+
if (this._mode !== MODE.EVENT_MODE && this._mode !== MODE.STARTING_EVENTS && this._mode !== MODE.FALLBACK) return;
|
|
686
|
+
|
|
687
|
+
console.log('[UI-WATCHER] Switching back to POLLING mode');
|
|
688
|
+
|
|
689
|
+
this._stopHealthCheck();
|
|
690
|
+
this._detachEventHandler();
|
|
691
|
+
|
|
692
|
+
if (this._fallbackRetryTimer) {
|
|
693
|
+
clearTimeout(this._fallbackRetryTimer);
|
|
694
|
+
this._fallbackRetryTimer = null;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
try {
|
|
698
|
+
const host = getSharedUIAHost();
|
|
699
|
+
await host.unsubscribeEvents();
|
|
700
|
+
} catch { /* ignore — host may be dead */ }
|
|
701
|
+
|
|
702
|
+
this._mode = MODE.POLLING;
|
|
703
|
+
|
|
704
|
+
// Restart polling if watcher should be active
|
|
705
|
+
if (this.isPolling || this.options.enabled) {
|
|
706
|
+
this._restartPolling();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
this.emit('mode-changed', MODE.POLLING);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Handle incoming UIA event from the .NET host */
|
|
713
|
+
_onUiaEvent(evt) {
|
|
714
|
+
this._lastEventTs = Date.now();
|
|
715
|
+
|
|
716
|
+
switch (evt.event) {
|
|
717
|
+
case 'focusChanged': {
|
|
718
|
+
// New window — update active window, await structureChanged for elements
|
|
719
|
+
if (evt.data?.activeWindow) {
|
|
720
|
+
this.cache.activeWindow = evt.data.activeWindow;
|
|
721
|
+
}
|
|
722
|
+
break;
|
|
723
|
+
}
|
|
724
|
+
case 'structureChanged': {
|
|
725
|
+
// Full element refresh
|
|
726
|
+
const elements = evt.data?.elements || [];
|
|
727
|
+
const diff = this.calculateDiff(elements);
|
|
728
|
+
this.cache = {
|
|
729
|
+
elements,
|
|
730
|
+
activeWindow: this.cache.activeWindow,
|
|
731
|
+
lastUpdate: Date.now(),
|
|
732
|
+
updateCount: this.cache.updateCount + 1
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
if (diff.hasChanges) {
|
|
736
|
+
this.emit('ui-changed', {
|
|
737
|
+
added: diff.added,
|
|
738
|
+
removed: diff.removed,
|
|
739
|
+
changed: diff.changed,
|
|
740
|
+
activeWindow: this.cache.activeWindow,
|
|
741
|
+
elementCount: elements.length
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
this.emit('poll-complete', {
|
|
746
|
+
elements,
|
|
747
|
+
activeWindow: this.cache.activeWindow,
|
|
748
|
+
pollTime: 0,
|
|
749
|
+
hasChanges: diff.hasChanges,
|
|
750
|
+
source: 'event-structure'
|
|
751
|
+
});
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
case 'propertyChanged': {
|
|
755
|
+
// Incremental property patches — merge into cache
|
|
756
|
+
const changed = evt.data?.elements || [];
|
|
757
|
+
if (changed.length === 0) break;
|
|
758
|
+
|
|
759
|
+
const map = new Map(this.cache.elements.map(e => [e.id, e]));
|
|
760
|
+
let patchCount = 0;
|
|
761
|
+
|
|
762
|
+
for (const patch of changed) {
|
|
763
|
+
if (map.has(patch.id)) {
|
|
764
|
+
Object.assign(map.get(patch.id), patch);
|
|
765
|
+
patchCount++;
|
|
766
|
+
} else {
|
|
767
|
+
// New element appeared via property event — add it
|
|
768
|
+
map.set(patch.id, patch);
|
|
769
|
+
patchCount++;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (patchCount > 0) {
|
|
774
|
+
const elements = Array.from(map.values());
|
|
775
|
+
this.cache.elements = elements;
|
|
776
|
+
this.cache.lastUpdate = Date.now();
|
|
777
|
+
|
|
778
|
+
this.emit('poll-complete', {
|
|
779
|
+
elements,
|
|
780
|
+
activeWindow: this.cache.activeWindow,
|
|
781
|
+
pollTime: 0,
|
|
782
|
+
hasChanges: true,
|
|
783
|
+
source: 'event-property'
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
break;
|
|
787
|
+
}
|
|
788
|
+
case 'error':
|
|
789
|
+
console.error('[UI-WATCHER] .NET event error:', evt.data?.error);
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Health check: if no events for 10s while in event mode, fall back to polling */
|
|
795
|
+
_startHealthCheck() {
|
|
796
|
+
this._stopHealthCheck();
|
|
797
|
+
this._healthCheckTimer = setInterval(() => {
|
|
798
|
+
if (this._mode !== MODE.EVENT_MODE) return;
|
|
799
|
+
const elapsed = Date.now() - this._lastEventTs;
|
|
800
|
+
if (elapsed > 10000) {
|
|
801
|
+
console.warn('[UI-WATCHER] No events for 10s — falling back to polling');
|
|
802
|
+
this._fallbackToPolling();
|
|
803
|
+
}
|
|
804
|
+
}, 5000);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
_stopHealthCheck() {
|
|
808
|
+
if (this._healthCheckTimer) {
|
|
809
|
+
clearInterval(this._healthCheckTimer);
|
|
810
|
+
this._healthCheckTimer = null;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Fall back to polling and schedule a retry */
|
|
815
|
+
_fallbackToPolling() {
|
|
816
|
+
this._stopHealthCheck();
|
|
817
|
+
this._mode = MODE.FALLBACK;
|
|
818
|
+
this._restartPolling();
|
|
819
|
+
this.emit('mode-changed', MODE.FALLBACK);
|
|
820
|
+
|
|
821
|
+
// Auto-retry event mode after 30s
|
|
822
|
+
this._fallbackRetryTimer = setTimeout(async () => {
|
|
823
|
+
this._fallbackRetryTimer = null;
|
|
824
|
+
if (this._mode === MODE.FALLBACK) {
|
|
825
|
+
console.log('[UI-WATCHER] Retrying event mode after fallback');
|
|
826
|
+
await this.startEventMode();
|
|
827
|
+
}
|
|
828
|
+
}, 30000);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
_restartPolling() {
|
|
832
|
+
if (this.pollTimer) {
|
|
833
|
+
clearInterval(this.pollTimer);
|
|
834
|
+
this.pollTimer = null;
|
|
835
|
+
}
|
|
836
|
+
this.isPolling = true;
|
|
837
|
+
this.options.enabled = true;
|
|
838
|
+
this.pollTimer = setInterval(() => {
|
|
839
|
+
if (!this.pollInProgress) this.poll();
|
|
840
|
+
}, this.options.pollInterval);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
_detachEventHandler() {
|
|
844
|
+
if (this._uiaEventHandler) {
|
|
845
|
+
try {
|
|
846
|
+
const host = getSharedUIAHost();
|
|
847
|
+
host.removeListener('uia-event', this._uiaEventHandler);
|
|
848
|
+
} catch { /* ignore */ }
|
|
849
|
+
this._uiaEventHandler = null;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
488
852
|
}
|
|
489
853
|
|
|
490
854
|
// 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
|