@lardexx/arenacode 2.6.2 → 2.7.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 +20 -0
- package/package.json +2 -2
- package/src/automation.js +726 -0
- package/src/local.js +166 -8
- package/src/tui/app.js +18 -6
package/README.md
CHANGED
|
@@ -134,6 +134,26 @@ 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`). **macOS**: grant Accessibility to your terminal, and
|
|
152
|
+
`brew install cliclick` for the mouse. **Linux** (X11): `sudo apt install xdotool wmctrl imagemagick`; on Wayland
|
|
153
|
+
`xdotool` cannot inject input, so Computer Agent needs XWayland. What this client does *not* have is the desktop's UI
|
|
154
|
+
Automation tree: `ui state` returns windows and pixels, with `interactive_elements` empty, and `--annotated`
|
|
155
|
+
screenshots are plain screenshots.
|
|
156
|
+
|
|
137
157
|
**Ask and Proceed / Proceed.** The desktop's Settings switch, `ctrl+x a` here (also `/ask`, `/proceed`,
|
|
138
158
|
`--proceed`, and `arenacode config set approval proceed`). The default, **Ask and Proceed**, asks before commands,
|
|
139
159
|
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.7.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,726 @@
|
|
|
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 -TypeDefinition @'
|
|
59
|
+
using System;
|
|
60
|
+
using System.Text;
|
|
61
|
+
using System.Runtime.InteropServices;
|
|
62
|
+
public class AC {
|
|
63
|
+
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
|
64
|
+
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, int dx, int dy, int data, UIntPtr extra);
|
|
65
|
+
[DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extra);
|
|
66
|
+
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
67
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
|
68
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
|
|
69
|
+
[DllImport("user32.dll")] public static extern bool MoveWindow(IntPtr h, int x, int y, int w, int ht, bool repaint);
|
|
70
|
+
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p);
|
|
71
|
+
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
|
|
72
|
+
[DllImport("user32.dll")] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n);
|
|
73
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
|
74
|
+
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
|
75
|
+
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
|
|
76
|
+
public delegate bool EnumProc(IntPtr h, IntPtr p);
|
|
77
|
+
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
|
|
78
|
+
}
|
|
79
|
+
'@
|
|
80
|
+
|
|
81
|
+
function Get-ACWindows {
|
|
82
|
+
$list = New-Object System.Collections.ArrayList
|
|
83
|
+
foreach ($p in (Get-Process | Where-Object { $_.MainWindowHandle -ne 0 })) {
|
|
84
|
+
try {
|
|
85
|
+
$h = [IntPtr]$p.MainWindowHandle
|
|
86
|
+
$r = New-Object AC+RECT
|
|
87
|
+
[void][AC]::GetWindowRect($h, [ref]$r)
|
|
88
|
+
$title = [string]$p.MainWindowTitle
|
|
89
|
+
if ($title.Trim().Length -eq 0) { continue }
|
|
90
|
+
[void]$list.Add([pscustomobject]@{
|
|
91
|
+
name = $title
|
|
92
|
+
app = [string]$p.ProcessName
|
|
93
|
+
handle = $h.ToInt64()
|
|
94
|
+
x = $r.Left; y = $r.Top
|
|
95
|
+
width = [Math]::Max(0, $r.Right - $r.Left); height = [Math]::Max(0, $r.Bottom - $r.Top)
|
|
96
|
+
process_id = [int]$p.Id
|
|
97
|
+
status = $(if ($p.MainWindowHandle -eq 0) { 'hidden' } else { 'normal' })
|
|
98
|
+
})
|
|
99
|
+
} catch { }
|
|
100
|
+
}
|
|
101
|
+
return $list
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function Find-ACWindow($name) {
|
|
105
|
+
if (-not $name) { return $null }
|
|
106
|
+
$all = Get-ACWindows
|
|
107
|
+
$exact = $all | Where-Object { $_.name -eq $name } | Select-Object -First 1
|
|
108
|
+
if ($exact) { return $exact }
|
|
109
|
+
return ($all | Where-Object { $_.name -like "*$name*" } | Select-Object -First 1)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function Get-ACScreen {
|
|
113
|
+
$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
|
114
|
+
return @{ width = $b.Width; height = $b.Height }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function Send-ACKeys($pairs) {
|
|
118
|
+
foreach ($k in $pairs) {
|
|
119
|
+
if ($k.flags -eq 0) { [AC]::keybd_event([byte]$k.vk, 0, 0, [UIntPtr]::Zero) }
|
|
120
|
+
else { [AC]::keybd_event([byte]$k.vk, 0, 2, [UIntPtr]::Zero) }
|
|
121
|
+
Start-Sleep -Milliseconds 12
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
$out = @{ ok = $true }
|
|
126
|
+
switch ($req.action) {
|
|
127
|
+
'screen' { $s = Get-ACScreen; $out.width = $s.width; $out.height = $s.height }
|
|
128
|
+
'windows' { $out.windows = @(Get-ACWindows); $out.active_window = (Get-ACWindows | Select-Object -First 1) }
|
|
129
|
+
'active' {
|
|
130
|
+
$h = [AC]::GetForegroundWindow()
|
|
131
|
+
$sb = New-Object System.Text.StringBuilder 1024
|
|
132
|
+
[void][AC]::GetWindowText($h, $sb, 1024)
|
|
133
|
+
$out.name = $sb.ToString()
|
|
134
|
+
}
|
|
135
|
+
'focus' {
|
|
136
|
+
$w = Find-ACWindow $req.name
|
|
137
|
+
if ($w) {
|
|
138
|
+
[void][AC]::ShowWindow([IntPtr]$w.handle, 9)
|
|
139
|
+
[void][AC]::SetForegroundWindow([IntPtr]$w.handle)
|
|
140
|
+
Start-Sleep -Milliseconds 150
|
|
141
|
+
$out.name = $w.name
|
|
142
|
+
} else { $out = @{ ok = $false; error = "window not found: $($req.name)" } }
|
|
143
|
+
}
|
|
144
|
+
'resize' {
|
|
145
|
+
$w = Find-ACWindow $req.name
|
|
146
|
+
if ($w) {
|
|
147
|
+
[void][AC]::MoveWindow([IntPtr]$w.handle, [int]$req.x, [int]$req.y, [int]$req.width, [int]$req.height, $true)
|
|
148
|
+
$out.name = $w.name
|
|
149
|
+
} else { $out = @{ ok = $false; error = "window not found: $($req.name)" } }
|
|
150
|
+
}
|
|
151
|
+
'launch' {
|
|
152
|
+
$name = [string]$req.name
|
|
153
|
+
$done = $false
|
|
154
|
+
try { Start-Process -FilePath $name -ErrorAction Stop; $done = $true } catch { $done = $false }
|
|
155
|
+
if (-not $done) {
|
|
156
|
+
$hit = $null
|
|
157
|
+
try { $hit = Get-StartApps | Where-Object { $_.Name -like "*$name*" } | Select-Object -First 1 } catch { $hit = $null }
|
|
158
|
+
if ($hit) { Start-Process 'explorer.exe' "shell:AppsFolder\$($hit.AppID)"; $done = $true }
|
|
159
|
+
else {
|
|
160
|
+
$exe = Get-Command $name -ErrorAction SilentlyContinue
|
|
161
|
+
if ($exe) { Start-Process $exe.Source; $done = $true }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if ($done) { Start-Sleep -Milliseconds 700; $out.name = $name } else { $out = @{ ok = $false; error = "app not found: $name" } }
|
|
165
|
+
}
|
|
166
|
+
'move' { [void][AC]::SetCursorPos([int]$req.x, [int]$req.y) }
|
|
167
|
+
'click' {
|
|
168
|
+
[void][AC]::SetCursorPos([int]$req.x, [int]$req.y)
|
|
169
|
+
Start-Sleep -Milliseconds 40
|
|
170
|
+
$down = 2; $up = 4
|
|
171
|
+
if ($req.button -eq 'right') { $down = 8; $up = 16 }
|
|
172
|
+
elseif ($req.button -eq 'middle') { $down = 32; $up = 64 }
|
|
173
|
+
$times = [int]$req.count; if ($times -lt 1) { $times = 1 }
|
|
174
|
+
for ($i = 0; $i -lt $times; $i++) {
|
|
175
|
+
[AC]::mouse_event($down, 0, 0, 0, [UIntPtr]::Zero)
|
|
176
|
+
Start-Sleep -Milliseconds 20
|
|
177
|
+
[AC]::mouse_event($up, 0, 0, 0, [UIntPtr]::Zero)
|
|
178
|
+
Start-Sleep -Milliseconds 60
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
'down' {
|
|
182
|
+
[void][AC]::SetCursorPos([int]$req.x, [int]$req.y)
|
|
183
|
+
$flag = 2; if ($req.button -eq 'right') { $flag = 8 } elseif ($req.button -eq 'middle') { $flag = 32 }
|
|
184
|
+
[AC]::mouse_event($flag, 0, 0, 0, [UIntPtr]::Zero)
|
|
185
|
+
}
|
|
186
|
+
'up' {
|
|
187
|
+
$flag = 4; if ($req.button -eq 'right') { $flag = 16 } elseif ($req.button -eq 'middle') { $flag = 64 }
|
|
188
|
+
if ($req.x -ne $null) { [void][AC]::SetCursorPos([int]$req.x, [int]$req.y) }
|
|
189
|
+
[AC]::mouse_event($flag, 0, 0, 0, [UIntPtr]::Zero)
|
|
190
|
+
}
|
|
191
|
+
'drag' {
|
|
192
|
+
[void][AC]::SetCursorPos([int]$req.x, [int]$req.y)
|
|
193
|
+
Start-Sleep -Milliseconds 60
|
|
194
|
+
[AC]::mouse_event(2, 0, 0, 0, [UIntPtr]::Zero)
|
|
195
|
+
$steps = 12
|
|
196
|
+
for ($i = 1; $i -le $steps; $i++) {
|
|
197
|
+
$nx = [int]($req.x + (($req.x2 - $req.x) * $i / $steps))
|
|
198
|
+
$ny = [int]($req.y + (($req.y2 - $req.y) * $i / $steps))
|
|
199
|
+
[void][AC]::SetCursorPos($nx, $ny)
|
|
200
|
+
Start-Sleep -Milliseconds 15
|
|
201
|
+
}
|
|
202
|
+
[AC]::mouse_event(4, 0, 0, 0, [UIntPtr]::Zero)
|
|
203
|
+
}
|
|
204
|
+
'scroll' {
|
|
205
|
+
if ($req.x -ne $null) { [void][AC]::SetCursorPos([int]$req.x, [int]$req.y); Start-Sleep -Milliseconds 40 }
|
|
206
|
+
$amount = [int]$req.amount
|
|
207
|
+
$dir = 120; if ($amount -lt 0) { $dir = -120 }
|
|
208
|
+
for ($i = 0; $i -lt [Math]::Abs($amount); $i++) {
|
|
209
|
+
[AC]::mouse_event(0x0800, 0, 0, $dir, [UIntPtr]::Zero)
|
|
210
|
+
Start-Sleep -Milliseconds 25
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
'keys' { Send-ACKeys $req.sequence }
|
|
214
|
+
'type' {
|
|
215
|
+
if ($req.x -ne $null) {
|
|
216
|
+
[void][AC]::SetCursorPos([int]$req.x, [int]$req.y)
|
|
217
|
+
Start-Sleep -Milliseconds 60
|
|
218
|
+
[AC]::mouse_event(2, 0, 0, 0, [UIntPtr]::Zero)
|
|
219
|
+
[AC]::mouse_event(4, 0, 0, 0, [UIntPtr]::Zero)
|
|
220
|
+
Start-Sleep -Milliseconds 120
|
|
221
|
+
}
|
|
222
|
+
Set-Clipboard -Value ([string]$req.text)
|
|
223
|
+
Start-Sleep -Milliseconds 80
|
|
224
|
+
[System.Windows.Forms.SendKeys]::SendWait('^v')
|
|
225
|
+
Start-Sleep -Milliseconds 150
|
|
226
|
+
$out.typed = ([string]$req.text).Length
|
|
227
|
+
}
|
|
228
|
+
'shot' {
|
|
229
|
+
$s = Get-ACScreen
|
|
230
|
+
$bmp = New-Object System.Drawing.Bitmap $s.width, $s.height
|
|
231
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
232
|
+
$g.CopyFromScreen(0, 0, 0, 0, $bmp.Size)
|
|
233
|
+
$bmp.Save($req.path, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
234
|
+
$g.Dispose(); $bmp.Dispose()
|
|
235
|
+
$out.width = $s.width; $out.height = $s.height; $out.file = $req.path
|
|
236
|
+
}
|
|
237
|
+
'apps' {
|
|
238
|
+
try { $out.apps = @(Get-StartApps | ForEach-Object { @{ name = $_.Name; app_id = $_.AppID } }) }
|
|
239
|
+
catch { $out.apps = @() }
|
|
240
|
+
}
|
|
241
|
+
'sysinfo' {
|
|
242
|
+
$out.system = @{
|
|
243
|
+
windows_version = [string](Get-CimInstance Win32_OperatingSystem).Caption
|
|
244
|
+
os_version = [Environment]::OSVersion.Version.ToString()
|
|
245
|
+
screen_size = (Get-ACScreen)
|
|
246
|
+
user_account_type = 'Local Account'
|
|
247
|
+
default_language = (Get-Culture).Name
|
|
248
|
+
dpi_scaling = 1
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
default { $out = @{ ok = $false; error = "unknown action: $($req.action)" } }
|
|
252
|
+
}
|
|
253
|
+
$out | ConvertTo-Json -Depth 6 -Compress
|
|
254
|
+
`;
|
|
255
|
+
|
|
256
|
+
let psEnginePath = null;
|
|
257
|
+
function psEngine() {
|
|
258
|
+
if (psEnginePath && fs.existsSync(psEnginePath)) return psEnginePath;
|
|
259
|
+
const dir = path.join(os.tmpdir(), 'arenacode-automation');
|
|
260
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
261
|
+
const file = path.join(dir, 'engine.ps1');
|
|
262
|
+
fs.writeFileSync(file, PS_ENGINE, 'utf8');
|
|
263
|
+
psEnginePath = file;
|
|
264
|
+
return file;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function winRun(request, timeout = 30000) {
|
|
268
|
+
const shell = which('powershell') ? 'powershell' : 'pwsh';
|
|
269
|
+
if (!which(shell)) return fail('PowerShell not found');
|
|
270
|
+
const res = run(
|
|
271
|
+
shell,
|
|
272
|
+
['-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-File', psEngine()],
|
|
273
|
+
{ input: JSON.stringify(request), timeout }
|
|
274
|
+
);
|
|
275
|
+
if (!res.ok && !res.stdout.trim()) {
|
|
276
|
+
return fail(res.stderr.trim() || res.error || `PowerShell exit ${res.status}`);
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
return JSON.parse(res.stdout.trim().split('\n').pop());
|
|
280
|
+
} catch {
|
|
281
|
+
return fail(`unreadable answer from PowerShell: ${res.stdout.slice(0, 200)}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// --------------------------------------------------------------------------- //
|
|
286
|
+
// macOS
|
|
287
|
+
// --------------------------------------------------------------------------- //
|
|
288
|
+
|
|
289
|
+
function osa(script, timeout = 20000) {
|
|
290
|
+
const res = run('osascript', ['-e', script], { timeout });
|
|
291
|
+
return { ok: res.ok, out: res.stdout.trim(), error: res.stderr.trim() || res.error };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function macScreen() {
|
|
295
|
+
const res = osa('tell application "Finder" to get bounds of window of desktop');
|
|
296
|
+
const nums = String(res.out)
|
|
297
|
+
.split(',')
|
|
298
|
+
.map((n) => Number(n.trim()));
|
|
299
|
+
if (nums.length === 4 && nums.every((n) => Number.isFinite(n))) {
|
|
300
|
+
return { width: nums[2] - nums[0], height: nums[3] - nums[1] };
|
|
301
|
+
}
|
|
302
|
+
return { width: 1440, height: 900 };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function macWindows() {
|
|
306
|
+
const res = osa(
|
|
307
|
+
'tell application "System Events" to set out to ""\n' +
|
|
308
|
+
'repeat with p in (every process whose background only is false)\n' +
|
|
309
|
+
' repeat with w in (every window of p)\n' +
|
|
310
|
+
' 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' +
|
|
311
|
+
' end repeat\n' +
|
|
312
|
+
'end repeat\n' +
|
|
313
|
+
'return out'
|
|
314
|
+
);
|
|
315
|
+
if (!res.ok) return [];
|
|
316
|
+
return String(res.out)
|
|
317
|
+
.split('\n')
|
|
318
|
+
.filter(Boolean)
|
|
319
|
+
.map((line) => {
|
|
320
|
+
const [app, title, x, y, w, h] = line.split('\t');
|
|
321
|
+
return {
|
|
322
|
+
name: title || app,
|
|
323
|
+
app,
|
|
324
|
+
x: Number(x) || 0,
|
|
325
|
+
y: Number(y) || 0,
|
|
326
|
+
width: Number(w) || 0,
|
|
327
|
+
height: Number(h) || 0,
|
|
328
|
+
process_id: 0,
|
|
329
|
+
status: 'normal',
|
|
330
|
+
};
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function macKeys(sequence, text) {
|
|
335
|
+
// sequence comes as vk pairs; on macOS it is easier to send the original combo
|
|
336
|
+
const script = text
|
|
337
|
+
? `tell application "System Events" to keystroke ${JSON.stringify(text)}`
|
|
338
|
+
: `tell application "System Events" to keystroke ${JSON.stringify(sequence)}`;
|
|
339
|
+
return osa(script);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// --------------------------------------------------------------------------- //
|
|
343
|
+
// Linux
|
|
344
|
+
// --------------------------------------------------------------------------- //
|
|
345
|
+
|
|
346
|
+
function linuxScreen() {
|
|
347
|
+
const res = run('xdotool', ['getdisplaygeometry']);
|
|
348
|
+
if (res.ok) {
|
|
349
|
+
const [w, h] = res.stdout.trim().split(/\s+/).map(Number);
|
|
350
|
+
if (w && h) return { width: w, height: h };
|
|
351
|
+
}
|
|
352
|
+
const xr = run('xrandr', ['--current']);
|
|
353
|
+
const m = /current (\d+) x (\d+)/.exec(xr.stdout);
|
|
354
|
+
if (m) return { width: Number(m[1]), height: Number(m[2]) };
|
|
355
|
+
return { width: 1280, height: 800 };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function linuxWindows() {
|
|
359
|
+
const res = run('wmctrl', ['-lG']);
|
|
360
|
+
const out = [];
|
|
361
|
+
if (res.ok) {
|
|
362
|
+
for (const line of res.stdout.split('\n').filter(Boolean)) {
|
|
363
|
+
const parts = line.split(/\s+/);
|
|
364
|
+
const x = Number(parts[2]);
|
|
365
|
+
const y = Number(parts[3]);
|
|
366
|
+
const width = Number(parts[4]);
|
|
367
|
+
const height = Number(parts[5]);
|
|
368
|
+
const name = parts.slice(7).join(' ');
|
|
369
|
+
out.push({ name, x, y, width, height, process_id: 0, status: 'normal' });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// --------------------------------------------------------------------------- //
|
|
376
|
+
// public interface
|
|
377
|
+
// --------------------------------------------------------------------------- //
|
|
378
|
+
|
|
379
|
+
const IMAGE_TOOLS = [
|
|
380
|
+
['gnome-screenshot', (p) => ['-f', p]],
|
|
381
|
+
['scrot', (p) => [p]],
|
|
382
|
+
['import', (p) => ['-window', 'root', p]],
|
|
383
|
+
['grim', (p) => [p]],
|
|
384
|
+
['spectacle', (p) => ['-b', '-n', '-o', p]],
|
|
385
|
+
];
|
|
386
|
+
|
|
387
|
+
function keyboardSequence(combo) {
|
|
388
|
+
// "ctrl+shift+s" -> [{vk, flags}] using Windows virtual-key codes, which the
|
|
389
|
+
// PowerShell engine sends through keybd_event. Unknown names are ignored.
|
|
390
|
+
const VK = {
|
|
391
|
+
ctrl: 0x11, control: 0x11, alt: 0x12, shift: 0x10, win: 0x5b, meta: 0x5b, cmd: 0x5b,
|
|
392
|
+
enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, space: 0x20,
|
|
393
|
+
backspace: 0x08, delete: 0x2e, del: 0x2e, insert: 0x2d, home: 0x24, end: 0x23,
|
|
394
|
+
pageup: 0x21, pagedown: 0x22, pgup: 0x21, pgdn: 0x22, up: 0x26, down: 0x28,
|
|
395
|
+
left: 0x25, right: 0x27, printscreen: 0x2c, capslock: 0x14, menu: 0x5d,
|
|
396
|
+
};
|
|
397
|
+
for (let i = 1; i <= 24; i += 1) VK[`f${i}`] = 0x6f + i;
|
|
398
|
+
const parts = String(combo || '')
|
|
399
|
+
.toLowerCase()
|
|
400
|
+
.split('+')
|
|
401
|
+
.map((p) => p.trim())
|
|
402
|
+
.filter(Boolean);
|
|
403
|
+
if (!parts.length) return [];
|
|
404
|
+
const seq = [];
|
|
405
|
+
const repeat = parts.filter((p) => p === 'repeat').length;
|
|
406
|
+
const keys = parts.filter((p) => p !== 'repeat');
|
|
407
|
+
const modVks = [];
|
|
408
|
+
const mainVks = [];
|
|
409
|
+
for (const key of keys) {
|
|
410
|
+
if (VK[key]) {
|
|
411
|
+
if (['ctrl', 'control', 'alt', 'shift', 'win', 'meta', 'cmd'].includes(key)) modVks.push(VK[key]);
|
|
412
|
+
else mainVks.push(VK[key]);
|
|
413
|
+
} else if (key.length === 1) {
|
|
414
|
+
mainVks.push(key.toUpperCase().charCodeAt(0));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const times = Math.max(1, repeat || 1);
|
|
418
|
+
for (let i = 0; i < times; i += 1) {
|
|
419
|
+
if (!mainVks.length && modVks.length) {
|
|
420
|
+
// "win" on its own: press and release the modifier itself
|
|
421
|
+
const only = modVks[modVks.length - 1];
|
|
422
|
+
seq.push({ vk: only, flags: 0 }, { vk: only, flags: 2 });
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
for (const vk of modVks) seq.push({ vk, flags: 0 });
|
|
426
|
+
for (const vk of mainVks) {
|
|
427
|
+
seq.push({ vk, flags: 0 });
|
|
428
|
+
seq.push({ vk, flags: 2 });
|
|
429
|
+
}
|
|
430
|
+
for (const vk of [...modVks].reverse()) seq.push({ vk, flags: 2 });
|
|
431
|
+
}
|
|
432
|
+
return seq;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const automation = {
|
|
436
|
+
platform: process.platform,
|
|
437
|
+
|
|
438
|
+
available() {
|
|
439
|
+
if (isWin) return { ok: true, backend: 'windows', detail: 'PowerShell + user32' };
|
|
440
|
+
if (isMac) {
|
|
441
|
+
const mouse = which('cliclick');
|
|
442
|
+
return {
|
|
443
|
+
ok: true,
|
|
444
|
+
backend: 'macos',
|
|
445
|
+
detail: mouse ? 'osascript + cliclick' : 'osascript (mouse needs cliclick: brew install cliclick)',
|
|
446
|
+
mouse: Boolean(mouse),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
if (isLinux) {
|
|
450
|
+
const xdotool = which('xdotool');
|
|
451
|
+
if (!xdotool) {
|
|
452
|
+
return { ok: false, backend: 'linux', error: 'xdotool not installed: sudo apt install xdotool (or dnf/pacman)' };
|
|
453
|
+
}
|
|
454
|
+
return { ok: true, backend: 'linux', detail: 'xdotool' };
|
|
455
|
+
}
|
|
456
|
+
return { ok: false, backend: null, error: `unsupported platform: ${process.platform}` };
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
screenSize() {
|
|
460
|
+
if (isWin) {
|
|
461
|
+
const r = winRun({ action: 'screen' });
|
|
462
|
+
return r.ok ? { ok: true, width: r.width, height: r.height } : fail(r.error);
|
|
463
|
+
}
|
|
464
|
+
if (isMac) {
|
|
465
|
+
const s = macScreen();
|
|
466
|
+
return { ok: true, ...s };
|
|
467
|
+
}
|
|
468
|
+
const s = linuxScreen();
|
|
469
|
+
return { ok: true, ...s };
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
windows() {
|
|
473
|
+
if (isWin) {
|
|
474
|
+
const r = winRun({ action: 'windows' });
|
|
475
|
+
return r.ok ? { ok: true, windows: r.windows || [], active_window: r.active_window || null } : fail(r.error);
|
|
476
|
+
}
|
|
477
|
+
const list = isMac ? macWindows() : linuxWindows();
|
|
478
|
+
return { ok: true, windows: list, active_window: list[0] || null };
|
|
479
|
+
},
|
|
480
|
+
|
|
481
|
+
activeWindow() {
|
|
482
|
+
const w = this.windows();
|
|
483
|
+
return w.ok ? { ok: true, name: (w.active_window && w.active_window.name) || '' } : w;
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
launchApp(name) {
|
|
487
|
+
if (isWin) {
|
|
488
|
+
const r = winRun({ action: 'launch', name }, 45000);
|
|
489
|
+
return r.ok ? { ok: true, app: name } : fail(r.error);
|
|
490
|
+
}
|
|
491
|
+
if (isMac) {
|
|
492
|
+
const res = run('open', ['-a', name], { timeout: 30000 });
|
|
493
|
+
return res.ok ? { ok: true, app: name } : fail(res.stderr.trim() || `open -a ${name} failed`);
|
|
494
|
+
}
|
|
495
|
+
const direct = run('sh', ['-c', `command -v ${JSON.stringify(name)} >/dev/null && setsid ${JSON.stringify(name)} >/dev/null 2>&1 &`]);
|
|
496
|
+
if (direct.ok) return { ok: true, app: name };
|
|
497
|
+
const app = run('gtk-launch', [name]);
|
|
498
|
+
if (app.ok) return { ok: true, app: name };
|
|
499
|
+
return fail(`app not found: ${name}`);
|
|
500
|
+
},
|
|
501
|
+
|
|
502
|
+
focusWindow(name) {
|
|
503
|
+
if (isWin) {
|
|
504
|
+
const r = winRun({ action: 'focus', name });
|
|
505
|
+
return r.ok ? { ok: true, name: r.name, app: r.name } : fail(r.error);
|
|
506
|
+
}
|
|
507
|
+
if (isMac) {
|
|
508
|
+
const res = osa(`tell application "System Events" to set frontmost of process ${JSON.stringify(name)} to true`);
|
|
509
|
+
return res.ok ? { ok: true, name } : fail(res.error);
|
|
510
|
+
}
|
|
511
|
+
const res = run('wmctrl', ['-a', name]);
|
|
512
|
+
return res.ok ? { ok: true, name } : fail(res.stderr.trim() || `window not found: ${name}`);
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
resizeWindow(name, x, y, width, height) {
|
|
516
|
+
if (isWin) {
|
|
517
|
+
const r = winRun({ action: 'resize', name, x, y, width, height });
|
|
518
|
+
return r.ok ? { ok: true, name: r.name, x, y, width, height } : fail(r.error);
|
|
519
|
+
}
|
|
520
|
+
if (isMac) {
|
|
521
|
+
const res = osa(
|
|
522
|
+
`tell application "System Events" to tell process ${JSON.stringify(name)} to set {position, size} of window 1 to {{${x}, ${y}}, {${width}, ${height}}}`
|
|
523
|
+
);
|
|
524
|
+
return res.ok ? { ok: true, name, x, y, width, height } : fail(res.error);
|
|
525
|
+
}
|
|
526
|
+
const res = run('wmctrl', ['-r', name, '-e', `0,${x},${y},${width},${height}`]);
|
|
527
|
+
return res.ok ? { ok: true, name, x, y, width, height } : fail(res.stderr.trim() || 'resize failed');
|
|
528
|
+
},
|
|
529
|
+
|
|
530
|
+
click(x, y, { button = 'left', count = 1 } = {}) {
|
|
531
|
+
if (isWin) {
|
|
532
|
+
const r = winRun({ action: 'click', x, y, button, count });
|
|
533
|
+
return r.ok ? { ok: true, x, y, button, count } : fail(r.error);
|
|
534
|
+
}
|
|
535
|
+
if (isMac) {
|
|
536
|
+
if (!which('cliclick')) return fail('mouse needs cliclick: brew install cliclick');
|
|
537
|
+
const arg = `${button === 'right' ? 'rc' : 'c'}:${x},${y}`;
|
|
538
|
+
const rounds = Math.max(1, count);
|
|
539
|
+
for (let i = 0; i < rounds; i += 1) {
|
|
540
|
+
const res = run('cliclick', [arg]);
|
|
541
|
+
if (!res.ok) return fail(res.stderr.trim() || 'cliclick failed');
|
|
542
|
+
}
|
|
543
|
+
return { ok: true, x, y, button, count };
|
|
544
|
+
}
|
|
545
|
+
const btn = button === 'right' ? '3' : button === 'middle' ? '2' : '1';
|
|
546
|
+
const res = run('xdotool', ['mousemove', String(x), String(y), 'click', '--repeat', String(Math.max(1, count)), btn]);
|
|
547
|
+
return res.ok ? { ok: true, x, y, button, count } : fail(res.stderr.trim() || 'xdotool click failed');
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
moveMouse(x, y) {
|
|
551
|
+
if (isWin) {
|
|
552
|
+
const r = winRun({ action: 'move', x, y });
|
|
553
|
+
return r.ok ? { ok: true, x, y } : fail(r.error);
|
|
554
|
+
}
|
|
555
|
+
if (isMac) {
|
|
556
|
+
if (!which('cliclick')) return fail('mouse needs cliclick: brew install cliclick');
|
|
557
|
+
const res = run('cliclick', [`m:${x},${y}`]);
|
|
558
|
+
return res.ok ? { ok: true, x, y } : fail(res.stderr.trim() || 'cliclick failed');
|
|
559
|
+
}
|
|
560
|
+
const res = run('xdotool', ['mousemove', String(x), String(y)]);
|
|
561
|
+
return res.ok ? { ok: true, x, y } : fail(res.stderr.trim() || 'xdotool failed');
|
|
562
|
+
},
|
|
563
|
+
|
|
564
|
+
mouseButton(x, y, button, down) {
|
|
565
|
+
if (isWin) {
|
|
566
|
+
const r = winRun({ action: down ? 'down' : 'up', x, y, button });
|
|
567
|
+
return r.ok ? { ok: true, x, y, button } : fail(r.error);
|
|
568
|
+
}
|
|
569
|
+
if (isMac) {
|
|
570
|
+
if (!which('cliclick')) return fail('mouse needs cliclick: brew install cliclick');
|
|
571
|
+
const arg = `dd:${x},${y}`;
|
|
572
|
+
if (down) return run('cliclick', [arg]).ok ? { ok: true } : fail('cliclick failed');
|
|
573
|
+
return run('cliclick', [`du:${x},${y}`]).ok ? { ok: true } : fail('cliclick failed');
|
|
574
|
+
}
|
|
575
|
+
const btn = button === 'right' ? '3' : button === 'middle' ? '2' : '1';
|
|
576
|
+
const res = run('xdotool', [down ? 'mousedown' : 'mouseup', btn]);
|
|
577
|
+
return res.ok ? { ok: true } : fail(res.stderr.trim() || 'xdotool failed');
|
|
578
|
+
},
|
|
579
|
+
|
|
580
|
+
drag(x, y, x2, y2) {
|
|
581
|
+
if (isWin) {
|
|
582
|
+
const r = winRun({ action: 'drag', x, y, x2, y2 });
|
|
583
|
+
return r.ok ? { ok: true, from: [x, y], to: [x2, y2] } : fail(r.error);
|
|
584
|
+
}
|
|
585
|
+
if (isMac) {
|
|
586
|
+
if (!which('cliclick')) return fail('mouse needs cliclick: brew install cliclick');
|
|
587
|
+
const res = run('cliclick', [`dd:${x},${y}`, `dm:${x2},${y2}`, `du:${x2},${y2}`]);
|
|
588
|
+
return res.ok ? { ok: true, from: [x, y], to: [x2, y2] } : fail(res.stderr.trim() || 'cliclick failed');
|
|
589
|
+
}
|
|
590
|
+
const res = run('xdotool', ['mousemove', String(x), String(y), 'mousedown', '1', 'mousemove', String(x2), String(y2), 'mouseup', '1']);
|
|
591
|
+
return res.ok ? { ok: true, from: [x, y], to: [x2, y2] } : fail(res.stderr.trim() || 'xdotool failed');
|
|
592
|
+
},
|
|
593
|
+
|
|
594
|
+
scroll(amount, x = null, y = null) {
|
|
595
|
+
const clicks = Math.trunc(amount) || 1;
|
|
596
|
+
if (isWin) {
|
|
597
|
+
const r = winRun({ action: 'scroll', amount: clicks, x, y });
|
|
598
|
+
return r.ok ? { ok: true, amount: clicks } : fail(r.error);
|
|
599
|
+
}
|
|
600
|
+
const button = clicks > 0 ? '4' : '5';
|
|
601
|
+
const args = [];
|
|
602
|
+
if (x !== null && y !== null) args.push('mousemove', String(x), String(y));
|
|
603
|
+
args.push('click', '--repeat', String(Math.abs(clicks)), button);
|
|
604
|
+
if (isMac) {
|
|
605
|
+
if (!which('cliclick')) return fail('mouse needs cliclick: brew install cliclick');
|
|
606
|
+
const res = run('cliclick', [`w:${clicks > 0 ? 'up' : 'down'}`]);
|
|
607
|
+
return res.ok ? { ok: true, amount: clicks } : fail(res.stderr.trim());
|
|
608
|
+
}
|
|
609
|
+
const res = run('xdotool', args);
|
|
610
|
+
return res.ok ? { ok: true, amount: clicks } : fail(res.stderr.trim() || 'xdotool failed');
|
|
611
|
+
},
|
|
612
|
+
|
|
613
|
+
typeText(text, x = null, y = null) {
|
|
614
|
+
if (isWin) {
|
|
615
|
+
const r = winRun({ action: 'type', text, x, y }, 60000);
|
|
616
|
+
return r.ok ? { ok: true, chars: text.length, typed: r.typed ?? text.length } : fail(r.error);
|
|
617
|
+
}
|
|
618
|
+
if (x !== null && y !== null) this.click(x, y);
|
|
619
|
+
if (isMac) {
|
|
620
|
+
const res = osa(`tell application "System Events" to keystroke ${JSON.stringify(text)}`);
|
|
621
|
+
return res.ok ? { ok: true, chars: text.length, typed: text.length } : fail(res.error);
|
|
622
|
+
}
|
|
623
|
+
const res = run('xdotool', ['type', '--clearmodifiers', '--delay', '12', text], { timeout: 120000 });
|
|
624
|
+
return res.ok ? { ok: true, chars: text.length, typed: text.length } : fail(res.stderr.trim() || 'xdotool type failed');
|
|
625
|
+
},
|
|
626
|
+
|
|
627
|
+
pressKeys(combo) {
|
|
628
|
+
const sequence = keyboardSequence(combo);
|
|
629
|
+
if (!sequence.length) return fail(`unreadable key combination: ${combo}`);
|
|
630
|
+
if (isWin) {
|
|
631
|
+
const r = winRun({ action: 'keys', sequence });
|
|
632
|
+
return r.ok ? { ok: true, keys: combo } : fail(r.error);
|
|
633
|
+
}
|
|
634
|
+
if (isMac) {
|
|
635
|
+
const res = macKeys(combo);
|
|
636
|
+
return res.ok ? { ok: true, keys: combo } : fail(res.error);
|
|
637
|
+
}
|
|
638
|
+
const mapped = String(combo)
|
|
639
|
+
.toLowerCase()
|
|
640
|
+
.split('+')
|
|
641
|
+
.map((p) => (p === 'ctrl' ? 'ctrl' : p))
|
|
642
|
+
.join('+');
|
|
643
|
+
const res = run('xdotool', ['key', '--clearmodifiers', mapped]);
|
|
644
|
+
return res.ok ? { ok: true, keys: combo } : fail(res.stderr.trim() || 'xdotool key failed');
|
|
645
|
+
},
|
|
646
|
+
|
|
647
|
+
screenshot(dest) {
|
|
648
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
649
|
+
if (isWin) {
|
|
650
|
+
const r = winRun({ action: 'shot', path: dest }, 60000);
|
|
651
|
+
return r.ok ? { ok: true, file: dest, width: r.width, height: r.height, mime: 'image/png' } : fail(r.error);
|
|
652
|
+
}
|
|
653
|
+
if (isMac) {
|
|
654
|
+
const res = run('screencapture', ['-x', '-t', 'png', dest], { timeout: 30000 });
|
|
655
|
+
if (!res.ok) return fail(res.stderr.trim() || 'screencapture failed');
|
|
656
|
+
const size = this.screenSize();
|
|
657
|
+
return { ok: true, file: dest, width: size.width, height: size.height, mime: 'image/png' };
|
|
658
|
+
}
|
|
659
|
+
for (const [tool, args] of IMAGE_TOOLS) {
|
|
660
|
+
if (!which(tool)) continue;
|
|
661
|
+
const res = run(tool, args(dest), { timeout: 30000 });
|
|
662
|
+
if (res.ok && fs.existsSync(dest)) {
|
|
663
|
+
const size = this.screenSize();
|
|
664
|
+
return { ok: true, file: dest, width: size.width, height: size.height, mime: 'image/png' };
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return fail('no screenshot tool found: install imagemagick (import), scrot, grim or gnome-screenshot');
|
|
668
|
+
},
|
|
669
|
+
|
|
670
|
+
listApps() {
|
|
671
|
+
if (isWin) {
|
|
672
|
+
const r = winRun({ action: 'apps' }, 60000);
|
|
673
|
+
return r.ok ? { ok: true, apps: r.apps || [] } : fail(r.error);
|
|
674
|
+
}
|
|
675
|
+
const out = [];
|
|
676
|
+
if (isMac) {
|
|
677
|
+
for (const dir of ['/Applications', '/System/Applications']) {
|
|
678
|
+
try {
|
|
679
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
680
|
+
if (entry.endsWith('.app')) out.push({ name: entry.replace(/\.app$/, ''), app_id: path.join(dir, entry) });
|
|
681
|
+
}
|
|
682
|
+
} catch {
|
|
683
|
+
/* ignore */
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return { ok: true, apps: out };
|
|
687
|
+
}
|
|
688
|
+
const dirs = ['/usr/share/applications', '/usr/local/share/applications', path.join(os.homedir(), '.local/share/applications')];
|
|
689
|
+
for (const dir of dirs) {
|
|
690
|
+
let files = [];
|
|
691
|
+
try {
|
|
692
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith('.desktop'));
|
|
693
|
+
} catch {
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
for (const f of files) {
|
|
697
|
+
try {
|
|
698
|
+
const text = fs.readFileSync(path.join(dir, f), 'utf8');
|
|
699
|
+
if (/^NoDisplay=true/m.test(text)) continue;
|
|
700
|
+
const name = /^Name=(.+)$/m.exec(text);
|
|
701
|
+
if (name) out.push({ name: name[1].trim(), app_id: f.replace(/\.desktop$/, '') });
|
|
702
|
+
} catch {
|
|
703
|
+
/* ignore */
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return { ok: true, apps: out };
|
|
708
|
+
},
|
|
709
|
+
|
|
710
|
+
sysinfo() {
|
|
711
|
+
const screen = this.screenSize();
|
|
712
|
+
return {
|
|
713
|
+
ok: true,
|
|
714
|
+
system: {
|
|
715
|
+
windows_version: isWin ? os.release() : `${process.platform} ${os.release()}`,
|
|
716
|
+
os_version: os.release(),
|
|
717
|
+
screen_size: { width: screen.width, height: screen.height },
|
|
718
|
+
user_account_type: 'Local Account',
|
|
719
|
+
default_language: process.env.LANG || 'en',
|
|
720
|
+
dpi_scaling: 1,
|
|
721
|
+
},
|
|
722
|
+
};
|
|
723
|
+
},
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
module.exports = { ...automation, keyboardSequence };
|
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,172 @@ 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
|
+
const wins = automation.windows();
|
|
535
|
+
const screen = automation.screenSize();
|
|
536
|
+
const info = automation.sysinfo();
|
|
537
|
+
if (!wins.ok) return wins;
|
|
538
|
+
return {
|
|
539
|
+
ok: true,
|
|
540
|
+
windows: wins.windows,
|
|
541
|
+
active_window: wins.active_window,
|
|
542
|
+
interactive_elements: [],
|
|
543
|
+
scrollable_elements: [],
|
|
544
|
+
interactive_elements_text: '',
|
|
545
|
+
system: info.system,
|
|
546
|
+
screen: { width: screen.width, height: screen.height },
|
|
547
|
+
// the terminal client reads windows and pixels, not the accessibility tree
|
|
548
|
+
note: 'interactive_elements are empty: this client does not read the UI Automation tree',
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (op === 'sysinfo') {
|
|
553
|
+
const info = automation.sysinfo();
|
|
554
|
+
return {
|
|
555
|
+
...info,
|
|
556
|
+
os: `${process.platform} ${os.release()}`,
|
|
557
|
+
hostname: os.hostname(),
|
|
558
|
+
arch: os.arch(),
|
|
559
|
+
cpus: os.cpus().length,
|
|
560
|
+
memory_mb: Math.round(os.totalmem() / 1048576),
|
|
561
|
+
node: process.version,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (op === 'apps') return automation.listApps();
|
|
566
|
+
|
|
567
|
+
if (!toolkit.ok) {
|
|
568
|
+
return {
|
|
569
|
+
ok: false,
|
|
570
|
+
error:
|
|
571
|
+
toolkit.error ||
|
|
572
|
+
t(lang, 'automazione non disponibile su questo sistema', 'automation is not available on this system', 'la automatización no está disponible en este sistema'),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (op === 'control') {
|
|
577
|
+
const { action, x, y, x2, y2, amount, button } = request;
|
|
578
|
+
switch (action) {
|
|
579
|
+
case 'move':
|
|
580
|
+
return automation.moveMouse(x, y);
|
|
581
|
+
case 'click':
|
|
582
|
+
return automation.click(x, y, { button: button || 'left', count: 1 });
|
|
583
|
+
case 'dblclick':
|
|
584
|
+
return automation.click(x, y, { button: button || 'left', count: 2 });
|
|
585
|
+
case 'rightclick':
|
|
586
|
+
return automation.click(x, y, { button: 'right', count: 1 });
|
|
587
|
+
case 'down':
|
|
588
|
+
return automation.mouseButton(x, y, button || 'left', true);
|
|
589
|
+
case 'up':
|
|
590
|
+
return automation.mouseButton(x, y, button || 'left', false);
|
|
591
|
+
case 'drag':
|
|
592
|
+
return automation.drag(x, y, x2, y2);
|
|
593
|
+
case 'scroll':
|
|
594
|
+
return automation.scroll(amount ?? 1, x ?? null, y ?? null);
|
|
595
|
+
default:
|
|
596
|
+
return { ok: false, error: `unknown control action: ${action}` };
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (op === 'keystroke') {
|
|
601
|
+
return automation.pressKeys(request.keys || '');
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (op === 'type') {
|
|
605
|
+
let text = request.text;
|
|
606
|
+
if (request.payload || text === undefined) {
|
|
607
|
+
const res = await bridge.uiPayload(taskId, rid);
|
|
608
|
+
text = res.status === 200 ? (res.json && (res.json.text ?? res.json.payload ?? '')) : '';
|
|
609
|
+
if (!text) return { ok: false, error: 'the typed text could not be read from the bridge' };
|
|
610
|
+
}
|
|
611
|
+
return automation.typeText(String(text), request.x ?? null, request.y ?? null);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (op === 'app') {
|
|
615
|
+
const name = request.text || request.name || '';
|
|
616
|
+
const action = request.action || 'launch';
|
|
617
|
+
if (action === 'launch') return automation.launchApp(name);
|
|
618
|
+
if (action === 'switch') return automation.focusWindow(name);
|
|
619
|
+
if (action === 'resize') {
|
|
620
|
+
return automation.resizeWindow(name, request.x ?? 0, request.y ?? 0, request.x2 ?? 800, request.y2 ?? 600);
|
|
621
|
+
}
|
|
622
|
+
return { ok: false, error: `unknown app action: ${action}` };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (op === 'screenshot') {
|
|
626
|
+
const dir = path.join(os.tmpdir(), 'arenacode-shots');
|
|
627
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
628
|
+
const file = path.join(dir, `shot-${stamp()}.png`);
|
|
629
|
+
const shot = automation.screenshot(file);
|
|
630
|
+
if (!shot.ok) return shot;
|
|
631
|
+
const buf = fs.readFileSync(shot.file);
|
|
632
|
+
// the agent runs in the sandbox: the image travels through the bridge workspace
|
|
633
|
+
const up = await bridge.upload(`uploads/${stamp()}/shot-${stamp()}.png`, buf);
|
|
634
|
+
const remote = up.status === 200 ? up.json.path : null;
|
|
635
|
+
const out = {
|
|
636
|
+
ok: true,
|
|
637
|
+
mime: 'image/png',
|
|
638
|
+
width: shot.width,
|
|
639
|
+
height: shot.height,
|
|
640
|
+
file: remote || shot.file,
|
|
641
|
+
local_file: shot.file,
|
|
642
|
+
};
|
|
643
|
+
if (buf.length <= 2 * 1024 * 1024) out.image = buf.toString('base64');
|
|
644
|
+
return out;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (op === 'multi_select') {
|
|
648
|
+
const { elements = [], press_ctrl = true } = json(request.args_json);
|
|
649
|
+
const modifier = press_ctrl ? automation.pressKeys('ctrl') : null;
|
|
650
|
+
if (modifier && !modifier.ok) return modifier;
|
|
651
|
+
for (const [x, y] of elements) {
|
|
652
|
+
const r = automation.click(x, y, { button: 'left', count: 1 });
|
|
653
|
+
if (!r.ok) return r;
|
|
654
|
+
}
|
|
655
|
+
return { ok: true, selected: elements.length };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (op === 'multi_edit') {
|
|
659
|
+
const { elements = [] } = json(request.args_json);
|
|
660
|
+
let typed = 0;
|
|
661
|
+
for (const [x, y, text] of elements) {
|
|
662
|
+
const r = automation.typeText(String(text), x, y);
|
|
663
|
+
if (!r.ok) return r;
|
|
664
|
+
typed += 1;
|
|
665
|
+
}
|
|
666
|
+
return { ok: true, fields: typed };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return { ok: false, error: `unknown ui operation: ${op}` };
|
|
670
|
+
}
|
|
671
|
+
|
|
515
672
|
function available() {
|
|
516
673
|
return os.release();
|
|
517
674
|
}
|
|
@@ -536,6 +693,7 @@ async function handleLocalFile(bridge, taskId, request, opts) {
|
|
|
536
693
|
}
|
|
537
694
|
|
|
538
695
|
module.exports = {
|
|
696
|
+
runUiAction,
|
|
539
697
|
REFUSED,
|
|
540
698
|
resolveInRoot,
|
|
541
699
|
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);
|