@lardexx/arenacode 2.9.0 → 2.11.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 +68 -12
- package/package.json +2 -2
- package/src/automation/linux.js +2 -40
- package/src/automation/mac.js +657 -0
- package/src/automation/png.js +47 -0
- package/src/automation.js +20 -3
- package/src/cli.js +34 -4
- package/src/config.js +1 -0
- package/src/context.js +234 -0
- package/src/local.js +121 -13
- package/src/meta.js +5 -18
- package/src/tui/app.js +49 -2
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// macOS backend: CoreGraphics through koffi. Same idea as the Linux one — no helper
|
|
4
|
+
// binaries, no `brew install cliclick`: the mouse is CGEventCreateMouseEvent + CGEventPost,
|
|
5
|
+
// the keyboard is CGEventCreateKeyboardEvent (chords) and CGEventKeyboardSetUnicodeString
|
|
6
|
+
// (any character, emoji included), windows come from CGWindowListCopyWindowInfo and the
|
|
7
|
+
// screenshot from CGDisplayCreateImage. Only what CoreGraphics cannot do at all —
|
|
8
|
+
// launching an app, bringing a window to the front, resizing it — still goes through
|
|
9
|
+
// `osascript`, which ships with macOS.
|
|
10
|
+
//
|
|
11
|
+
// Permissions: posting events needs Accessibility for the terminal (System Settings →
|
|
12
|
+
// Privacy & Security → Accessibility); window titles and pixels need Screen Recording.
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const { spawnSync } = require('node:child_process');
|
|
18
|
+
const { fn, lib } = require('./ffi');
|
|
19
|
+
const { encodePng } = require('./png');
|
|
20
|
+
|
|
21
|
+
const CG = '/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics';
|
|
22
|
+
const CF = '/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation';
|
|
23
|
+
|
|
24
|
+
const K_CG_HID_EVENT_TAP = 0;
|
|
25
|
+
const K_CG_SESSION_EVENT_TAP = 1;
|
|
26
|
+
|
|
27
|
+
const EVENT = {
|
|
28
|
+
leftDown: 1,
|
|
29
|
+
leftUp: 2,
|
|
30
|
+
rightDown: 3,
|
|
31
|
+
rightUp: 4,
|
|
32
|
+
mouseMoved: 5,
|
|
33
|
+
leftDragged: 6,
|
|
34
|
+
rightDragged: 7,
|
|
35
|
+
keyDown: 10,
|
|
36
|
+
keyUp: 11,
|
|
37
|
+
scrollWheel: 22,
|
|
38
|
+
};
|
|
39
|
+
const FIELD_CLICK_STATE = 1;
|
|
40
|
+
|
|
41
|
+
const FLAG = {
|
|
42
|
+
shift: 0x20000,
|
|
43
|
+
control: 0x40000,
|
|
44
|
+
option: 0x80000,
|
|
45
|
+
command: 0x100000,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// kVK_* — fixed virtual keycodes; they do not depend on the keyboard layout.
|
|
49
|
+
const VK = {
|
|
50
|
+
a: 0, s: 1, d: 2, f: 3, h: 4, g: 5, z: 6, x: 7, c: 8, v: 9,
|
|
51
|
+
b: 11, q: 12, w: 13, e: 14, r: 15, y: 16, t: 17,
|
|
52
|
+
1: 18, 2: 19, 3: 20, 4: 21, 6: 22, 5: 23, equal: 24, 9: 25, 7: 26, minus: 27,
|
|
53
|
+
8: 28, 0: 29, bracketright: 30, o: 31, u: 32, bracketleft: 33, i: 34, p: 35,
|
|
54
|
+
return: 36, l: 37, j: 38, quote: 39, k: 40, semicolon: 41, backslash: 42,
|
|
55
|
+
comma: 43, slash: 44, n: 45, m: 46, period: 47, tab: 48, space: 49, grave: 50,
|
|
56
|
+
backspace: 51, escape: 53, command: 55, shift: 56, capslock: 57, option: 58,
|
|
57
|
+
control: 59, rightshift: 60, rightoption: 61, rightcontrol: 62,
|
|
58
|
+
help: 114, home: 115, pageup: 116, delete: 117, end: 119, pagedown: 121,
|
|
59
|
+
left: 123, right: 124, down: 125, up: 126,
|
|
60
|
+
f1: 122, f2: 120, f3: 99, f4: 118, f5: 96, f6: 97, f7: 98, f8: 100,
|
|
61
|
+
f9: 101, f10: 109, f11: 103, f12: 111,
|
|
62
|
+
enter: 76,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const MODIFIERS = {
|
|
66
|
+
cmd: FLAG.command, command: FLAG.command, meta: FLAG.command, win: FLAG.command, super: FLAG.command,
|
|
67
|
+
ctrl: FLAG.control, control: FLAG.control,
|
|
68
|
+
alt: FLAG.option, option: FLAG.option, opt: FLAG.option,
|
|
69
|
+
shift: FLAG.shift,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Names that AppleScript/linux-style tasks use, mapped to the kVK_* table.
|
|
73
|
+
const ALIASES = {
|
|
74
|
+
esc: 'escape', delete: 'backspace', backspace: 'backspace', del: 'delete',
|
|
75
|
+
arrowup: 'up', arrowdown: 'down', arrowleft: 'left', arrowright: 'right',
|
|
76
|
+
up: 'up', down: 'down', left: 'left', right: 'right',
|
|
77
|
+
pgup: 'pageup', pgdn: 'pagedown', page_up: 'pageup', page_down: 'pagedown',
|
|
78
|
+
home: 'home', end: 'end', space: 'space', spacebar: 'space',
|
|
79
|
+
return: 'return', enter: 'return', tab: 'tab', capslock: 'capslock',
|
|
80
|
+
'[': 'bracketleft', ']': 'bracketright', ';': 'semicolon', '=': 'equal',
|
|
81
|
+
'-': 'minus', '`': 'grave', '\\': 'backslash', ',': 'comma', '.': 'period',
|
|
82
|
+
'/': 'slash', "'": 'quote',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
let bound = null;
|
|
86
|
+
|
|
87
|
+
function bindings() {
|
|
88
|
+
if (bound) return bound;
|
|
89
|
+
if (!lib(CG) || !lib(CF)) throw new Error('CoreGraphics not available (not macOS?)');
|
|
90
|
+
bound = {
|
|
91
|
+
CGMainDisplayID: fn(CG, 'CGMainDisplayID', 'unsigned int CGMainDisplayID(void)'),
|
|
92
|
+
CGDisplayPixelsWide: fn(CG, 'CGDisplayPixelsWide', 'unsigned long CGDisplayPixelsWide(unsigned int display)'),
|
|
93
|
+
CGDisplayPixelsHigh: fn(CG, 'CGDisplayPixelsHigh', 'unsigned long CGDisplayPixelsHigh(unsigned int display)'),
|
|
94
|
+
CGEventCreateMouseEvent: fn(
|
|
95
|
+
CG,
|
|
96
|
+
'CGEventCreateMouseEvent',
|
|
97
|
+
'void* CGEventCreateMouseEvent(void* source, int mouseType, double x, double y, int mouseButton)'
|
|
98
|
+
),
|
|
99
|
+
CGEventCreateKeyboardEvent: fn(
|
|
100
|
+
CG,
|
|
101
|
+
'CGEventCreateKeyboardEvent',
|
|
102
|
+
'void* CGEventCreateKeyboardEvent(void* source, unsigned short virtualKey, bool keyDown)'
|
|
103
|
+
),
|
|
104
|
+
CGEventCreateScrollWheelEvent2: fn(
|
|
105
|
+
CG,
|
|
106
|
+
'CGEventCreateScrollWheelEvent2',
|
|
107
|
+
'void* CGEventCreateScrollWheelEvent2(void* source, int units, unsigned int wheelCount, int wheel1, int wheel2, int wheel3)'
|
|
108
|
+
),
|
|
109
|
+
CGEventPost: fn(CG, 'CGEventPost', 'void CGEventPost(int tap, void* event)'),
|
|
110
|
+
CGEventSetFlags: fn(CG, 'CGEventSetFlags', 'void CGEventSetFlags(void* event, unsigned long long flags)'),
|
|
111
|
+
CGEventSetIntegerValueField: fn(
|
|
112
|
+
CG,
|
|
113
|
+
'CGEventSetIntegerValueField',
|
|
114
|
+
'void CGEventSetIntegerValueField(void* event, int field, long long value)'
|
|
115
|
+
),
|
|
116
|
+
CGEventKeyboardSetUnicodeString: fn(
|
|
117
|
+
CG,
|
|
118
|
+
'CGEventKeyboardSetUnicodeString',
|
|
119
|
+
'void CGEventKeyboardSetUnicodeString(void* event, unsigned long length, void* unicodeString)'
|
|
120
|
+
),
|
|
121
|
+
CGWindowListCopyWindowInfo: fn(
|
|
122
|
+
CG,
|
|
123
|
+
'CGWindowListCopyWindowInfo',
|
|
124
|
+
'void* CGWindowListCopyWindowInfo(unsigned int option, unsigned int relativeToWindow)'
|
|
125
|
+
),
|
|
126
|
+
CGDisplayCreateImage: fn(CG, 'CGDisplayCreateImage', 'void* CGDisplayCreateImage(unsigned int display)'),
|
|
127
|
+
CGImageGetWidth: fn(CG, 'CGImageGetWidth', 'unsigned long CGImageGetWidth(void* image)'),
|
|
128
|
+
CGImageGetHeight: fn(CG, 'CGImageGetHeight', 'unsigned long CGImageGetHeight(void* image)'),
|
|
129
|
+
CGImageGetBytesPerRow: fn(CG, 'CGImageGetBytesPerRow', 'unsigned long CGImageGetBytesPerRow(void* image)'),
|
|
130
|
+
CGImageGetBitsPerPixel: fn(CG, 'CGImageGetBitsPerPixel', 'unsigned long CGImageGetBitsPerPixel(void* image)'),
|
|
131
|
+
CGImageGetDataProvider: fn(CG, 'CGImageGetDataProvider', 'void* CGImageGetDataProvider(void* image)'),
|
|
132
|
+
CFDataGetBytePtr: fn(CF, 'CFDataGetBytePtr', 'const unsigned char* CFDataGetBytePtr(void* data)'),
|
|
133
|
+
CFDataGetLength: fn(CF, 'CFDataGetLength', 'long CFDataGetLength(void* data)'),
|
|
134
|
+
CFDataGetBytes: fn(CF, 'CFDataGetBytes', 'void CFDataGetBytes(void* data, void* buffer, unsigned long length)'),
|
|
135
|
+
CGDataProviderCopyData: fn(CF, 'CGDataProviderCopyData', 'void* CGDataProviderCopyData(void* provider)'),
|
|
136
|
+
CFRelease: fn(CF, 'CFRelease', 'void CFRelease(void* ref)'),
|
|
137
|
+
// CF containers are only aliases of the CoreFoundation types we read windows with
|
|
138
|
+
CFArrayGetCount: fn(CF, 'CFArrayGetCount', 'long CFArrayGetCount(void* array)'),
|
|
139
|
+
CFArrayGetValueAtIndex: fn(CF, 'CFArrayGetValueAtIndex', 'void* CFArrayGetValueAtIndex(void* array, long index)'),
|
|
140
|
+
CFDictionaryGetValue: fn(CF, 'CFDictionaryGetValue', 'void* CFDictionaryGetValue(void* dict, void* key)'),
|
|
141
|
+
CFStringCreateWithCString: fn(
|
|
142
|
+
CF,
|
|
143
|
+
'CFStringCreateWithCString',
|
|
144
|
+
'void* CFStringCreateWithCString(void* allocator, const char* cString, unsigned int encoding)'
|
|
145
|
+
),
|
|
146
|
+
CFStringGetCString: fn(
|
|
147
|
+
CF,
|
|
148
|
+
'CFStringGetCString',
|
|
149
|
+
'bool CFStringGetCString(void* string, void* buffer, long bufferSize, unsigned int encoding)'
|
|
150
|
+
),
|
|
151
|
+
CFStringGetLength: fn(CF, 'CFStringGetLength', 'long CFStringGetLength(void* string)'),
|
|
152
|
+
CFNumberGetValue: fn(CF, 'CFNumberGetValue', 'bool CFNumberGetValue(void* number, int type, void* valuePtr)'),
|
|
153
|
+
CFBooleanGetValue: fn(CF, 'CFBooleanGetValue', 'bool CFBooleanGetValue(void* boolean)'),
|
|
154
|
+
};
|
|
155
|
+
return bound;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function requireCG() {
|
|
159
|
+
if (process.platform !== 'darwin') throw new Error('the macOS backend runs on macOS only');
|
|
160
|
+
return bindings();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const K_CF_STRING_ENCODING_UTF8 = 0x08000100;
|
|
164
|
+
|
|
165
|
+
function cfString(text) {
|
|
166
|
+
const b = bindings();
|
|
167
|
+
return b.CFStringCreateWithCString(null, String(text), K_CF_STRING_ENCODING_UTF8);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function cfToString(ref) {
|
|
171
|
+
if (!ref) return '';
|
|
172
|
+
const koffi = require('koffi');
|
|
173
|
+
const b = bindings();
|
|
174
|
+
const length = Number(b.CFStringGetLength(ref));
|
|
175
|
+
if (!Number.isFinite(length) || length < 0) return '';
|
|
176
|
+
const size = Math.max(16, length * 4 + 1);
|
|
177
|
+
const buffer = koffi.alloc('uint8_t', size);
|
|
178
|
+
if (!b.CFStringGetCString(ref, buffer, size, K_CF_STRING_ENCODING_UTF8)) return '';
|
|
179
|
+
return String(koffi.decode(buffer, 'uint8_t', size)).split('\0')[0];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function cfNumber(ref) {
|
|
183
|
+
if (!ref) return 0;
|
|
184
|
+
const koffi = require('koffi');
|
|
185
|
+
const out = koffi.alloc('double', 1);
|
|
186
|
+
const b = bindings();
|
|
187
|
+
// kCFNumberDoubleType = 13, kCFNumberSInt64Type = 4
|
|
188
|
+
if (b.CFNumberGetValue(ref, 13, out)) return Number(koffi.decode(out, 'double', 1));
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Reads a key out of the CGWindowList dictionaries. */
|
|
193
|
+
function dictValue(dict, key, stringKey) {
|
|
194
|
+
const b = bindings();
|
|
195
|
+
const name = cfString(key);
|
|
196
|
+
let raw = null;
|
|
197
|
+
try {
|
|
198
|
+
raw = b.CFDictionaryGetValue(dict, name);
|
|
199
|
+
if (!raw) return stringKey ? '' : 0;
|
|
200
|
+
return stringKey ? cfToString(raw) : cfNumber(raw);
|
|
201
|
+
} finally {
|
|
202
|
+
if (name) b.CFRelease(name);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
207
|
+
|
|
208
|
+
// ------------------------------------------------------------------- input --
|
|
209
|
+
|
|
210
|
+
function post(event, tap = K_CG_HID_EVENT_TAP) {
|
|
211
|
+
const b = bindings();
|
|
212
|
+
b.CGEventPost(tap, event);
|
|
213
|
+
if (event) b.CFRelease(event);
|
|
214
|
+
// let the window server digest the event before the next one
|
|
215
|
+
sleep(2);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function moveMouse(x, y) {
|
|
219
|
+
const b = requireCG();
|
|
220
|
+
const event = b.CGEventCreateMouseEvent(null, EVENT.mouseMoved, Number(x), Number(y), 0);
|
|
221
|
+
if (!event) return { ok: false, error: 'CGEventCreateMouseEvent failed' };
|
|
222
|
+
post(event);
|
|
223
|
+
return { ok: true, x, y };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function buttonEvent(type, x, y, button = 'left') {
|
|
227
|
+
const b = bindings();
|
|
228
|
+
const which = button === 'right' ? 1 : button === 'middle' ? 2 : 0;
|
|
229
|
+
return b.CGEventCreateMouseEvent(null, type, Number(x), Number(y), which);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function click(x, y, { button = 'left', count = 1 } = {}) {
|
|
233
|
+
const b = requireCG();
|
|
234
|
+
const clicks = Math.max(1, Math.trunc(count) || 1);
|
|
235
|
+
const down = button === 'right' ? EVENT.rightDown : EVENT.leftDown;
|
|
236
|
+
const up = button === 'right' ? EVENT.rightUp : EVENT.leftUp;
|
|
237
|
+
moveMouse(x, y);
|
|
238
|
+
for (let i = 1; i <= Math.min(3, clicks); i += 1) {
|
|
239
|
+
const press = buttonEvent(down, x, y, button);
|
|
240
|
+
if (press) {
|
|
241
|
+
b.CGEventSetIntegerValueField(press, FIELD_CLICK_STATE, i);
|
|
242
|
+
post(press);
|
|
243
|
+
}
|
|
244
|
+
const release = buttonEvent(up, x, y, button);
|
|
245
|
+
if (release) {
|
|
246
|
+
b.CGEventSetIntegerValueField(release, FIELD_CLICK_STATE, i);
|
|
247
|
+
post(release);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { ok: true, x, y, button, count: clicks };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function mouseButton(x, y, button, down) {
|
|
254
|
+
const b = requireCG();
|
|
255
|
+
moveMouse(x, y);
|
|
256
|
+
const type = button === 'right' ? (down ? EVENT.rightDown : EVENT.rightUp) : down ? EVENT.leftDown : EVENT.leftUp;
|
|
257
|
+
const event = buttonEvent(type, x, y, button);
|
|
258
|
+
if (!event) return { ok: false, error: 'CGEventCreateMouseEvent failed' };
|
|
259
|
+
post(event);
|
|
260
|
+
return { ok: true, x, y, button };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function drag(x, y, x2, y2) {
|
|
264
|
+
const b = requireCG();
|
|
265
|
+
const downEvent = buttonEvent(EVENT.leftDown, x, y, 'left');
|
|
266
|
+
if (!downEvent) return { ok: false, error: 'CGEventCreateMouseEvent failed' };
|
|
267
|
+
post(downEvent);
|
|
268
|
+
const steps = 12;
|
|
269
|
+
for (let i = 1; i <= steps; i += 1) {
|
|
270
|
+
const px = Math.round(x + ((x2 - x) * i) / steps);
|
|
271
|
+
const py = Math.round(y + ((y2 - y) * i) / steps);
|
|
272
|
+
const moved = b.CGEventCreateMouseEvent(null, EVENT.leftDragged, px, py, 0);
|
|
273
|
+
if (moved) post(moved);
|
|
274
|
+
}
|
|
275
|
+
const upEvent = buttonEvent(EVENT.leftUp, x2, y2, 'left');
|
|
276
|
+
if (upEvent) post(upEvent);
|
|
277
|
+
const released = b.CGEventCreateMouseEvent(null, EVENT.mouseMoved, x2, y2, 0);
|
|
278
|
+
if (released) post(released);
|
|
279
|
+
return { ok: true, from: [x, y], to: [x2, y2] };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function scroll(amount, x = null, y = null) {
|
|
283
|
+
const b = requireCG();
|
|
284
|
+
if (x !== null && y !== null) moveMouse(x, y);
|
|
285
|
+
const lines = Math.max(1, Math.abs(Math.trunc(amount) || 1));
|
|
286
|
+
const signed = (Math.trunc(amount) || 1) > 0 ? lines : -lines;
|
|
287
|
+
const event = b.CGEventCreateScrollWheelEvent2(null, 1 /* line */, 1, signed, 0, 0);
|
|
288
|
+
if (!event) return { ok: false, error: 'CGEventCreateScrollWheelEvent2 failed' };
|
|
289
|
+
post(event);
|
|
290
|
+
return { ok: true, amount };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Types text as unicode: layout-independent, works for é, ü, 中文, emoji. */
|
|
294
|
+
function typeText(text, x = null, y = null) {
|
|
295
|
+
const b = requireCG();
|
|
296
|
+
if (x !== null && y !== null) click(x, y);
|
|
297
|
+
const value = String(text || '');
|
|
298
|
+
let typed = 0;
|
|
299
|
+
for (const ch of value) {
|
|
300
|
+
const utf16 = Buffer.from(ch, 'utf16le');
|
|
301
|
+
const length = utf16.length / 2;
|
|
302
|
+
const down = b.CGEventCreateKeyboardEvent(null, 0, true);
|
|
303
|
+
const up = b.CGEventCreateKeyboardEvent(null, 0, false);
|
|
304
|
+
if (!down || !up) return { ok: false, error: 'CGEventCreateKeyboardEvent failed' };
|
|
305
|
+
const koffi = require('koffi');
|
|
306
|
+
const buffer = koffi.alloc('uint16_t', length);
|
|
307
|
+
koffi.encode(buffer, koffi.array('uint16_t', length), [...Array(length).keys()].map((i) => utf16.readUInt16LE(i * 2)));
|
|
308
|
+
b.CGEventKeyboardSetUnicodeString(down, length, buffer);
|
|
309
|
+
b.CGEventKeyboardSetUnicodeString(up, length, buffer);
|
|
310
|
+
post(down);
|
|
311
|
+
post(up);
|
|
312
|
+
typed += 1;
|
|
313
|
+
}
|
|
314
|
+
return { ok: true, chars: value.length, typed };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function keycodeFor(name) {
|
|
318
|
+
const key = String(name || '').toLowerCase();
|
|
319
|
+
const alias = ALIASES[key] || key;
|
|
320
|
+
if (alias in VK) return VK[alias];
|
|
321
|
+
if (key.length === 1) {
|
|
322
|
+
const lower = key.toLowerCase();
|
|
323
|
+
if (lower in VK) return VK[lower];
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* `ctrl+shift+t`, `cmd+space`, `alt+f4 x2` → { flags, keys, repeat }. Pure, so it is
|
|
330
|
+
* checked on every platform by test/native.js.
|
|
331
|
+
*/
|
|
332
|
+
function parseChord(combo) {
|
|
333
|
+
const parts = String(combo || '')
|
|
334
|
+
.toLowerCase()
|
|
335
|
+
.split(/[\s+]+/)
|
|
336
|
+
.filter(Boolean);
|
|
337
|
+
if (!parts.length) return { ok: false, error: `unreadable key combination: ${combo}` };
|
|
338
|
+
let repeat = 1;
|
|
339
|
+
let flags = 0;
|
|
340
|
+
const keys = [];
|
|
341
|
+
const modifiers = [];
|
|
342
|
+
for (const part of parts) {
|
|
343
|
+
if (part === 'repeat') {
|
|
344
|
+
repeat = 2;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (/^x\d+$/.test(part)) {
|
|
348
|
+
repeat = Number(part.slice(1));
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (MODIFIERS[part] !== undefined) {
|
|
352
|
+
flags |= MODIFIERS[part];
|
|
353
|
+
modifiers.push(part);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const code = keycodeFor(part);
|
|
357
|
+
if (code !== null) keys.push(code);
|
|
358
|
+
}
|
|
359
|
+
if (!keys.length && !modifiers.length) return { ok: false, error: `unreadable key combination: ${combo}` };
|
|
360
|
+
if (!keys.length) keys.push(VK.space); // a bare modifier: press it
|
|
361
|
+
return { ok: true, flags, keys, modifiers, repeat: Math.min(50, Math.max(1, repeat)) };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function pressKeys(combo) {
|
|
365
|
+
const b = requireCG();
|
|
366
|
+
const chord = parseChord(combo);
|
|
367
|
+
if (!chord.ok) return chord;
|
|
368
|
+
const { flags, keys, repeat } = chord;
|
|
369
|
+
for (let i = 0; i < repeat; i += 1) {
|
|
370
|
+
for (const code of keys) {
|
|
371
|
+
const down = b.CGEventCreateKeyboardEvent(null, code, true);
|
|
372
|
+
const up = b.CGEventCreateKeyboardEvent(null, code, false);
|
|
373
|
+
if (!down || !up) return { ok: false, error: 'CGEventCreateKeyboardEvent failed' };
|
|
374
|
+
if (flags) {
|
|
375
|
+
b.CGEventSetFlags(down, flags);
|
|
376
|
+
b.CGEventSetFlags(up, flags);
|
|
377
|
+
}
|
|
378
|
+
post(down);
|
|
379
|
+
post(up);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return { ok: true, keys: combo };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ------------------------------------------------------------------ screen --
|
|
386
|
+
|
|
387
|
+
function screenSize() {
|
|
388
|
+
const b = requireCG();
|
|
389
|
+
const display = b.CGMainDisplayID();
|
|
390
|
+
return { ok: true, width: Number(b.CGDisplayPixelsWide(display)), height: Number(b.CGDisplayPixelsHigh(display)) };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function windows() {
|
|
394
|
+
const b = requireCG();
|
|
395
|
+
const options = 1 | 16; // kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements
|
|
396
|
+
const list = b.CGWindowListCopyWindowInfo(options, 0);
|
|
397
|
+
if (!list) return { ok: true, windows: [] };
|
|
398
|
+
const out = [];
|
|
399
|
+
try {
|
|
400
|
+
const count = Number(b.CFArrayGetCount(list));
|
|
401
|
+
for (let i = 0; i < count; i += 1) {
|
|
402
|
+
const dict = b.CFArrayGetValueAtIndex(list, i);
|
|
403
|
+
if (!dict) continue;
|
|
404
|
+
const name = dictValue(dict, 'kCGWindowName', true) || dictValue(dict, 'kCGWindowOwnerName', true);
|
|
405
|
+
if (!name) continue;
|
|
406
|
+
const layer = dictValue(dict, 'kCGWindowLayer', false);
|
|
407
|
+
if (layer !== 0) continue; // menu bar, dock, overlays
|
|
408
|
+
// kCGWindowBounds is a nested dictionary with X/Y/Width/Height numbers
|
|
409
|
+
const boundsRef = (() => {
|
|
410
|
+
const key = cfString('kCGWindowBounds');
|
|
411
|
+
try {
|
|
412
|
+
return b.CFDictionaryGetValue(dict, key);
|
|
413
|
+
} finally {
|
|
414
|
+
if (key) b.CFRelease(key);
|
|
415
|
+
}
|
|
416
|
+
})();
|
|
417
|
+
const x = boundsRef ? dictValue(boundsRef, 'X', false) : 0;
|
|
418
|
+
const y = boundsRef ? dictValue(boundsRef, 'Y', false) : 0;
|
|
419
|
+
const width = boundsRef ? dictValue(boundsRef, 'Width', false) : 0;
|
|
420
|
+
const height = boundsRef ? dictValue(boundsRef, 'Height', false) : 0;
|
|
421
|
+
if (!width || !height) continue;
|
|
422
|
+
out.push({
|
|
423
|
+
name,
|
|
424
|
+
handle: dictValue(dict, 'kCGWindowNumber', false),
|
|
425
|
+
hwnd: dictValue(dict, 'kCGWindowNumber', false),
|
|
426
|
+
x: Math.round(x),
|
|
427
|
+
y: Math.round(y),
|
|
428
|
+
width: Math.round(width),
|
|
429
|
+
height: Math.round(height),
|
|
430
|
+
process_id: dictValue(dict, 'kCGWindowOwnerPID', false),
|
|
431
|
+
status: 'normal',
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
} finally {
|
|
435
|
+
b.CFRelease(list);
|
|
436
|
+
}
|
|
437
|
+
return { ok: true, windows: out };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function activeWindow() {
|
|
441
|
+
const res = osascript('tell application "System Events" to get name of first application process whose frontmost is true');
|
|
442
|
+
if (!res.ok || !res.out) return { ok: true, name: '', handle: null };
|
|
443
|
+
const hit = windows().windows.find((w) => w.name === res.out) || null;
|
|
444
|
+
return { ok: true, name: res.out, handle: hit ? hit.handle : null };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** CoreGraphics hands out BGRA (little endian, alpha first) with a padded row stride. */
|
|
448
|
+
function bgraToRgb(raw, width, height, bytesPerRow, bitsPerPixel = 32) {
|
|
449
|
+
const rgb = Buffer.alloc(width * height * 3);
|
|
450
|
+
const bpp = Math.max(4, Math.floor(bitsPerPixel / 8));
|
|
451
|
+
for (let y = 0; y < height; y += 1) {
|
|
452
|
+
for (let x = 0; x < width; x += 1) {
|
|
453
|
+
const src = y * bytesPerRow + x * bpp;
|
|
454
|
+
const dst = (y * width + x) * 3;
|
|
455
|
+
rgb[dst] = raw[src + 2];
|
|
456
|
+
rgb[dst + 1] = raw[src + 1];
|
|
457
|
+
rgb[dst + 2] = raw[src];
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return rgb;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function screenshot(dest) {
|
|
464
|
+
const b = requireCG();
|
|
465
|
+
const display = b.CGMainDisplayID();
|
|
466
|
+
const image = b.CGDisplayCreateImage(display);
|
|
467
|
+
if (!image) return { ok: false, error: 'CGDisplayCreateImage failed (grant Screen Recording to the terminal)' };
|
|
468
|
+
try {
|
|
469
|
+
const width = Number(b.CGImageGetWidth(image));
|
|
470
|
+
const height = Number(b.CGImageGetHeight(image));
|
|
471
|
+
const bytesPerRow = Number(b.CGImageGetBytesPerRow(image));
|
|
472
|
+
const bits = Number(b.CGImageGetBitsPerPixel(image));
|
|
473
|
+
const provider = b.CGImageGetDataProvider(image);
|
|
474
|
+
const data = provider ? b.CGDataProviderCopyData(provider) : null;
|
|
475
|
+
if (!data) return { ok: false, error: 'CGDataProviderCopyData failed' };
|
|
476
|
+
const koffi = require('koffi');
|
|
477
|
+
try {
|
|
478
|
+
const length = Number(b.CFDataGetLength(data));
|
|
479
|
+
const ptr = b.CFDataGetBytePtr(data);
|
|
480
|
+
if (!ptr) return { ok: false, error: 'CFDataGetBytePtr failed' };
|
|
481
|
+
const raw = koffi.decode(ptr, 'uint8_t', length);
|
|
482
|
+
const rgb = bgraToRgb(raw, width, height, bytesPerRow, bits);
|
|
483
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
484
|
+
fs.writeFileSync(dest, encodePng(rgb, width, height));
|
|
485
|
+
return { ok: true, file: dest, width, height, mime: 'image/png' };
|
|
486
|
+
} finally {
|
|
487
|
+
b.CFRelease(data);
|
|
488
|
+
}
|
|
489
|
+
} finally {
|
|
490
|
+
b.CFRelease(image);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ----------------------------------------------------- what CoreGraphics cannot do --
|
|
495
|
+
|
|
496
|
+
function osascript(script) {
|
|
497
|
+
const res = spawnSync('osascript', ['-e', script], { encoding: 'utf8', timeout: 20000 });
|
|
498
|
+
return { ok: res.status === 0, out: String(res.stdout || '').trim(), error: String(res.stderr || '').trim() };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function launchApp(name) {
|
|
502
|
+
const res = spawnSync('open', ['-a', String(name)], { encoding: 'utf8', timeout: 30000 });
|
|
503
|
+
return res.status === 0 ? { ok: true, app: name } : { ok: false, error: String(res.stderr || 'open -a failed').trim() };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function focusWindow(name) {
|
|
507
|
+
const res = osascript(
|
|
508
|
+
`tell application "System Events" to set frontmost of (first application process whose name is ${JSON.stringify(
|
|
509
|
+
String(name)
|
|
510
|
+
)}) to true`
|
|
511
|
+
);
|
|
512
|
+
return res.ok ? { ok: true, name } : { ok: false, error: res.error || `window not found: ${name}` };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function resizeWindow(name, x, y, width, height) {
|
|
516
|
+
const res = osascript(
|
|
517
|
+
`tell application "System Events" to tell (first application process whose name is ${JSON.stringify(
|
|
518
|
+
String(name)
|
|
519
|
+
)}) to set {position, size} of window 1 to {{${x}, ${y}}, {${width}, ${height}}}`
|
|
520
|
+
);
|
|
521
|
+
return res.ok ? { ok: true, name, x, y, width, height } : { ok: false, error: res.error || 'resize failed' };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function listApps() {
|
|
525
|
+
const out = [];
|
|
526
|
+
for (const dir of ['/Applications', '/System/Applications', path.join(os.homedir(), 'Applications')]) {
|
|
527
|
+
let entries = [];
|
|
528
|
+
try {
|
|
529
|
+
entries = fs.readdirSync(dir);
|
|
530
|
+
} catch {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
for (const entry of entries) {
|
|
534
|
+
if (!entry.endsWith('.app')) continue;
|
|
535
|
+
out.push({ name: entry.replace(/\.app$/, ''), app_id: path.join(dir, entry) });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return { ok: true, apps: out };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function state(handle = null) {
|
|
542
|
+
const wins = windows();
|
|
543
|
+
const size = screenSize();
|
|
544
|
+
const active = activeWindow();
|
|
545
|
+
const list = wins.ok ? wins.windows : [];
|
|
546
|
+
const focused = handle || active.handle;
|
|
547
|
+
const elements = list
|
|
548
|
+
.filter((w) => !focused || w.handle === focused)
|
|
549
|
+
.map((w) => ({
|
|
550
|
+
name: w.name,
|
|
551
|
+
control_type: 'Window',
|
|
552
|
+
kind: 'Window',
|
|
553
|
+
bounding_box: { left: w.x, top: w.y, right: w.x + w.width, bottom: w.y + w.height },
|
|
554
|
+
center: { x: w.x + Math.floor(w.width / 2), y: w.y + Math.floor(w.height / 2) },
|
|
555
|
+
value: '',
|
|
556
|
+
enabled: true,
|
|
557
|
+
automation_id: '',
|
|
558
|
+
}));
|
|
559
|
+
return {
|
|
560
|
+
ok: true,
|
|
561
|
+
windows: list,
|
|
562
|
+
active_window: active.handle ? list.find((w) => w.handle === active.handle) || { name: active.name, handle: active.handle } : null,
|
|
563
|
+
interactive_elements: elements,
|
|
564
|
+
scrollable_elements: [],
|
|
565
|
+
interactive_elements_text: elements
|
|
566
|
+
.map((e, i) => `${i + 1}. ${e.control_type} '${e.name}' at (${e.center.x}, ${e.center.y})`)
|
|
567
|
+
.join('\n'),
|
|
568
|
+
scrollable_elements_text: '',
|
|
569
|
+
tree_status: 'coregraphics',
|
|
570
|
+
system: {
|
|
571
|
+
windows_version: `darwin ${os.release()}`,
|
|
572
|
+
os_version: os.release(),
|
|
573
|
+
screen_size: { width: size.width, height: size.height },
|
|
574
|
+
user_account_type: 'Local Account',
|
|
575
|
+
default_language: process.env.LANG || 'en',
|
|
576
|
+
dpi_scaling: 1,
|
|
577
|
+
},
|
|
578
|
+
metadata: { depth: 'top-level', kind: 'windows-list', all_desktops: false },
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function available() {
|
|
583
|
+
if (process.platform !== 'darwin') return { ok: false, backend: 'macos', error: 'macOS only' };
|
|
584
|
+
try {
|
|
585
|
+
const size = screenSize();
|
|
586
|
+
if (!size.width || !size.height) throw new Error('no display');
|
|
587
|
+
return { ok: true, backend: 'macos', detail: 'CoreGraphics via FFI (no external tools)', screen: size };
|
|
588
|
+
} catch (err) {
|
|
589
|
+
return { ok: false, backend: 'macos', error: err.message };
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function sysinfo() {
|
|
594
|
+
const size = screenSize();
|
|
595
|
+
return {
|
|
596
|
+
ok: true,
|
|
597
|
+
system: {
|
|
598
|
+
windows_version: `darwin ${os.release()}`,
|
|
599
|
+
os_version: os.release(),
|
|
600
|
+
screen_size: { width: size.width, height: size.height },
|
|
601
|
+
user_account_type: 'Local Account',
|
|
602
|
+
default_language: process.env.LANG || 'en',
|
|
603
|
+
dpi_scaling: 1,
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const backend = {
|
|
609
|
+
name: 'coregraphics-ffi',
|
|
610
|
+
available,
|
|
611
|
+
screenSize,
|
|
612
|
+
windows,
|
|
613
|
+
activeWindow,
|
|
614
|
+
state,
|
|
615
|
+
moveMouse,
|
|
616
|
+
click,
|
|
617
|
+
mouseButton,
|
|
618
|
+
drag,
|
|
619
|
+
scroll,
|
|
620
|
+
typeText,
|
|
621
|
+
pressKeys,
|
|
622
|
+
focusWindow,
|
|
623
|
+
resizeWindow,
|
|
624
|
+
screenshot,
|
|
625
|
+
launchApp,
|
|
626
|
+
listApps,
|
|
627
|
+
sysinfo,
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
module.exports = {
|
|
631
|
+
backend,
|
|
632
|
+
available,
|
|
633
|
+
screenSize,
|
|
634
|
+
windows,
|
|
635
|
+
activeWindow,
|
|
636
|
+
state,
|
|
637
|
+
moveMouse,
|
|
638
|
+
click,
|
|
639
|
+
mouseButton,
|
|
640
|
+
drag,
|
|
641
|
+
scroll,
|
|
642
|
+
typeText,
|
|
643
|
+
pressKeys,
|
|
644
|
+
focusWindow,
|
|
645
|
+
resizeWindow,
|
|
646
|
+
screenshot,
|
|
647
|
+
launchApp,
|
|
648
|
+
listApps,
|
|
649
|
+
sysinfo,
|
|
650
|
+
VK,
|
|
651
|
+
FLAG,
|
|
652
|
+
MODIFIERS,
|
|
653
|
+
EVENT,
|
|
654
|
+
keycodeFor,
|
|
655
|
+
parseChord,
|
|
656
|
+
bgraToRgb,
|
|
657
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// PNG encoding without dependencies: the screenshots of the Linux and macOS backends
|
|
4
|
+
// are RGB rasters, wrapped here in IHDR/IDAT/IEND with zlib.
|
|
5
|
+
|
|
6
|
+
const zlib = require('node:zlib');
|
|
7
|
+
|
|
8
|
+
function crc32(buf) {
|
|
9
|
+
let c = 0xffffffff;
|
|
10
|
+
for (let i = 0; i < buf.length; i += 1) {
|
|
11
|
+
c ^= buf[i];
|
|
12
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
13
|
+
}
|
|
14
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function pngChunk(type, data) {
|
|
18
|
+
const len = Buffer.alloc(4);
|
|
19
|
+
len.writeUInt32BE(data.length);
|
|
20
|
+
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
21
|
+
const crc = Buffer.alloc(4);
|
|
22
|
+
crc.writeUInt32BE(crc32(body));
|
|
23
|
+
return Buffer.concat([len, body, crc]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** rgb: width*height*3 bytes, top-down, no padding. */
|
|
27
|
+
function encodePng(rgb, width, height) {
|
|
28
|
+
const stride = width * 3;
|
|
29
|
+
const raw = Buffer.alloc((stride + 1) * height);
|
|
30
|
+
for (let y = 0; y < height; y += 1) {
|
|
31
|
+
raw[y * (stride + 1)] = 0; // filter: none
|
|
32
|
+
rgb.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
|
|
33
|
+
}
|
|
34
|
+
const ihdr = Buffer.alloc(13);
|
|
35
|
+
ihdr.writeUInt32BE(width, 0);
|
|
36
|
+
ihdr.writeUInt32BE(height, 4);
|
|
37
|
+
ihdr[8] = 8; // bit depth
|
|
38
|
+
ihdr[9] = 2; // truecolour
|
|
39
|
+
return Buffer.concat([
|
|
40
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
41
|
+
pngChunk('IHDR', ihdr),
|
|
42
|
+
pngChunk('IDAT', zlib.deflateSync(raw, { level: 6 })),
|
|
43
|
+
pngChunk('IEND', Buffer.alloc(0)),
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { encodePng, crc32 };
|