@lardexx/arenacode 2.6.2 → 2.8.0
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/README.md +26 -0
- package/package.json +2 -2
- package/src/automation.js +993 -0
- package/src/local.js +155 -8
- package/src/tui/app.js +18 -6
package/README.md
CHANGED
|
@@ -134,6 +134,32 @@ so the port reproduces it byte for byte:
|
|
|
134
134
|
|
|
135
135
|
The exact strings are checked by `test/parity.js` against the ones extracted from `ArenaCode-x64.exe`.
|
|
136
136
|
|
|
137
|
+
**Computer Agent: mouse, keyboard and screen.** The three modes' `ui` commands now run in the terminal client too,
|
|
138
|
+
from the same bridge request the desktop app answers:
|
|
139
|
+
|
|
140
|
+
| Command | What it does here |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `ui control` | `move`, `click`, `dblclick`, `rightclick`, `down`, `up`, `drag` (smooth, 12 steps), `scroll` |
|
|
143
|
+
| `ui type` | clicks the field first when the task gives `x,y`, otherwise types into the focused field; the text is pasted, so accents and emoji survive |
|
|
144
|
+
| `ui keys` | `ctrl+s`, `alt+f4`, `win`, `enter`, `f5`, `ctrl+shift+esc` … |
|
|
145
|
+
| `ui app` | `launch` (Start menu on Windows, `open -a` on macOS, `gtk-launch` on Linux), `switch`, `resize` |
|
|
146
|
+
| `ui shot` | full-screen PNG, uploaded to the bridge workspace and returned as `file` (+ `image` base64 when small) |
|
|
147
|
+
| `ui state` | windows with position, size and process id, plus screen and system |
|
|
148
|
+
| `ui apps` | the Start menu (Windows), `/Applications` (macOS), `.desktop` entries (Linux) |
|
|
149
|
+
| `ui multi-select`, `ui multi-edit` | built from the primitives above |
|
|
150
|
+
|
|
151
|
+
No extra install on **Windows**: PowerShell + `user32.dll` + the .NET UI Automation assemblies. The desktop client
|
|
152
|
+
ships a `windows-use.dll` (exports `wu_call_json`, `wu_free`, `wu_version`) for the same job; the terminal client
|
|
153
|
+
reproduces its behaviour instead of depending on it — the same JSON keys (`bounding_box`, `center`, `control_type`,
|
|
154
|
+
`interactive_elements_text`, `scrollable_elements`, `screenshot_png_base64`), the same ideas: the UI Automation tree of
|
|
155
|
+
the focused window for `ui state`, **unicode typing through `SendInput`** so the clipboard is untouched, extended-key
|
|
156
|
+
flags for arrows/navigation, fuzzy window matching (`ui app --name chrome` finds *"Google Chrome — New Tab"*), and
|
|
157
|
+
numbered labels drawn on the elements for `ui shot --annotated`.
|
|
158
|
+
|
|
159
|
+
**macOS**: grant Accessibility to your terminal, and `brew install cliclick` for the mouse. **Linux** (X11):
|
|
160
|
+
`sudo apt install xdotool wmctrl imagemagick`; on Wayland `xdotool` cannot inject input, so Computer Agent needs
|
|
161
|
+
XWayland. On macOS/Linux the accessibility tree is not read: `ui state` returns windows and geometry as elements.
|
|
162
|
+
|
|
137
163
|
**Ask and Proceed / Proceed.** The desktop's Settings switch, `ctrl+x a` here (also `/ask`, `/proceed`,
|
|
138
164
|
`--proceed`, and `arenacode config set approval proceed`). The default, **Ask and Proceed**, asks before commands,
|
|
139
165
|
file changes and plans; in Computer mode it asks once, on the plan, and after that the commands run on their own
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lardexx/arenacode",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"description": "Terminal client for ArenaCode: talks to an arena-bridge, streams the Agent Mode timeline, and runs local commands and file operations on your PC. Node port of the ArenaCode desktop client.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"LICENSE"
|
|
20
20
|
],
|
|
21
21
|
"scripts": {
|
|
22
|
-
"test": "node test/integration.js && node test/parity.js"
|
|
22
|
+
"test": "node test/integration.js && node test/parity.js && node test/automation.js"
|
|
23
23
|
},
|
|
24
24
|
"keywords": [
|
|
25
25
|
"arenacode",
|
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Computer Agent automation for the terminal client: mouse, keyboard, screen,
|
|
4
|
+
// windows and Start-menu apps. One small interface, three backends:
|
|
5
|
+
//
|
|
6
|
+
// Windows PowerShell + user32 (no extra install)
|
|
7
|
+
// macOS osascript (System Events) + screencapture, cliclick for the mouse
|
|
8
|
+
// Linux xdotool + ImageMagick/scrot/grim for screenshots
|
|
9
|
+
//
|
|
10
|
+
// Every function resolves to plain JSON, the same shapes the desktop client
|
|
11
|
+
// returns to the bridge: { ok: true, ... } or { ok: false, error }.
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { spawnSync } = require('node:child_process');
|
|
17
|
+
|
|
18
|
+
const isWin = process.platform === 'win32';
|
|
19
|
+
const isMac = process.platform === 'darwin';
|
|
20
|
+
const isLinux = process.platform === 'linux';
|
|
21
|
+
|
|
22
|
+
function fail(error, extra = {}) {
|
|
23
|
+
return { ok: false, error, ...extra };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function which(cmd) {
|
|
27
|
+
const probe = isWin ? 'where' : 'which';
|
|
28
|
+
const res = spawnSync(probe, [cmd], { encoding: 'utf8', windowsHide: true });
|
|
29
|
+
return res.status === 0 && String(res.stdout).trim() !== '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function run(cmd, args, opts = {}) {
|
|
33
|
+
const res = spawnSync(cmd, args, {
|
|
34
|
+
input: opts.input,
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
timeout: opts.timeout || 30000,
|
|
37
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
38
|
+
windowsHide: true,
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
ok: res.status === 0,
|
|
42
|
+
status: res.status,
|
|
43
|
+
stdout: res.stdout || '',
|
|
44
|
+
stderr: res.stderr || '',
|
|
45
|
+
error: res.error ? res.error.message : '',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --------------------------------------------------------------------------- //
|
|
50
|
+
// Windows
|
|
51
|
+
// --------------------------------------------------------------------------- //
|
|
52
|
+
|
|
53
|
+
const PS_ENGINE = String.raw`
|
|
54
|
+
$ErrorActionPreference = 'Stop'
|
|
55
|
+
$req = ([Console]::In.ReadToEnd() | ConvertFrom-Json)
|
|
56
|
+
Add-Type -AssemblyName System.Drawing
|
|
57
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
58
|
+
Add-Type -AssemblyName UIAutomationClient
|
|
59
|
+
Add-Type -AssemblyName UIAutomationTypes
|
|
60
|
+
Add-Type -TypeDefinition @'
|
|
61
|
+
using System;
|
|
62
|
+
using System.Text;
|
|
63
|
+
using System.Runtime.InteropServices;
|
|
64
|
+
public class AC {
|
|
65
|
+
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
|
66
|
+
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, int dx, int dy, int data, UIntPtr extra);
|
|
67
|
+
[DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extra);
|
|
68
|
+
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
69
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
|
70
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
|
|
71
|
+
[DllImport("user32.dll")] public static extern bool MoveWindow(IntPtr h, int x, int y, int w, int ht, bool repaint);
|
|
72
|
+
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
|
73
|
+
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
|
|
74
|
+
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
|
|
75
|
+
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
|
|
76
|
+
}
|
|
77
|
+
'@
|
|
78
|
+
|
|
79
|
+
$AC_TYPES = @(
|
|
80
|
+
[System.Windows.Automation.ControlType]::Button,
|
|
81
|
+
[System.Windows.Automation.ControlType]::CheckBox,
|
|
82
|
+
[System.Windows.Automation.ControlType]::ComboBox,
|
|
83
|
+
[System.Windows.Automation.ControlType]::Custom,
|
|
84
|
+
[System.Windows.Automation.ControlType]::DataItem,
|
|
85
|
+
[System.Windows.Automation.ControlType]::Edit,
|
|
86
|
+
[System.Windows.Automation.ControlType]::Hyperlink,
|
|
87
|
+
[System.Windows.Automation.ControlType]::ListItem,
|
|
88
|
+
[System.Windows.Automation.ControlType]::MenuItem,
|
|
89
|
+
[System.Windows.Automation.ControlType]::RadioButton,
|
|
90
|
+
[System.Windows.Automation.ControlType]::ScrollBar,
|
|
91
|
+
[System.Windows.Automation.ControlType]::Slider,
|
|
92
|
+
[System.Windows.Automation.ControlType]::Spinner,
|
|
93
|
+
[System.Windows.Automation.ControlType]::SplitButton,
|
|
94
|
+
[System.Windows.Automation.ControlType]::Tab,
|
|
95
|
+
[System.Windows.Automation.ControlType]::TabItem,
|
|
96
|
+
[System.Windows.Automation.ControlType]::Text,
|
|
97
|
+
[System.Windows.Automation.ControlType]::ToolBar,
|
|
98
|
+
[System.Windows.Automation.ControlType]::TreeItem
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
function Get-ACWindow($h) {
|
|
102
|
+
$hh = [IntPtr][int64]$h
|
|
103
|
+
$r = New-Object AC+RECT
|
|
104
|
+
[void][AC]::GetWindowRect($hh, [ref]$r)
|
|
105
|
+
$sb = New-Object System.Text.StringBuilder 1024
|
|
106
|
+
[void][AC]::GetWindowText($hh, $sb, 1024)
|
|
107
|
+
return [pscustomobject]@{
|
|
108
|
+
name = $sb.ToString()
|
|
109
|
+
handle = $hh.ToInt64()
|
|
110
|
+
hwnd = $hh.ToInt64()
|
|
111
|
+
x = $r.Left; y = $r.Top
|
|
112
|
+
width = [Math]::Max(0, $r.Right - $r.Left); height = [Math]::Max(0, $r.Bottom - $r.Top)
|
|
113
|
+
status = $(if ([AC]::IsIconic($hh)) { 'minimized' } else { 'normal' })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function Get-ACWindows {
|
|
118
|
+
$list = New-Object System.Collections.ArrayList
|
|
119
|
+
foreach ($p in (Get-Process | Where-Object { $_.MainWindowHandle -ne 0 })) {
|
|
120
|
+
try {
|
|
121
|
+
$title = [string]$p.MainWindowTitle
|
|
122
|
+
if ($title.Trim().Length -eq 0) { continue }
|
|
123
|
+
$w = Get-ACWindow $p.MainWindowHandle
|
|
124
|
+
$w | Add-Member -NotePropertyName process_id -NotePropertyValue ([int]$p.Id)
|
|
125
|
+
$w | Add-Member -NotePropertyName app -NotePropertyValue ([string]$p.ProcessName)
|
|
126
|
+
[void]$list.Add($w)
|
|
127
|
+
} catch { }
|
|
128
|
+
}
|
|
129
|
+
return $list
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function Get-ACScreen {
|
|
133
|
+
$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
|
134
|
+
return @{ width = $b.Width; height = $b.Height }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function Send-ACKeys($pairs) {
|
|
138
|
+
foreach ($k in $pairs) {
|
|
139
|
+
$flags = 0
|
|
140
|
+
if ($k.flags -eq 2) { $flags = $flags -bor 2 }
|
|
141
|
+
if ($k.ext) { $flags = $flags -bor 1 }
|
|
142
|
+
[AC]::keybd_event([byte]$k.vk, 0, $flags, [UIntPtr]::Zero)
|
|
143
|
+
Start-Sleep -Milliseconds 12
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# windows-use types through SendInput unicode events: the clipboard is never touched
|
|
148
|
+
function Send-ACUnicode($text) {
|
|
149
|
+
$typed = 0
|
|
150
|
+
foreach ($ch in $text.ToCharArray()) {
|
|
151
|
+
if ($ch -eq [char]10) {
|
|
152
|
+
[AC]::keybd_event(0x0D, 0, 0, [UIntPtr]::Zero)
|
|
153
|
+
[AC]::keybd_event(0x0D, 0, 2, [UIntPtr]::Zero)
|
|
154
|
+
$typed++
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if ($ch -eq [char]13) { continue }
|
|
158
|
+
[AC]::keybd_event(0, [byte][char]$ch, 4, [UIntPtr]::Zero)
|
|
159
|
+
[AC]::keybd_event(0, [byte][char]$ch, 6, [UIntPtr]::Zero)
|
|
160
|
+
$typed++
|
|
161
|
+
Start-Sleep -Milliseconds 3
|
|
162
|
+
}
|
|
163
|
+
return $typed
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function Get-ACElements($hwnd, $interactiveOnly) {
|
|
167
|
+
$handle = [IntPtr][int64]$hwnd
|
|
168
|
+
$root = $null
|
|
169
|
+
if ($handle -ne [IntPtr]::Zero) {
|
|
170
|
+
$root = [System.Windows.Automation.AutomationElement]::FromHandle($handle)
|
|
171
|
+
} else {
|
|
172
|
+
$root = [System.Windows.Automation.AutomationElement]::FocusedElement
|
|
173
|
+
while ($root -ne $null -and $root.Current.NativeWindowHandle -eq 0) {
|
|
174
|
+
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
|
|
175
|
+
$root = $walker.GetParent($root)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if ($root -eq $null) { return @() }
|
|
179
|
+
|
|
180
|
+
$conditions = @()
|
|
181
|
+
foreach ($ct in $AC_TYPES) {
|
|
182
|
+
$conditions += New-Object System.Windows.Automation.PropertyCondition(
|
|
183
|
+
[System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ct)
|
|
184
|
+
}
|
|
185
|
+
$cond = New-Object System.Windows.Automation.OrCondition $conditions
|
|
186
|
+
$found = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond)
|
|
187
|
+
|
|
188
|
+
$out = New-Object System.Collections.ArrayList
|
|
189
|
+
$n = 0
|
|
190
|
+
foreach ($el in $found) {
|
|
191
|
+
if ($n -ge 120) { break }
|
|
192
|
+
try {
|
|
193
|
+
$cur = $el.Current
|
|
194
|
+
if ($cur.IsOffscreen) { continue }
|
|
195
|
+
$rect = $cur.BoundingRectangle
|
|
196
|
+
if ($rect.Width -le 0 -or $rect.Height -le 0) { continue }
|
|
197
|
+
$name = [string]$cur.Name
|
|
198
|
+
$ctype = [string]$cur.ControlType.ProgrammaticName -replace '^ControlType\.', ''
|
|
199
|
+
if ($interactiveOnly -and $ctype -eq 'Text' -and $name.Trim().Length -eq 0) { continue }
|
|
200
|
+
$val = ''
|
|
201
|
+
try {
|
|
202
|
+
$vp = $null
|
|
203
|
+
if ($el.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$vp)) { $val = [string]$vp.Current.Value }
|
|
204
|
+
} catch { $val = '' }
|
|
205
|
+
$scrollable = $false
|
|
206
|
+
try {
|
|
207
|
+
$sp = $null
|
|
208
|
+
if ($el.TryGetCurrentPattern([System.Windows.Automation.ScrollPattern]::Pattern, [ref]$sp)) {
|
|
209
|
+
$scrollable = [bool]$sp.Current.VerticallyScrollable -or [bool]$sp.Current.HorizontallyScrollable
|
|
210
|
+
}
|
|
211
|
+
} catch { }
|
|
212
|
+
[void]$out.Add([pscustomobject]@{
|
|
213
|
+
name = $name
|
|
214
|
+
control_type = $ctype
|
|
215
|
+
kind = $ctype
|
|
216
|
+
bounding_box = @{ left = [int]$rect.X; top = [int]$rect.Y; right = [int]($rect.X + $rect.Width); bottom = [int]($rect.Y + $rect.Height) }
|
|
217
|
+
center = @{ x = [int]($rect.X + $rect.Width / 2); y = [int]($rect.Y + $rect.Height / 2) }
|
|
218
|
+
value = $val
|
|
219
|
+
scrollable = $scrollable
|
|
220
|
+
enabled = [bool]$cur.IsEnabled
|
|
221
|
+
automation_id = [string]$cur.AutomationId
|
|
222
|
+
})
|
|
223
|
+
$n++
|
|
224
|
+
} catch { }
|
|
225
|
+
}
|
|
226
|
+
return $out
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
$out = @{ ok = $true }
|
|
230
|
+
switch ($req.action) {
|
|
231
|
+
'screen' { $s = Get-ACScreen; $out.width = $s.width; $out.height = $s.height }
|
|
232
|
+
'windows' { $out.windows = @(Get-ACWindows) }
|
|
233
|
+
'active' {
|
|
234
|
+
$hh = [AC]::GetForegroundWindow()
|
|
235
|
+
$w = Get-ACWindow $hh.ToInt64()
|
|
236
|
+
$out.name = $w.name
|
|
237
|
+
$out.handle = $w.handle
|
|
238
|
+
}
|
|
239
|
+
'focus' {
|
|
240
|
+
$hh = [IntPtr][int64]$req.handle
|
|
241
|
+
[void][AC]::ShowWindow($hh, 9)
|
|
242
|
+
[void][AC]::SetForegroundWindow($hh)
|
|
243
|
+
Start-Sleep -Milliseconds 200
|
|
244
|
+
$w = Get-ACWindow $req.handle
|
|
245
|
+
$out.name = $w.name
|
|
246
|
+
}
|
|
247
|
+
'resize' {
|
|
248
|
+
$hh = [IntPtr][int64]$req.handle
|
|
249
|
+
[void][AC]::MoveWindow($hh, [int]$req.x, [int]$req.y, [int]$req.width, [int]$req.height, $true)
|
|
250
|
+
$w = Get-ACWindow $req.handle
|
|
251
|
+
$out.name = $w.name
|
|
252
|
+
}
|
|
253
|
+
'launch' {
|
|
254
|
+
$name = [string]$req.name
|
|
255
|
+
$done = $false
|
|
256
|
+
try { Start-Process -FilePath $name -ErrorAction Stop; $done = $true } catch { $done = $false }
|
|
257
|
+
if (-not $done) {
|
|
258
|
+
$hit = $null
|
|
259
|
+
try { $hit = Get-StartApps | Where-Object { $_.Name -like "*$name*" } | Select-Object -First 1 } catch { $hit = $null }
|
|
260
|
+
if ($hit) { Start-Process 'explorer.exe' "shell:AppsFolder\$($hit.AppID)"; $done = $true }
|
|
261
|
+
else {
|
|
262
|
+
$exe = Get-Command $name -ErrorAction SilentlyContinue
|
|
263
|
+
if ($exe) { Start-Process $exe.Source; $done = $true }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if ($done) { Start-Sleep -Milliseconds 900; $out.name = $name } else { $out = @{ ok = $false; error = "app not found: $name" } }
|
|
267
|
+
}
|
|
268
|
+
'move' { [void][AC]::SetCursorPos([int]$req.x, [int]$req.y) }
|
|
269
|
+
'click' {
|
|
270
|
+
[void][AC]::SetCursorPos([int]$req.x, [int]$req.y)
|
|
271
|
+
Start-Sleep -Milliseconds 40
|
|
272
|
+
$down = 2; $up = 4
|
|
273
|
+
if ($req.button -eq 'right') { $down = 8; $up = 16 }
|
|
274
|
+
elseif ($req.button -eq 'middle') { $down = 32; $up = 64 }
|
|
275
|
+
$times = [int]$req.count; if ($times -lt 1) { $times = 1 }
|
|
276
|
+
for ($i = 0; $i -lt $times; $i++) {
|
|
277
|
+
[AC]::mouse_event($down, 0, 0, 0, [UIntPtr]::Zero)
|
|
278
|
+
Start-Sleep -Milliseconds 20
|
|
279
|
+
[AC]::mouse_event($up, 0, 0, 0, [UIntPtr]::Zero)
|
|
280
|
+
Start-Sleep -Milliseconds 60
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
'down' {
|
|
284
|
+
[void][AC]::SetCursorPos([int]$req.x, [int]$req.y)
|
|
285
|
+
$flag = 2; if ($req.button -eq 'right') { $flag = 8 } elseif ($req.button -eq 'middle') { $flag = 32 }
|
|
286
|
+
[AC]::mouse_event($flag, 0, 0, 0, [UIntPtr]::Zero)
|
|
287
|
+
}
|
|
288
|
+
'up' {
|
|
289
|
+
$flag = 4; if ($req.button -eq 'right') { $flag = 16 } elseif ($req.button -eq 'middle') { $flag = 64 }
|
|
290
|
+
if ($null -ne $req.x) { [void][AC]::SetCursorPos([int]$req.x, [int]$req.y) }
|
|
291
|
+
[AC]::mouse_event($flag, 0, 0, 0, [UIntPtr]::Zero)
|
|
292
|
+
}
|
|
293
|
+
'drag' {
|
|
294
|
+
[void][AC]::SetCursorPos([int]$req.x, [int]$req.y)
|
|
295
|
+
Start-Sleep -Milliseconds 60
|
|
296
|
+
[AC]::mouse_event(2, 0, 0, 0, [UIntPtr]::Zero)
|
|
297
|
+
$steps = 12
|
|
298
|
+
for ($i = 1; $i -le $steps; $i++) {
|
|
299
|
+
$nx = [int]($req.x + (($req.x2 - $req.x) * $i / $steps))
|
|
300
|
+
$ny = [int]($req.y + (($req.y2 - $req.y) * $i / $steps))
|
|
301
|
+
[void][AC]::SetCursorPos($nx, $ny)
|
|
302
|
+
Start-Sleep -Milliseconds 15
|
|
303
|
+
}
|
|
304
|
+
[AC]::mouse_event(4, 0, 0, 0, [UIntPtr]::Zero)
|
|
305
|
+
}
|
|
306
|
+
'scroll' {
|
|
307
|
+
if ($null -ne $req.x -and $null -ne $req.y) { [void][AC]::SetCursorPos([int]$req.x, [int]$req.y); Start-Sleep -Milliseconds 40 }
|
|
308
|
+
$amount = [int]$req.amount
|
|
309
|
+
$dir = 120; if ($amount -lt 0) { $dir = -120 }
|
|
310
|
+
for ($i = 0; $i -lt [Math]::Abs($amount); $i++) {
|
|
311
|
+
[AC]::mouse_event(0x0800, 0, 0, $dir, [UIntPtr]::Zero)
|
|
312
|
+
Start-Sleep -Milliseconds 25
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
'keys' { Send-ACKeys $req.sequence }
|
|
316
|
+
'type' {
|
|
317
|
+
if ($null -ne $req.x -and $null -ne $req.y) {
|
|
318
|
+
[void][AC]::SetCursorPos([int]$req.x, [int]$req.y)
|
|
319
|
+
Start-Sleep -Milliseconds 60
|
|
320
|
+
[AC]::mouse_event(2, 0, 0, 0, [UIntPtr]::Zero)
|
|
321
|
+
[AC]::mouse_event(4, 0, 0, 0, [UIntPtr]::Zero)
|
|
322
|
+
Start-Sleep -Milliseconds 150
|
|
323
|
+
}
|
|
324
|
+
$out.typed = (Send-ACUnicode ([string]$req.text))
|
|
325
|
+
}
|
|
326
|
+
'shot' {
|
|
327
|
+
$s = Get-ACScreen
|
|
328
|
+
$bmp = New-Object System.Drawing.Bitmap $s.width, $s.height
|
|
329
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
330
|
+
$g.CopyFromScreen(0, 0, 0, 0, $bmp.Size)
|
|
331
|
+
if ($req.annotate) {
|
|
332
|
+
$fg = $null
|
|
333
|
+
try { $fg = [AC]::GetForegroundWindow().ToInt64() } catch { $fg = 0 }
|
|
334
|
+
$els = @(Get-ACElements $fg $true)
|
|
335
|
+
$font = New-Object System.Drawing.Font('Segoe UI', 9, [System.Drawing.FontStyle]::Bold)
|
|
336
|
+
$brush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(230, 250, 178, 131))
|
|
337
|
+
$pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(200, 250, 178, 131)), 1
|
|
338
|
+
$i = 1
|
|
339
|
+
foreach ($e in $els) {
|
|
340
|
+
$bb = $e.bounding_box
|
|
341
|
+
$g.DrawRectangle($pen, $bb.left, $bb.top, [Math]::Max(1, $bb.right - $bb.left), [Math]::Max(1, $bb.bottom - $bb.top))
|
|
342
|
+
$g.FillRectangle($brush, $bb.left, [Math]::Max(0, $bb.top - 14), 18, 14)
|
|
343
|
+
$g.DrawString([string]$i, $font, (New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::Black)), $bb.left + 2, [Math]::Max(0, $bb.top - 14))
|
|
344
|
+
$i++
|
|
345
|
+
}
|
|
346
|
+
$pen.Dispose(); $brush.Dispose(); $font.Dispose()
|
|
347
|
+
$out.elements = $els.Count
|
|
348
|
+
}
|
|
349
|
+
$bmp.Save($req.path, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
350
|
+
$g.Dispose(); $bmp.Dispose()
|
|
351
|
+
$out.width = $s.width; $out.height = $s.height; $out.file = $req.path
|
|
352
|
+
}
|
|
353
|
+
'apps' {
|
|
354
|
+
try { $out.apps = @(Get-StartApps | ForEach-Object { @{ name = $_.Name; app_id = $_.AppID } }) }
|
|
355
|
+
catch { $out.apps = @() }
|
|
356
|
+
}
|
|
357
|
+
'state' {
|
|
358
|
+
$hh = $null
|
|
359
|
+
if ($null -ne $req.handle -and [int64]$req.handle -ne 0) { $hh = [int64]$req.handle } else { $hh = [AC]::GetForegroundWindow().ToInt64() }
|
|
360
|
+
$els = @(Get-ACElements $hh $true)
|
|
361
|
+
$interactive = @()
|
|
362
|
+
$scrollable = @()
|
|
363
|
+
$lines = New-Object System.Collections.ArrayList
|
|
364
|
+
$i = 1
|
|
365
|
+
foreach ($e in $els) {
|
|
366
|
+
if ($e.scrollable) { $scrollable += $e } else { $interactive += $e }
|
|
367
|
+
[void]$lines.Add("$i. $($e.control_type) '$($e.name)' at ($($e.center.x), $($e.center.y))")
|
|
368
|
+
$i++
|
|
369
|
+
}
|
|
370
|
+
$out.windows = @(Get-ACWindows)
|
|
371
|
+
$out.active_window = (Get-ACWindow $hh)
|
|
372
|
+
$out.interactive_elements = $interactive
|
|
373
|
+
$out.scrollable_elements = $scrollable
|
|
374
|
+
$out.interactive_elements_text = ($lines -join [Environment]::NewLine)
|
|
375
|
+
$out.scrollable_elements_text = (($scrollable | ForEach-Object { "$($_.control_type) '$($_.name)' at ($($_.center.x), $($_.center.y))" }) -join [Environment]::NewLine)
|
|
376
|
+
$out.system = @{
|
|
377
|
+
windows_version = [string](Get-CimInstance Win32_OperatingSystem).Caption
|
|
378
|
+
os_version = [Environment]::OSVersion.Version.ToString()
|
|
379
|
+
screen_size = (Get-ACScreen)
|
|
380
|
+
user_account_type = 'Local Account'
|
|
381
|
+
default_language = (Get-Culture).Name
|
|
382
|
+
dpi_scaling = 1
|
|
383
|
+
}
|
|
384
|
+
$out.metadata = @{ kind = 'ui-automation'; depth = 'descendants'; all_desktops = $false }
|
|
385
|
+
}
|
|
386
|
+
'sysinfo' {
|
|
387
|
+
$out.system = @{
|
|
388
|
+
windows_version = [string](Get-CimInstance Win32_OperatingSystem).Caption
|
|
389
|
+
os_version = [Environment]::OSVersion.Version.ToString()
|
|
390
|
+
screen_size = (Get-ACScreen)
|
|
391
|
+
user_account_type = 'Local Account'
|
|
392
|
+
default_language = (Get-Culture).Name
|
|
393
|
+
dpi_scaling = 1
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
default { $out = @{ ok = $false; error = "unknown action: $($req.action)" } }
|
|
397
|
+
}
|
|
398
|
+
$out | ConvertTo-Json -Depth 8 -Compress
|
|
399
|
+
`;
|
|
400
|
+
let psEnginePath = null;
|
|
401
|
+
function psEngine() {
|
|
402
|
+
if (psEnginePath && fs.existsSync(psEnginePath)) return psEnginePath;
|
|
403
|
+
const dir = path.join(os.tmpdir(), 'arenacode-automation');
|
|
404
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
405
|
+
const file = path.join(dir, 'engine.ps1');
|
|
406
|
+
fs.writeFileSync(file, PS_ENGINE, 'utf8');
|
|
407
|
+
psEnginePath = file;
|
|
408
|
+
return file;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function winRun(request, timeout = 30000) {
|
|
412
|
+
const shell = which('powershell') ? 'powershell' : 'pwsh';
|
|
413
|
+
if (!which(shell)) return fail('PowerShell not found');
|
|
414
|
+
const res = run(
|
|
415
|
+
shell,
|
|
416
|
+
['-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-File', psEngine()],
|
|
417
|
+
{ input: JSON.stringify(request), timeout }
|
|
418
|
+
);
|
|
419
|
+
if (!res.ok && !res.stdout.trim()) {
|
|
420
|
+
return fail(res.stderr.trim() || res.error || `PowerShell exit ${res.status}`);
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
return JSON.parse(res.stdout.trim().split('\n').pop());
|
|
424
|
+
} catch {
|
|
425
|
+
return fail(`unreadable answer from PowerShell: ${res.stdout.slice(0, 200)}`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// --------------------------------------------------------------------------- //
|
|
430
|
+
// macOS
|
|
431
|
+
// --------------------------------------------------------------------------- //
|
|
432
|
+
|
|
433
|
+
function osa(script, timeout = 20000) {
|
|
434
|
+
const res = run('osascript', ['-e', script], { timeout });
|
|
435
|
+
return { ok: res.ok, out: res.stdout.trim(), error: res.stderr.trim() || res.error };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function macScreen() {
|
|
439
|
+
const res = osa('tell application "Finder" to get bounds of window of desktop');
|
|
440
|
+
const nums = String(res.out)
|
|
441
|
+
.split(',')
|
|
442
|
+
.map((n) => Number(n.trim()));
|
|
443
|
+
if (nums.length === 4 && nums.every((n) => Number.isFinite(n))) {
|
|
444
|
+
return { width: nums[2] - nums[0], height: nums[3] - nums[1] };
|
|
445
|
+
}
|
|
446
|
+
return { width: 1440, height: 900 };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function macWindows() {
|
|
450
|
+
const res = osa(
|
|
451
|
+
'tell application "System Events" to set out to ""\n' +
|
|
452
|
+
'repeat with p in (every process whose background only is false)\n' +
|
|
453
|
+
' repeat with w in (every window of p)\n' +
|
|
454
|
+
' set out to out & (name of p) & tab & (name of w) & tab & (item 1 of (position of w)) & tab & (item 2 of (position of w)) & tab & (item 1 of (size of w)) & tab & (item 2 of (size of w)) & linefeed\n' +
|
|
455
|
+
' end repeat\n' +
|
|
456
|
+
'end repeat\n' +
|
|
457
|
+
'return out'
|
|
458
|
+
);
|
|
459
|
+
if (!res.ok) return [];
|
|
460
|
+
return String(res.out)
|
|
461
|
+
.split('\n')
|
|
462
|
+
.filter(Boolean)
|
|
463
|
+
.map((line) => {
|
|
464
|
+
const [app, title, x, y, w, h] = line.split('\t');
|
|
465
|
+
return {
|
|
466
|
+
name: title || app,
|
|
467
|
+
app,
|
|
468
|
+
x: Number(x) || 0,
|
|
469
|
+
y: Number(y) || 0,
|
|
470
|
+
width: Number(w) || 0,
|
|
471
|
+
height: Number(h) || 0,
|
|
472
|
+
process_id: 0,
|
|
473
|
+
status: 'normal',
|
|
474
|
+
};
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function macKeys(sequence, text) {
|
|
479
|
+
// sequence comes as vk pairs; on macOS it is easier to send the original combo
|
|
480
|
+
const script = text
|
|
481
|
+
? `tell application "System Events" to keystroke ${JSON.stringify(text)}`
|
|
482
|
+
: `tell application "System Events" to keystroke ${JSON.stringify(sequence)}`;
|
|
483
|
+
return osa(script);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// --------------------------------------------------------------------------- //
|
|
487
|
+
// Linux
|
|
488
|
+
// --------------------------------------------------------------------------- //
|
|
489
|
+
|
|
490
|
+
function linuxScreen() {
|
|
491
|
+
const res = run('xdotool', ['getdisplaygeometry']);
|
|
492
|
+
if (res.ok) {
|
|
493
|
+
const [w, h] = res.stdout.trim().split(/\s+/).map(Number);
|
|
494
|
+
if (w && h) return { width: w, height: h };
|
|
495
|
+
}
|
|
496
|
+
const xr = run('xrandr', ['--current']);
|
|
497
|
+
const m = /current (\d+) x (\d+)/.exec(xr.stdout);
|
|
498
|
+
if (m) return { width: Number(m[1]), height: Number(m[2]) };
|
|
499
|
+
return { width: 1280, height: 800 };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function linuxWindows() {
|
|
503
|
+
const res = run('wmctrl', ['-lG']);
|
|
504
|
+
const out = [];
|
|
505
|
+
if (res.ok) {
|
|
506
|
+
for (const line of res.stdout.split('\n').filter(Boolean)) {
|
|
507
|
+
const parts = line.split(/\s+/);
|
|
508
|
+
const x = Number(parts[2]);
|
|
509
|
+
const y = Number(parts[3]);
|
|
510
|
+
const width = Number(parts[4]);
|
|
511
|
+
const height = Number(parts[5]);
|
|
512
|
+
const name = parts.slice(7).join(' ');
|
|
513
|
+
out.push({ name, x, y, width, height, process_id: 0, status: 'normal' });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return out;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// --------------------------------------------------------------------------- //
|
|
520
|
+
// public interface
|
|
521
|
+
// --------------------------------------------------------------------------- //
|
|
522
|
+
|
|
523
|
+
const IMAGE_TOOLS = [
|
|
524
|
+
['gnome-screenshot', (p) => ['-f', p]],
|
|
525
|
+
['scrot', (p) => [p]],
|
|
526
|
+
['import', (p) => ['-window', 'root', p]],
|
|
527
|
+
['grim', (p) => [p]],
|
|
528
|
+
['spectacle', (p) => ['-b', '-n', '-o', p]],
|
|
529
|
+
];
|
|
530
|
+
|
|
531
|
+
const VK = {
|
|
532
|
+
ctrl: 0x11, control: 0x11, alt: 0x12, option: 0x12, shift: 0x10, win: 0x5b, super: 0x5b,
|
|
533
|
+
meta: 0x5b, cmd: 0x5b, command: 0x5b,
|
|
534
|
+
enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, space: 0x20, spacebar: 0x20,
|
|
535
|
+
backspace: 0x08, bs: 0x08, delete: 0x2e, del: 0x2e, insert: 0x2d, ins: 0x2d,
|
|
536
|
+
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22, pgup: 0x21, pgdn: 0x22,
|
|
537
|
+
up: 0x26, down: 0x28, left: 0x25, right: 0x27, arrowup: 0x26, arrowdown: 0x28,
|
|
538
|
+
arrowleft: 0x25, arrowright: 0x27,
|
|
539
|
+
capslock: 0x14, numlock: 0x90, scrolllock: 0x91, printscreen: 0x2c, prtsc: 0x2c,
|
|
540
|
+
pause: 0x13, menu: 0x5d, contextmenu: 0x5d, apps: 0x5d,
|
|
541
|
+
plus: 0xbb, minus: 0xbd, comma: 0xbc, period: 0xbe, slash: 0xbf, backslash: 0xdc,
|
|
542
|
+
semicolon: 0xba, quote: 0xde, bracketleft: 0xdb, bracketright: 0xdd, backtick: 0xc0,
|
|
543
|
+
numpad0: 0x60, numpad1: 0x61, numpad2: 0x62, numpad3: 0x63, numpad4: 0x64,
|
|
544
|
+
numpad5: 0x65, numpad6: 0x66, numpad7: 0x67, numpad8: 0x68, numpad9: 0x69,
|
|
545
|
+
};
|
|
546
|
+
for (let i = 1; i <= 24; i += 1) VK[`f${i}`] = 0x6f + i;
|
|
547
|
+
|
|
548
|
+
// keys that need KEYEVENTF_EXTENDEDKEY, like windows-use's kScExtendedVks
|
|
549
|
+
const EXTENDED = new Set([
|
|
550
|
+
0x2e, 0x2d, 0x24, 0x23, 0x21, 0x22, 0x26, 0x28, 0x25, 0x27, 0x5b, 0x5d, 0x2c, 0x90, 0x5d,
|
|
551
|
+
0x6f + 11, 0x6f + 12,
|
|
552
|
+
]);
|
|
553
|
+
const MODIFIERS = new Set(['ctrl', 'control', 'alt', 'option', 'shift', 'win', 'super', 'meta', 'cmd', 'command']);
|
|
554
|
+
|
|
555
|
+
function keyboardSequence(combo) {
|
|
556
|
+
// "ctrl+shift+s" -> [{ vk, flags, ext }] for keybd_event, the call windows-use makes too
|
|
557
|
+
const parts = String(combo || '')
|
|
558
|
+
.toLowerCase()
|
|
559
|
+
.split('+')
|
|
560
|
+
.map((p) => p.trim())
|
|
561
|
+
.filter(Boolean);
|
|
562
|
+
if (!parts.length) return [];
|
|
563
|
+
let repeat = 1;
|
|
564
|
+
const modVks = [];
|
|
565
|
+
const mainVks = [];
|
|
566
|
+
for (const key of parts) {
|
|
567
|
+
if (key === 'repeat' || /^x\d+$/.test(key)) {
|
|
568
|
+
repeat = key === 'repeat' ? 2 : Number(key.slice(1));
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
let vk = VK[key];
|
|
572
|
+
if (vk === undefined && key.length === 1) vk = key.toUpperCase().charCodeAt(0);
|
|
573
|
+
if (vk === undefined) continue;
|
|
574
|
+
if (MODIFIERS.has(key)) modVks.push(vk);
|
|
575
|
+
else mainVks.push(vk);
|
|
576
|
+
}
|
|
577
|
+
const seq = [];
|
|
578
|
+
const times = Math.max(1, Math.min(50, repeat));
|
|
579
|
+
for (let i = 0; i < times; i += 1) {
|
|
580
|
+
if (!mainVks.length && modVks.length) {
|
|
581
|
+
// "win" on its own: press and release the modifier itself
|
|
582
|
+
const only = modVks[modVks.length - 1];
|
|
583
|
+
seq.push({ vk: only, flags: 0, ext: EXTENDED.has(only) });
|
|
584
|
+
seq.push({ vk: only, flags: 2, ext: EXTENDED.has(only) });
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
for (const vk of modVks) seq.push({ vk, flags: 0, ext: EXTENDED.has(vk) });
|
|
588
|
+
for (const vk of mainVks) {
|
|
589
|
+
seq.push({ vk, flags: 0, ext: EXTENDED.has(vk) });
|
|
590
|
+
seq.push({ vk, flags: 2, ext: EXTENDED.has(vk) });
|
|
591
|
+
}
|
|
592
|
+
for (const vk of [...modVks].reverse()) seq.push({ vk, flags: 2, ext: EXTENDED.has(vk) });
|
|
593
|
+
}
|
|
594
|
+
return seq;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// windows-use matches window names with fuzzy scores (wu::fuzzy::WRatio); the same
|
|
598
|
+
// idea keeps `ui app --name chrome` working when the title is "Google Chrome — …".
|
|
599
|
+
const fuzzyScore = (needle, haystack) => {
|
|
600
|
+
const a = String(needle || '').toLowerCase().trim();
|
|
601
|
+
const b = String(haystack || '').toLowerCase().trim();
|
|
602
|
+
if (!a || !b) return 0;
|
|
603
|
+
if (a === b) return 1;
|
|
604
|
+
if (b === a || b.startsWith(a)) return 0.95;
|
|
605
|
+
if (b.includes(a)) return 0.85;
|
|
606
|
+
const tokens = a.split(/\s+/).filter(Boolean);
|
|
607
|
+
const words = b.split(/\s+/).filter(Boolean);
|
|
608
|
+
const hits = tokens.filter((t) => words.some((w) => w.startsWith(t) || t.startsWith(w))).length;
|
|
609
|
+
if (hits) return 0.6 + (0.2 * hits) / tokens.length;
|
|
610
|
+
// character-level overlap, same spirit as partial_ratio
|
|
611
|
+
const chars = new Set(b.replace(/\s+/g, ''));
|
|
612
|
+
const overlap = [...a.replace(/\s+/g, '')].filter((c) => chars.has(c)).length;
|
|
613
|
+
return 0.4 * (overlap / Math.max(1, a.replace(/\s+/g, '').length));
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const AVOIDED = [/arenacode/i, /arena code/i];
|
|
617
|
+
|
|
618
|
+
function pickWindow(list, name) {
|
|
619
|
+
const candidates = (list || []).filter((w) => w && w.name && !AVOIDED.some((re) => re.test(w.name)));
|
|
620
|
+
let best = null;
|
|
621
|
+
for (const w of candidates) {
|
|
622
|
+
const score = fuzzyScore(name, w.name);
|
|
623
|
+
if (score >= 0.6 && (!best || score > best.score)) best = { ...w, score };
|
|
624
|
+
}
|
|
625
|
+
return best;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const automation = {
|
|
629
|
+
platform: process.platform,
|
|
630
|
+
|
|
631
|
+
available() {
|
|
632
|
+
if (isWin) return { ok: true, backend: 'windows', detail: 'PowerShell + user32' };
|
|
633
|
+
if (isMac) {
|
|
634
|
+
const mouse = which('cliclick');
|
|
635
|
+
return {
|
|
636
|
+
ok: true,
|
|
637
|
+
backend: 'macos',
|
|
638
|
+
detail: mouse ? 'osascript + cliclick' : 'osascript (mouse needs cliclick: brew install cliclick)',
|
|
639
|
+
mouse: Boolean(mouse),
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
if (isLinux) {
|
|
643
|
+
const xdotool = which('xdotool');
|
|
644
|
+
if (!xdotool) {
|
|
645
|
+
return { ok: false, backend: 'linux', error: 'xdotool not installed: sudo apt install xdotool (or dnf/pacman)' };
|
|
646
|
+
}
|
|
647
|
+
return { ok: true, backend: 'linux', detail: 'xdotool' };
|
|
648
|
+
}
|
|
649
|
+
return { ok: false, backend: null, error: `unsupported platform: ${process.platform}` };
|
|
650
|
+
},
|
|
651
|
+
|
|
652
|
+
screenSize() {
|
|
653
|
+
if (isWin) {
|
|
654
|
+
const r = winRun({ action: 'screen' });
|
|
655
|
+
return r.ok ? { ok: true, width: r.width, height: r.height } : fail(r.error);
|
|
656
|
+
}
|
|
657
|
+
if (isMac) {
|
|
658
|
+
const s = macScreen();
|
|
659
|
+
return { ok: true, ...s };
|
|
660
|
+
}
|
|
661
|
+
const s = linuxScreen();
|
|
662
|
+
return { ok: true, ...s };
|
|
663
|
+
},
|
|
664
|
+
|
|
665
|
+
windows() {
|
|
666
|
+
if (isWin) {
|
|
667
|
+
const r = winRun({ action: 'windows' }, 45000);
|
|
668
|
+
if (!r.ok) return fail(r.error);
|
|
669
|
+
return { ok: true, windows: r.windows || [] };
|
|
670
|
+
}
|
|
671
|
+
const list = isMac ? macWindows() : linuxWindows();
|
|
672
|
+
return { ok: true, windows: list };
|
|
673
|
+
},
|
|
674
|
+
|
|
675
|
+
activeWindow() {
|
|
676
|
+
if (isWin) {
|
|
677
|
+
const r = winRun({ action: 'active' });
|
|
678
|
+
return r.ok ? { ok: true, name: r.name, handle: r.handle } : fail(r.error);
|
|
679
|
+
}
|
|
680
|
+
const w = this.windows();
|
|
681
|
+
const first = w.ok && w.windows.length ? w.windows[0] : null;
|
|
682
|
+
return { ok: true, name: first ? first.name : '', handle: first ? first.handle : null };
|
|
683
|
+
},
|
|
684
|
+
|
|
685
|
+
/** Finds the window the user means, the way windows-use does: best fuzzy match. */
|
|
686
|
+
findWindow(name) {
|
|
687
|
+
const list = this.windows();
|
|
688
|
+
if (!list.ok) return list;
|
|
689
|
+
const hit = pickWindow(list.windows, name);
|
|
690
|
+
if (!hit) return { ok: false, error: `window not found: ${name}` };
|
|
691
|
+
return { ok: true, window: hit };
|
|
692
|
+
},
|
|
693
|
+
|
|
694
|
+
launchApp(name) {
|
|
695
|
+
if (isWin) {
|
|
696
|
+
const r = winRun({ action: 'launch', name }, 45000);
|
|
697
|
+
return r.ok ? { ok: true, app: name } : fail(r.error);
|
|
698
|
+
}
|
|
699
|
+
if (isMac) {
|
|
700
|
+
const res = run('open', ['-a', name], { timeout: 30000 });
|
|
701
|
+
return res.ok ? { ok: true, app: name } : fail(res.stderr.trim() || `open -a ${name} failed`);
|
|
702
|
+
}
|
|
703
|
+
const direct = run('sh', ['-c', `command -v ${JSON.stringify(name)} >/dev/null && setsid ${JSON.stringify(name)} >/dev/null 2>&1 &`]);
|
|
704
|
+
if (direct.ok) return { ok: true, app: name };
|
|
705
|
+
const app = run('gtk-launch', [name]);
|
|
706
|
+
if (app.ok) return { ok: true, app: name };
|
|
707
|
+
return fail(`app not found: ${name}`);
|
|
708
|
+
},
|
|
709
|
+
|
|
710
|
+
focusWindow(name) {
|
|
711
|
+
if (isWin) {
|
|
712
|
+
const found = this.findWindow(name);
|
|
713
|
+
if (!found.ok) return found;
|
|
714
|
+
const r = winRun({ action: 'focus', handle: found.window.handle });
|
|
715
|
+
return r.ok ? { ok: true, name: r.name, handle: found.window.handle, matched: found.window.name } : fail(r.error);
|
|
716
|
+
}
|
|
717
|
+
if (isMac) {
|
|
718
|
+
const res = osa(`tell application "System Events" to set frontmost of process ${JSON.stringify(name)} to true`);
|
|
719
|
+
return res.ok ? { ok: true, name } : fail(res.error);
|
|
720
|
+
}
|
|
721
|
+
const res = run('wmctrl', ['-a', name]);
|
|
722
|
+
return res.ok ? { ok: true, name } : fail(res.stderr.trim() || `window not found: ${name}`);
|
|
723
|
+
},
|
|
724
|
+
|
|
725
|
+
resizeWindow(name, x, y, width, height) {
|
|
726
|
+
if (isWin) {
|
|
727
|
+
const found = this.findWindow(name);
|
|
728
|
+
if (!found.ok) return found;
|
|
729
|
+
const r = winRun({
|
|
730
|
+
action: 'resize',
|
|
731
|
+
handle: found.window.handle,
|
|
732
|
+
x,
|
|
733
|
+
y,
|
|
734
|
+
width,
|
|
735
|
+
height,
|
|
736
|
+
});
|
|
737
|
+
return r.ok ? { ok: true, name: r.name, x, y, width, height } : fail(r.error);
|
|
738
|
+
}
|
|
739
|
+
if (isMac) {
|
|
740
|
+
const res = osa(
|
|
741
|
+
`tell application "System Events" to tell process ${JSON.stringify(name)} to set {position, size} of window 1 to {{${x}, ${y}}, {${width}, ${height}}}`
|
|
742
|
+
);
|
|
743
|
+
return res.ok ? { ok: true, name, x, y, width, height } : fail(res.error);
|
|
744
|
+
}
|
|
745
|
+
const res = run('wmctrl', ['-r', name, '-e', `0,${x},${y},${width},${height}`]);
|
|
746
|
+
return res.ok ? { ok: true, name, x, y, width, height } : fail(res.stderr.trim() || 'resize failed');
|
|
747
|
+
},
|
|
748
|
+
|
|
749
|
+
/** The UI Automation tree of the focused window: what the desktop's windows-use returns. */
|
|
750
|
+
state(handle = null) {
|
|
751
|
+
if (isWin) {
|
|
752
|
+
const r = winRun({ action: 'state', handle }, 60000);
|
|
753
|
+
if (!r.ok) return fail(r.error);
|
|
754
|
+
return {
|
|
755
|
+
ok: true,
|
|
756
|
+
windows: r.windows || [],
|
|
757
|
+
active_window: r.active_window || null,
|
|
758
|
+
interactive_elements: r.interactive_elements || [],
|
|
759
|
+
scrollable_elements: r.scrollable_elements || [],
|
|
760
|
+
interactive_elements_text: r.interactive_elements_text || '',
|
|
761
|
+
scrollable_elements_text: r.scrollable_elements_text || '',
|
|
762
|
+
system: r.system || {},
|
|
763
|
+
metadata: r.metadata || {},
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
// no accessibility tree on the other platforms: windows, geometry and pixels
|
|
767
|
+
const wins = this.windows();
|
|
768
|
+
const screen = this.screenSize();
|
|
769
|
+
const active = wins.ok && wins.windows.length ? wins.windows[0] : null;
|
|
770
|
+
const elements = (wins.ok ? wins.windows : []).map((w) => ({
|
|
771
|
+
name: w.name,
|
|
772
|
+
control_type: 'Window',
|
|
773
|
+
kind: 'Window',
|
|
774
|
+
bounding_box: { left: w.x, top: w.y, right: w.x + w.width, bottom: w.y + w.height },
|
|
775
|
+
center: { x: w.x + Math.floor(w.width / 2), y: w.y + Math.floor(w.height / 2) },
|
|
776
|
+
value: '',
|
|
777
|
+
enabled: true,
|
|
778
|
+
automation_id: '',
|
|
779
|
+
}));
|
|
780
|
+
return {
|
|
781
|
+
ok: true,
|
|
782
|
+
windows: wins.ok ? wins.windows : [],
|
|
783
|
+
active_window: active,
|
|
784
|
+
interactive_elements: elements,
|
|
785
|
+
scrollable_elements: [],
|
|
786
|
+
interactive_elements_text: elements
|
|
787
|
+
.map((e, i) => `${i + 1}. ${e.control_type} '${e.name}' at (${e.center.x}, ${e.center.y})`)
|
|
788
|
+
.join('\n'),
|
|
789
|
+
scrollable_elements_text: '',
|
|
790
|
+
system: this.sysinfo().system,
|
|
791
|
+
metadata: { kind: 'windows-only-list', depth: 'top-level', all_desktops: false },
|
|
792
|
+
};
|
|
793
|
+
},
|
|
794
|
+
|
|
795
|
+
click(x, y, { button = 'left', count = 1 } = {}) {
|
|
796
|
+
if (isWin) {
|
|
797
|
+
const r = winRun({ action: 'click', x, y, button, count });
|
|
798
|
+
return r.ok ? { ok: true, x, y, button, count } : fail(r.error);
|
|
799
|
+
}
|
|
800
|
+
if (isMac) {
|
|
801
|
+
if (!which('cliclick')) return fail('mouse needs cliclick: brew install cliclick');
|
|
802
|
+
const arg = `${button === 'right' ? 'rc' : 'c'}:${x},${y}`;
|
|
803
|
+
const rounds = Math.max(1, count);
|
|
804
|
+
for (let i = 0; i < rounds; i += 1) {
|
|
805
|
+
const res = run('cliclick', [arg]);
|
|
806
|
+
if (!res.ok) return fail(res.stderr.trim() || 'cliclick failed');
|
|
807
|
+
}
|
|
808
|
+
return { ok: true, x, y, button, count };
|
|
809
|
+
}
|
|
810
|
+
const btn = button === 'right' ? '3' : button === 'middle' ? '2' : '1';
|
|
811
|
+
const res = run('xdotool', ['mousemove', String(x), String(y), 'click', '--repeat', String(Math.max(1, count)), btn]);
|
|
812
|
+
return res.ok ? { ok: true, x, y, button, count } : fail(res.stderr.trim() || 'xdotool click failed');
|
|
813
|
+
},
|
|
814
|
+
|
|
815
|
+
moveMouse(x, y) {
|
|
816
|
+
if (isWin) {
|
|
817
|
+
const r = winRun({ action: 'move', x, y });
|
|
818
|
+
return r.ok ? { ok: true, x, y } : fail(r.error);
|
|
819
|
+
}
|
|
820
|
+
if (isMac) {
|
|
821
|
+
if (!which('cliclick')) return fail('mouse needs cliclick: brew install cliclick');
|
|
822
|
+
const res = run('cliclick', [`m:${x},${y}`]);
|
|
823
|
+
return res.ok ? { ok: true, x, y } : fail(res.stderr.trim() || 'cliclick failed');
|
|
824
|
+
}
|
|
825
|
+
const res = run('xdotool', ['mousemove', String(x), String(y)]);
|
|
826
|
+
return res.ok ? { ok: true, x, y } : fail(res.stderr.trim() || 'xdotool failed');
|
|
827
|
+
},
|
|
828
|
+
|
|
829
|
+
mouseButton(x, y, button, down) {
|
|
830
|
+
if (isWin) {
|
|
831
|
+
const r = winRun({ action: down ? 'down' : 'up', x, y, button });
|
|
832
|
+
return r.ok ? { ok: true, x, y, button } : fail(r.error);
|
|
833
|
+
}
|
|
834
|
+
if (isMac) {
|
|
835
|
+
if (!which('cliclick')) return fail('mouse needs cliclick: brew install cliclick');
|
|
836
|
+
const arg = `dd:${x},${y}`;
|
|
837
|
+
if (down) return run('cliclick', [arg]).ok ? { ok: true } : fail('cliclick failed');
|
|
838
|
+
return run('cliclick', [`du:${x},${y}`]).ok ? { ok: true } : fail('cliclick failed');
|
|
839
|
+
}
|
|
840
|
+
const btn = button === 'right' ? '3' : button === 'middle' ? '2' : '1';
|
|
841
|
+
const res = run('xdotool', [down ? 'mousedown' : 'mouseup', btn]);
|
|
842
|
+
return res.ok ? { ok: true } : fail(res.stderr.trim() || 'xdotool failed');
|
|
843
|
+
},
|
|
844
|
+
|
|
845
|
+
drag(x, y, x2, y2) {
|
|
846
|
+
if (isWin) {
|
|
847
|
+
const r = winRun({ action: 'drag', x, y, x2, y2 });
|
|
848
|
+
return r.ok ? { ok: true, from: [x, y], to: [x2, y2] } : fail(r.error);
|
|
849
|
+
}
|
|
850
|
+
if (isMac) {
|
|
851
|
+
if (!which('cliclick')) return fail('mouse needs cliclick: brew install cliclick');
|
|
852
|
+
const res = run('cliclick', [`dd:${x},${y}`, `dm:${x2},${y2}`, `du:${x2},${y2}`]);
|
|
853
|
+
return res.ok ? { ok: true, from: [x, y], to: [x2, y2] } : fail(res.stderr.trim() || 'cliclick failed');
|
|
854
|
+
}
|
|
855
|
+
const res = run('xdotool', ['mousemove', String(x), String(y), 'mousedown', '1', 'mousemove', String(x2), String(y2), 'mouseup', '1']);
|
|
856
|
+
return res.ok ? { ok: true, from: [x, y], to: [x2, y2] } : fail(res.stderr.trim() || 'xdotool failed');
|
|
857
|
+
},
|
|
858
|
+
|
|
859
|
+
scroll(amount, x = null, y = null) {
|
|
860
|
+
const clicks = Math.trunc(amount) || 1;
|
|
861
|
+
if (isWin) {
|
|
862
|
+
const r = winRun({ action: 'scroll', amount: clicks, x, y });
|
|
863
|
+
return r.ok ? { ok: true, amount: clicks } : fail(r.error);
|
|
864
|
+
}
|
|
865
|
+
const button = clicks > 0 ? '4' : '5';
|
|
866
|
+
const args = [];
|
|
867
|
+
if (x !== null && y !== null) args.push('mousemove', String(x), String(y));
|
|
868
|
+
args.push('click', '--repeat', String(Math.abs(clicks)), button);
|
|
869
|
+
if (isMac) {
|
|
870
|
+
if (!which('cliclick')) return fail('mouse needs cliclick: brew install cliclick');
|
|
871
|
+
const res = run('cliclick', [`w:${clicks > 0 ? 'up' : 'down'}`]);
|
|
872
|
+
return res.ok ? { ok: true, amount: clicks } : fail(res.stderr.trim());
|
|
873
|
+
}
|
|
874
|
+
const res = run('xdotool', args);
|
|
875
|
+
return res.ok ? { ok: true, amount: clicks } : fail(res.stderr.trim() || 'xdotool failed');
|
|
876
|
+
},
|
|
877
|
+
|
|
878
|
+
typeText(text, x = null, y = null) {
|
|
879
|
+
if (isWin) {
|
|
880
|
+
const r = winRun({ action: 'type', text, x, y }, 60000);
|
|
881
|
+
return r.ok ? { ok: true, chars: text.length, typed: r.typed ?? text.length } : fail(r.error);
|
|
882
|
+
}
|
|
883
|
+
if (x !== null && y !== null) this.click(x, y);
|
|
884
|
+
if (isMac) {
|
|
885
|
+
const res = osa(`tell application "System Events" to keystroke ${JSON.stringify(text)}`);
|
|
886
|
+
return res.ok ? { ok: true, chars: text.length, typed: text.length } : fail(res.error);
|
|
887
|
+
}
|
|
888
|
+
const res = run('xdotool', ['type', '--clearmodifiers', '--delay', '12', text], { timeout: 120000 });
|
|
889
|
+
return res.ok ? { ok: true, chars: text.length, typed: text.length } : fail(res.stderr.trim() || 'xdotool type failed');
|
|
890
|
+
},
|
|
891
|
+
|
|
892
|
+
pressKeys(combo) {
|
|
893
|
+
const sequence = keyboardSequence(combo);
|
|
894
|
+
if (!sequence.length) return fail(`unreadable key combination: ${combo}`);
|
|
895
|
+
if (isWin) {
|
|
896
|
+
const r = winRun({ action: 'keys', sequence });
|
|
897
|
+
return r.ok ? { ok: true, keys: combo } : fail(r.error);
|
|
898
|
+
}
|
|
899
|
+
if (isMac) {
|
|
900
|
+
const res = macKeys(combo);
|
|
901
|
+
return res.ok ? { ok: true, keys: combo } : fail(res.error);
|
|
902
|
+
}
|
|
903
|
+
const mapped = String(combo)
|
|
904
|
+
.toLowerCase()
|
|
905
|
+
.split('+')
|
|
906
|
+
.map((p) => (p === 'ctrl' ? 'ctrl' : p))
|
|
907
|
+
.join('+');
|
|
908
|
+
const res = run('xdotool', ['key', '--clearmodifiers', mapped]);
|
|
909
|
+
return res.ok ? { ok: true, keys: combo } : fail(res.stderr.trim() || 'xdotool key failed');
|
|
910
|
+
},
|
|
911
|
+
|
|
912
|
+
screenshot(dest, { annotate = false } = {}) {
|
|
913
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
914
|
+
if (isWin) {
|
|
915
|
+
const r = winRun({ action: 'shot', path: dest, annotate }, 90000);
|
|
916
|
+
return r.ok
|
|
917
|
+
? { ok: true, file: dest, width: r.width, height: r.height, mime: 'image/png', annotated: Boolean(annotate), elements: r.elements || 0 }
|
|
918
|
+
: fail(r.error);
|
|
919
|
+
}
|
|
920
|
+
if (isMac) {
|
|
921
|
+
const res = run('screencapture', ['-x', '-t', 'png', dest], { timeout: 30000 });
|
|
922
|
+
if (!res.ok) return fail(res.stderr.trim() || 'screencapture failed');
|
|
923
|
+
const size = this.screenSize();
|
|
924
|
+
return { ok: true, file: dest, width: size.width, height: size.height, mime: 'image/png' };
|
|
925
|
+
}
|
|
926
|
+
for (const [tool, args] of IMAGE_TOOLS) {
|
|
927
|
+
if (!which(tool)) continue;
|
|
928
|
+
const res = run(tool, args(dest), { timeout: 30000 });
|
|
929
|
+
if (res.ok && fs.existsSync(dest)) {
|
|
930
|
+
const size = this.screenSize();
|
|
931
|
+
return { ok: true, file: dest, width: size.width, height: size.height, mime: 'image/png' };
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
return fail('no screenshot tool found: install imagemagick (import), scrot, grim or gnome-screenshot');
|
|
935
|
+
},
|
|
936
|
+
|
|
937
|
+
listApps() {
|
|
938
|
+
if (isWin) {
|
|
939
|
+
const r = winRun({ action: 'apps' }, 60000);
|
|
940
|
+
return r.ok ? { ok: true, apps: r.apps || [] } : fail(r.error);
|
|
941
|
+
}
|
|
942
|
+
const out = [];
|
|
943
|
+
if (isMac) {
|
|
944
|
+
for (const dir of ['/Applications', '/System/Applications']) {
|
|
945
|
+
try {
|
|
946
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
947
|
+
if (entry.endsWith('.app')) out.push({ name: entry.replace(/\.app$/, ''), app_id: path.join(dir, entry) });
|
|
948
|
+
}
|
|
949
|
+
} catch {
|
|
950
|
+
/* ignore */
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return { ok: true, apps: out };
|
|
954
|
+
}
|
|
955
|
+
const dirs = ['/usr/share/applications', '/usr/local/share/applications', path.join(os.homedir(), '.local/share/applications')];
|
|
956
|
+
for (const dir of dirs) {
|
|
957
|
+
let files = [];
|
|
958
|
+
try {
|
|
959
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith('.desktop'));
|
|
960
|
+
} catch {
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
for (const f of files) {
|
|
964
|
+
try {
|
|
965
|
+
const text = fs.readFileSync(path.join(dir, f), 'utf8');
|
|
966
|
+
if (/^NoDisplay=true/m.test(text)) continue;
|
|
967
|
+
const name = /^Name=(.+)$/m.exec(text);
|
|
968
|
+
if (name) out.push({ name: name[1].trim(), app_id: f.replace(/\.desktop$/, '') });
|
|
969
|
+
} catch {
|
|
970
|
+
/* ignore */
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
return { ok: true, apps: out };
|
|
975
|
+
},
|
|
976
|
+
|
|
977
|
+
sysinfo() {
|
|
978
|
+
const screen = this.screenSize();
|
|
979
|
+
return {
|
|
980
|
+
ok: true,
|
|
981
|
+
system: {
|
|
982
|
+
windows_version: isWin ? os.release() : `${process.platform} ${os.release()}`,
|
|
983
|
+
os_version: os.release(),
|
|
984
|
+
screen_size: { width: screen.width, height: screen.height },
|
|
985
|
+
user_account_type: 'Local Account',
|
|
986
|
+
default_language: process.env.LANG || 'en',
|
|
987
|
+
dpi_scaling: 1,
|
|
988
|
+
},
|
|
989
|
+
};
|
|
990
|
+
},
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
module.exports = { ...automation, keyboardSequence, fuzzyScore, pickWindow };
|
package/src/local.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { decide } = require('./approval');
|
|
4
|
+
const automation = require('./automation');
|
|
5
|
+
const { stamp } = require('./clipboard');
|
|
4
6
|
const session = require('./session');
|
|
5
7
|
|
|
6
8
|
const fs = require('node:fs');
|
|
@@ -501,17 +503,161 @@ async function handleLocalUi(bridge, taskId, request, opts) {
|
|
|
501
503
|
}
|
|
502
504
|
|
|
503
505
|
// control / type / keystroke / screenshot / app / state / apps / multi_*
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
506
|
+
const result = await runUiAction(bridge, taskId, request, opts);
|
|
507
|
+
const ok = result && result.ok !== false;
|
|
508
|
+
await bridge.localResult(taskId, {
|
|
509
|
+
rid,
|
|
510
|
+
output: JSON.stringify(result),
|
|
511
|
+
code: ok ? 0 : 1,
|
|
512
|
+
});
|
|
513
|
+
const line = ok
|
|
514
|
+
? `${c.green('✓')} ${c.gray(op)}${request.action ? c.gray(` ${request.action}`) : ''}`
|
|
515
|
+
: `${c.red('✗')} ${c.gray(op)} ${c.gray('— ' + (result && result.error ? result.error : 'failed'))}`;
|
|
516
|
+
write(` ${line}`);
|
|
512
517
|
return true;
|
|
513
518
|
}
|
|
514
519
|
|
|
520
|
+
/** Computer Agent actions: mouse, keyboard, screen, windows, apps. */
|
|
521
|
+
async function runUiAction(bridge, taskId, request, opts) {
|
|
522
|
+
const { op, rid } = request;
|
|
523
|
+
const lang = opts.lang;
|
|
524
|
+
const toolkit = automation.available();
|
|
525
|
+
const json = (text) => {
|
|
526
|
+
try {
|
|
527
|
+
return JSON.parse(text || '{}');
|
|
528
|
+
} catch {
|
|
529
|
+
return {};
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
if (op === 'state') {
|
|
534
|
+
// the desktop's windows-use returns the UI Automation tree; so do we on Windows
|
|
535
|
+
const state = automation.state();
|
|
536
|
+
if (!state.ok) return state;
|
|
537
|
+
return state;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (op === 'sysinfo') {
|
|
541
|
+
const info = automation.sysinfo();
|
|
542
|
+
return {
|
|
543
|
+
...info,
|
|
544
|
+
os: `${process.platform} ${os.release()}`,
|
|
545
|
+
hostname: os.hostname(),
|
|
546
|
+
arch: os.arch(),
|
|
547
|
+
cpus: os.cpus().length,
|
|
548
|
+
memory_mb: Math.round(os.totalmem() / 1048576),
|
|
549
|
+
node: process.version,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (op === 'apps') return automation.listApps();
|
|
554
|
+
|
|
555
|
+
if (!toolkit.ok) {
|
|
556
|
+
return {
|
|
557
|
+
ok: false,
|
|
558
|
+
error:
|
|
559
|
+
toolkit.error ||
|
|
560
|
+
t(lang, 'automazione non disponibile su questo sistema', 'automation is not available on this system', 'la automatización no está disponible en este sistema'),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (op === 'control') {
|
|
565
|
+
const { action, x, y, x2, y2, amount, button } = request;
|
|
566
|
+
switch (action) {
|
|
567
|
+
case 'move':
|
|
568
|
+
return automation.moveMouse(x, y);
|
|
569
|
+
case 'click':
|
|
570
|
+
return automation.click(x, y, { button: button || 'left', count: 1 });
|
|
571
|
+
case 'dblclick':
|
|
572
|
+
return automation.click(x, y, { button: button || 'left', count: 2 });
|
|
573
|
+
case 'rightclick':
|
|
574
|
+
return automation.click(x, y, { button: 'right', count: 1 });
|
|
575
|
+
case 'down':
|
|
576
|
+
return automation.mouseButton(x, y, button || 'left', true);
|
|
577
|
+
case 'up':
|
|
578
|
+
return automation.mouseButton(x, y, button || 'left', false);
|
|
579
|
+
case 'drag':
|
|
580
|
+
return automation.drag(x, y, x2, y2);
|
|
581
|
+
case 'scroll':
|
|
582
|
+
return automation.scroll(amount ?? 1, x ?? null, y ?? null);
|
|
583
|
+
default:
|
|
584
|
+
return { ok: false, error: `unknown control action: ${action}` };
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (op === 'keystroke') {
|
|
589
|
+
return automation.pressKeys(request.keys || '');
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (op === 'type') {
|
|
593
|
+
let text = request.text;
|
|
594
|
+
if (request.payload || text === undefined) {
|
|
595
|
+
const res = await bridge.uiPayload(taskId, rid);
|
|
596
|
+
text = res.status === 200 ? (res.json && (res.json.text ?? res.json.payload ?? '')) : '';
|
|
597
|
+
if (!text) return { ok: false, error: 'the typed text could not be read from the bridge' };
|
|
598
|
+
}
|
|
599
|
+
return automation.typeText(String(text), request.x ?? null, request.y ?? null);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (op === 'app') {
|
|
603
|
+
const name = request.text || request.name || '';
|
|
604
|
+
const action = request.action || 'launch';
|
|
605
|
+
if (action === 'launch') return automation.launchApp(name);
|
|
606
|
+
if (action === 'switch') return automation.focusWindow(name);
|
|
607
|
+
if (action === 'resize') {
|
|
608
|
+
return automation.resizeWindow(name, request.x ?? 0, request.y ?? 0, request.x2 ?? 800, request.y2 ?? 600);
|
|
609
|
+
}
|
|
610
|
+
return { ok: false, error: `unknown app action: ${action}` };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (op === 'screenshot') {
|
|
614
|
+
const dir = path.join(os.tmpdir(), 'arenacode-shots');
|
|
615
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
616
|
+
const file = path.join(dir, `shot-${stamp()}.png`);
|
|
617
|
+
const shot = automation.screenshot(file, { annotate: Boolean(request.annotated) });
|
|
618
|
+
if (!shot.ok) return shot;
|
|
619
|
+
const buf = fs.readFileSync(shot.file);
|
|
620
|
+
// the agent runs in the sandbox: the image travels through the bridge workspace
|
|
621
|
+
const up = await bridge.upload(`uploads/${stamp()}/shot-${stamp()}.png`, buf);
|
|
622
|
+
const remote = up.status === 200 ? up.json.path : null;
|
|
623
|
+
const out = {
|
|
624
|
+
ok: true,
|
|
625
|
+
mime: 'image/png',
|
|
626
|
+
width: shot.width,
|
|
627
|
+
height: shot.height,
|
|
628
|
+
file: remote || shot.file,
|
|
629
|
+
local_file: shot.file,
|
|
630
|
+
annotated: Boolean(request.annotated),
|
|
631
|
+
};
|
|
632
|
+
if (buf.length <= 2 * 1024 * 1024) out.image = buf.toString('base64');
|
|
633
|
+
return out;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (op === 'multi_select') {
|
|
637
|
+
const { elements = [], press_ctrl = true } = json(request.args_json);
|
|
638
|
+
const modifier = press_ctrl ? automation.pressKeys('ctrl') : null;
|
|
639
|
+
if (modifier && !modifier.ok) return modifier;
|
|
640
|
+
for (const [x, y] of elements) {
|
|
641
|
+
const r = automation.click(x, y, { button: 'left', count: 1 });
|
|
642
|
+
if (!r.ok) return r;
|
|
643
|
+
}
|
|
644
|
+
return { ok: true, selected: elements.length };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (op === 'multi_edit') {
|
|
648
|
+
const { elements = [] } = json(request.args_json);
|
|
649
|
+
let typed = 0;
|
|
650
|
+
for (const [x, y, text] of elements) {
|
|
651
|
+
const r = automation.typeText(String(text), x, y);
|
|
652
|
+
if (!r.ok) return r;
|
|
653
|
+
typed += 1;
|
|
654
|
+
}
|
|
655
|
+
return { ok: true, fields: typed };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return { ok: false, error: `unknown ui operation: ${op}` };
|
|
659
|
+
}
|
|
660
|
+
|
|
515
661
|
function available() {
|
|
516
662
|
return os.release();
|
|
517
663
|
}
|
|
@@ -536,6 +682,7 @@ async function handleLocalFile(bridge, taskId, request, opts) {
|
|
|
536
682
|
}
|
|
537
683
|
|
|
538
684
|
module.exports = {
|
|
685
|
+
runUiAction,
|
|
539
686
|
REFUSED,
|
|
540
687
|
resolveInRoot,
|
|
541
688
|
runShell,
|
package/src/tui/app.js
CHANGED
|
@@ -1048,10 +1048,17 @@ class App {
|
|
|
1048
1048
|
item.state = 'done';
|
|
1049
1049
|
return;
|
|
1050
1050
|
}
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1051
|
+
// mouse, keyboard, screen, windows and apps: the automation backends
|
|
1052
|
+
const { runUiAction } = require('../local');
|
|
1053
|
+
const result = await runUiAction(this.bridge, taskId, payload, { lang: this.lang });
|
|
1054
|
+
const ok = result && result.ok !== false;
|
|
1055
|
+
await this.bridge.localResult(taskId, {
|
|
1056
|
+
rid: payload.rid,
|
|
1057
|
+
output: JSON.stringify(result),
|
|
1058
|
+
code: ok ? 0 : 1,
|
|
1059
|
+
});
|
|
1060
|
+
item.state = ok ? 'done' : 'failed';
|
|
1061
|
+
if (!ok && result && result.error) item.detail = String(result.error);
|
|
1055
1062
|
}
|
|
1056
1063
|
}
|
|
1057
1064
|
|
|
@@ -1276,13 +1283,14 @@ class App {
|
|
|
1276
1283
|
})
|
|
1277
1284
|
.map((line) => R.spaces(2) + line);
|
|
1278
1285
|
|
|
1279
|
-
const bodyH = Math.max(1, H - prompt.lines.length -
|
|
1286
|
+
const bodyH = Math.max(1, H - prompt.lines.length - 3);
|
|
1280
1287
|
const maxScroll = Math.max(0, chat.length - bodyH);
|
|
1281
1288
|
st.scroll = Math.min(Math.max(0, st.scroll), maxScroll);
|
|
1282
1289
|
const start = Math.max(0, chat.length - bodyH - st.scroll);
|
|
1283
1290
|
const visible = chat.slice(start, start + bodyH);
|
|
1284
1291
|
while (visible.length < bodyH) visible.unshift('');
|
|
1285
1292
|
frame.push(...visible);
|
|
1293
|
+
frame.push('');
|
|
1286
1294
|
const promptStart = frame.length;
|
|
1287
1295
|
frame.push(...prompt.lines);
|
|
1288
1296
|
frame.push('');
|
|
@@ -1308,7 +1316,11 @@ class App {
|
|
|
1308
1316
|
this.term.draw(frame);
|
|
1309
1317
|
|
|
1310
1318
|
if (!cursor || st.overlay || st.approval || st.prompt) this.term.hideCursor();
|
|
1311
|
-
else
|
|
1319
|
+
else {
|
|
1320
|
+
// frame rows/cols are 0-based, the terminal's CUP escape is 1-based
|
|
1321
|
+
const col = Math.min(W - 1, Math.max(0, cursor.col));
|
|
1322
|
+
this.term.placeCursor(cursor.row + 1, col + 1);
|
|
1323
|
+
}
|
|
1312
1324
|
|
|
1313
1325
|
this.rendering = false;
|
|
1314
1326
|
if (this.dirty) setTimeout(() => this.maybeRender(), 16);
|