@skillful-agents/agent-computer 0.0.3 → 0.0.5
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/bin/ac-core-darwin-arm64 +0 -0
- package/bin/ac-core-darwin-x64 +0 -0
- package/bin/ac-core-win32-arm64.exe +0 -0
- package/bin/ac-core-win32-x64.exe +0 -0
- package/dist/src/platform/resolve.d.ts.map +1 -1
- package/dist/src/platform/resolve.js +33 -6
- package/dist/src/platform/resolve.js.map +1 -1
- package/dist-cjs/bin/ac.js +127 -0
- package/dist-cjs/package.json +1 -0
- package/dist-cjs/src/bridge.js +693 -0
- package/dist-cjs/src/cdp/ax-tree.js +162 -0
- package/dist-cjs/src/cdp/bounds.js +66 -0
- package/dist-cjs/src/cdp/client.js +272 -0
- package/dist-cjs/src/cdp/connection.js +285 -0
- package/dist-cjs/src/cdp/diff.js +55 -0
- package/dist-cjs/src/cdp/discovery.js +91 -0
- package/dist-cjs/src/cdp/index.js +27 -0
- package/dist-cjs/src/cdp/interactions.js +301 -0
- package/dist-cjs/src/cdp/port-manager.js +68 -0
- package/dist-cjs/src/cdp/role-map.js +102 -0
- package/dist-cjs/src/cdp/types.js +2 -0
- package/dist-cjs/src/cli/commands/apps.js +63 -0
- package/dist-cjs/src/cli/commands/batch.js +37 -0
- package/dist-cjs/src/cli/commands/click.js +61 -0
- package/dist-cjs/src/cli/commands/clipboard.js +31 -0
- package/dist-cjs/src/cli/commands/dialog.js +45 -0
- package/dist-cjs/src/cli/commands/drag.js +26 -0
- package/dist-cjs/src/cli/commands/find.js +99 -0
- package/dist-cjs/src/cli/commands/menu.js +36 -0
- package/dist-cjs/src/cli/commands/screenshot.js +27 -0
- package/dist-cjs/src/cli/commands/scroll.js +77 -0
- package/dist-cjs/src/cli/commands/session.js +27 -0
- package/dist-cjs/src/cli/commands/snapshot.js +24 -0
- package/dist-cjs/src/cli/commands/type.js +69 -0
- package/dist-cjs/src/cli/commands/windowmgmt.js +62 -0
- package/dist-cjs/src/cli/commands/windows.js +10 -0
- package/dist-cjs/src/cli/commands.js +215 -0
- package/dist-cjs/src/cli/output.js +253 -0
- package/dist-cjs/src/cli/parser.js +128 -0
- package/dist-cjs/src/config.js +79 -0
- package/dist-cjs/src/daemon.js +183 -0
- package/dist-cjs/src/errors.js +118 -0
- package/dist-cjs/src/index.js +24 -0
- package/dist-cjs/src/platform/index.js +16 -0
- package/dist-cjs/src/platform/resolve.js +83 -0
- package/dist-cjs/src/refs.js +91 -0
- package/dist-cjs/src/sdk.js +288 -0
- package/dist-cjs/src/types.js +11 -0
- package/package.json +4 -2
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Bridge = void 0;
|
|
4
|
+
exports.buildRequest = buildRequest;
|
|
5
|
+
exports.parseResponse = parseResponse;
|
|
6
|
+
exports._resetIdCounter = _resetIdCounter;
|
|
7
|
+
const net_1 = require("net");
|
|
8
|
+
const child_process_1 = require("child_process");
|
|
9
|
+
const fs_1 = require("fs");
|
|
10
|
+
const errors_js_1 = require("./errors.js");
|
|
11
|
+
const index_js_1 = require("./platform/index.js");
|
|
12
|
+
const resolve_js_1 = require("./platform/resolve.js");
|
|
13
|
+
const client_js_1 = require("./cdp/client.js");
|
|
14
|
+
const port_manager_js_1 = require("./cdp/port-manager.js");
|
|
15
|
+
const discovery_js_1 = require("./cdp/discovery.js");
|
|
16
|
+
let nextId = 1;
|
|
17
|
+
// Methods that can be routed to CDP when a Chromium app is grabbed
|
|
18
|
+
const CDP_CAPABLE_METHODS = new Set([
|
|
19
|
+
'snapshot', 'find', 'read', 'children', 'click', 'hover', 'focus',
|
|
20
|
+
'type', 'fill', 'key', 'scroll', 'select', 'check', 'uncheck',
|
|
21
|
+
'box', 'is', 'changed', 'diff',
|
|
22
|
+
]);
|
|
23
|
+
function buildRequest(method, params = {}) {
|
|
24
|
+
return {
|
|
25
|
+
jsonrpc: '2.0',
|
|
26
|
+
id: nextId++,
|
|
27
|
+
method,
|
|
28
|
+
params,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function parseResponse(raw) {
|
|
32
|
+
if (raw === null || raw === undefined || typeof raw !== 'object') {
|
|
33
|
+
throw new Error('Invalid JSON-RPC response: expected an object');
|
|
34
|
+
}
|
|
35
|
+
const resp = raw;
|
|
36
|
+
if (resp.jsonrpc !== '2.0') {
|
|
37
|
+
throw new Error('Invalid JSON-RPC response: missing or incorrect "jsonrpc" field');
|
|
38
|
+
}
|
|
39
|
+
if (!('id' in resp)) {
|
|
40
|
+
throw new Error('Invalid JSON-RPC response: missing "id" field');
|
|
41
|
+
}
|
|
42
|
+
if ('error' in resp && resp.error) {
|
|
43
|
+
const err = resp.error;
|
|
44
|
+
throw (0, errors_js_1.errorFromCode)(err.code, err.message, err.data);
|
|
45
|
+
}
|
|
46
|
+
if ('result' in resp) {
|
|
47
|
+
return resp.result;
|
|
48
|
+
}
|
|
49
|
+
throw new Error('Invalid JSON-RPC response: missing both "result" and "error"');
|
|
50
|
+
}
|
|
51
|
+
function _resetIdCounter() {
|
|
52
|
+
nextId = 1;
|
|
53
|
+
}
|
|
54
|
+
class Bridge {
|
|
55
|
+
socket = null;
|
|
56
|
+
daemonProcess = null;
|
|
57
|
+
binaryPath;
|
|
58
|
+
timeout;
|
|
59
|
+
pendingRequests = new Map();
|
|
60
|
+
buffer = '';
|
|
61
|
+
bomChecked = false;
|
|
62
|
+
// CDP routing state
|
|
63
|
+
cdpClients = new Map(); // pid → CDPClient
|
|
64
|
+
grabbedAppInfo = null;
|
|
65
|
+
constructor(options = {}) {
|
|
66
|
+
this.timeout = options.timeout ?? 10000;
|
|
67
|
+
this.binaryPath = options.binaryPath ?? (0, resolve_js_1.resolveBinary)();
|
|
68
|
+
}
|
|
69
|
+
// Send a JSON-RPC request — routes to CDP or native daemon
|
|
70
|
+
async send(method, params = {}) {
|
|
71
|
+
// Special handlers
|
|
72
|
+
if (method === 'launch')
|
|
73
|
+
return this.handleLaunch(params);
|
|
74
|
+
if (method === 'relaunch')
|
|
75
|
+
return this.handleRelaunch(params);
|
|
76
|
+
if (method === 'grab')
|
|
77
|
+
return this.handleGrab(params);
|
|
78
|
+
if (method === 'ungrab')
|
|
79
|
+
return this.handleUngrab(params);
|
|
80
|
+
if (method === 'batch')
|
|
81
|
+
return this.handleBatch(params);
|
|
82
|
+
if (method === 'wait')
|
|
83
|
+
return this.handleWait(params);
|
|
84
|
+
// Route to CDP if applicable
|
|
85
|
+
if (CDP_CAPABLE_METHODS.has(method) && await this.ensureCDPIfNeeded()) {
|
|
86
|
+
return this.sendToCDP(method, params);
|
|
87
|
+
}
|
|
88
|
+
return this.sendToNative(method, params);
|
|
89
|
+
}
|
|
90
|
+
// Send directly to the native daemon
|
|
91
|
+
async sendToNative(method, params = {}) {
|
|
92
|
+
// Ensure daemon is running and connected
|
|
93
|
+
if (!this.socket || this.socket.destroyed) {
|
|
94
|
+
await this.ensureDaemon();
|
|
95
|
+
}
|
|
96
|
+
const request = buildRequest(method, params);
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
this.pendingRequests.delete(request.id);
|
|
100
|
+
reject(new errors_js_1.TimeoutError(`Command "${method}" timed out after ${this.timeout}ms`));
|
|
101
|
+
}, this.timeout);
|
|
102
|
+
this.pendingRequests.set(request.id, { resolve, reject, timer });
|
|
103
|
+
const line = JSON.stringify(request) + '\n';
|
|
104
|
+
this.socket.write(line, (err) => {
|
|
105
|
+
if (err) {
|
|
106
|
+
this.pendingRequests.delete(request.id);
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
reject(new Error(`Failed to send command: ${err.message}`));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// Send a JSON-RPC request via one-shot mode (exec binary per command)
|
|
114
|
+
sendOneShot(method, params = {}) {
|
|
115
|
+
const request = buildRequest(method, params);
|
|
116
|
+
const input = JSON.stringify(request);
|
|
117
|
+
try {
|
|
118
|
+
const stdout = (0, child_process_1.execFileSync)(this.binaryPath, [], {
|
|
119
|
+
encoding: 'utf-8',
|
|
120
|
+
input,
|
|
121
|
+
timeout: this.timeout,
|
|
122
|
+
}).trim();
|
|
123
|
+
const raw = JSON.parse(stdout);
|
|
124
|
+
return parseResponse(raw);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
if (err instanceof errors_js_1.ACError)
|
|
128
|
+
throw err;
|
|
129
|
+
// Try to parse error stdout (may contain BOM on Windows)
|
|
130
|
+
const errOut = (err.stdout || err.output?.[1]?.toString() || '').replace(/^\uFEFF/, '').trim();
|
|
131
|
+
if (errOut) {
|
|
132
|
+
try {
|
|
133
|
+
const raw = JSON.parse(errOut);
|
|
134
|
+
return parseResponse(raw);
|
|
135
|
+
}
|
|
136
|
+
catch (parseErr) {
|
|
137
|
+
if (parseErr instanceof errors_js_1.ACError)
|
|
138
|
+
throw parseErr;
|
|
139
|
+
/* fall through */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
throw new Error(`One-shot command failed: ${err.message}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Lazily detect if the currently grabbed app has CDP available.
|
|
147
|
+
* Checks live every time: asks daemon for grab state, checks if Chromium,
|
|
148
|
+
* scans port range for a live CDP endpoint.
|
|
149
|
+
*/
|
|
150
|
+
async ensureCDPIfNeeded() {
|
|
151
|
+
// Already connected this session
|
|
152
|
+
if (this.grabbedAppInfo?.isCDP)
|
|
153
|
+
return true;
|
|
154
|
+
// Ask the daemon what's grabbed (status includes app name and pid from cached window info)
|
|
155
|
+
let status;
|
|
156
|
+
try {
|
|
157
|
+
status = await this.sendToNative('status');
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
const app = status.grabbed_app;
|
|
163
|
+
const pid = status.grabbed_pid;
|
|
164
|
+
if (!app || !pid)
|
|
165
|
+
return false;
|
|
166
|
+
// Ask daemon for CDP port
|
|
167
|
+
let port;
|
|
168
|
+
try {
|
|
169
|
+
const result = await this.sendToNative('cdp_port', { name: app });
|
|
170
|
+
if (result.port)
|
|
171
|
+
port = result.port;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
if (!port)
|
|
177
|
+
return false;
|
|
178
|
+
// Connect CDP client
|
|
179
|
+
try {
|
|
180
|
+
await (0, discovery_js_1.waitForCDP)(port, 3000);
|
|
181
|
+
const client = new client_js_1.CDPClient(port);
|
|
182
|
+
await client.connect();
|
|
183
|
+
this.cdpClients.set(pid, client);
|
|
184
|
+
this.grabbedAppInfo = { pid, isCDP: true, app };
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async sendToCDP(method, params) {
|
|
192
|
+
const pid = this.grabbedAppInfo.pid;
|
|
193
|
+
const client = this.cdpClients.get(pid);
|
|
194
|
+
if (!client || !client.isConnected()) {
|
|
195
|
+
throw new Error('CDP client not connected for grabbed app');
|
|
196
|
+
}
|
|
197
|
+
// For non-snapshot commands, ensure we have state from a previous snapshot
|
|
198
|
+
if (method !== 'snapshot' && client.getLastRefMap().size === 0) {
|
|
199
|
+
// First try loading the persisted refMap (fast, for click/hover/focus)
|
|
200
|
+
try {
|
|
201
|
+
const kv = await this.sendToNative('kv_get', { key: 'cdp_refmap' });
|
|
202
|
+
if (kv.value && typeof kv.value === 'object') {
|
|
203
|
+
const map = client.getLastRefMap();
|
|
204
|
+
for (const [ref, nodeRef] of Object.entries(kv.value)) {
|
|
205
|
+
map.set(ref, nodeRef);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch { /* old daemon without kv_set */ }
|
|
210
|
+
// For query commands that need full elements, take a fresh snapshot
|
|
211
|
+
const NEEDS_ELEMENTS = new Set(['find', 'read', 'is', 'children', 'changed', 'diff']);
|
|
212
|
+
if (NEEDS_ELEMENTS.has(method)) {
|
|
213
|
+
const windowInfo = await this.sendToNative('windows', { app: this.grabbedAppInfo.app });
|
|
214
|
+
const win = windowInfo.windows?.[0];
|
|
215
|
+
if (win)
|
|
216
|
+
await client.snapshot({}, win);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
switch (method) {
|
|
220
|
+
case 'snapshot': {
|
|
221
|
+
// Get window info from native daemon
|
|
222
|
+
const windowInfo = await this.sendToNative('windows', { app: this.grabbedAppInfo.app });
|
|
223
|
+
const win = windowInfo.windows?.[0];
|
|
224
|
+
if (!win)
|
|
225
|
+
throw new Error('No window found for grabbed CDP app');
|
|
226
|
+
const snap = await client.snapshot({
|
|
227
|
+
interactive: params.interactive,
|
|
228
|
+
depth: params.depth,
|
|
229
|
+
}, win);
|
|
230
|
+
// Persist refMap to daemon so other CLI invocations can use it
|
|
231
|
+
const serialized = {};
|
|
232
|
+
for (const [ref, nodeRef] of client.getLastRefMap()) {
|
|
233
|
+
serialized[ref] = { nodeId: nodeRef.nodeId, backendDOMNodeId: nodeRef.backendDOMNodeId };
|
|
234
|
+
}
|
|
235
|
+
await this.sendToNative('kv_set', { key: 'cdp_refmap', value: serialized }).catch(() => { });
|
|
236
|
+
return snap;
|
|
237
|
+
}
|
|
238
|
+
case 'click': {
|
|
239
|
+
if (params.ref) {
|
|
240
|
+
await client.click(params.ref, {
|
|
241
|
+
right: params.right,
|
|
242
|
+
double: params.double,
|
|
243
|
+
count: params.count,
|
|
244
|
+
modifiers: params.modifiers,
|
|
245
|
+
});
|
|
246
|
+
return { ok: true };
|
|
247
|
+
}
|
|
248
|
+
// Coordinate clicks use screen coords — native CGEvent handles these correctly
|
|
249
|
+
return this.sendToNative(method, params);
|
|
250
|
+
}
|
|
251
|
+
case 'hover': {
|
|
252
|
+
if (params.ref) {
|
|
253
|
+
await client.hover(params.ref);
|
|
254
|
+
return { ok: true };
|
|
255
|
+
}
|
|
256
|
+
// Coordinate hovers use screen coords — route to native
|
|
257
|
+
return this.sendToNative(method, params);
|
|
258
|
+
}
|
|
259
|
+
case 'focus': {
|
|
260
|
+
await client.focus(params.ref);
|
|
261
|
+
return { ok: true };
|
|
262
|
+
}
|
|
263
|
+
case 'type': {
|
|
264
|
+
await client.type(params.text, { delay: params.delay });
|
|
265
|
+
return { ok: true };
|
|
266
|
+
}
|
|
267
|
+
case 'fill': {
|
|
268
|
+
await client.fill(params.ref, params.text);
|
|
269
|
+
return { ok: true };
|
|
270
|
+
}
|
|
271
|
+
case 'key': {
|
|
272
|
+
await client.key(params.combo, params.repeat);
|
|
273
|
+
return { ok: true };
|
|
274
|
+
}
|
|
275
|
+
case 'scroll': {
|
|
276
|
+
await client.scroll(params.direction, {
|
|
277
|
+
amount: params.amount,
|
|
278
|
+
on: params.on,
|
|
279
|
+
});
|
|
280
|
+
return { ok: true };
|
|
281
|
+
}
|
|
282
|
+
case 'select': {
|
|
283
|
+
await client.select(params.ref, params.value);
|
|
284
|
+
return { ok: true };
|
|
285
|
+
}
|
|
286
|
+
case 'check': {
|
|
287
|
+
await client.check(params.ref);
|
|
288
|
+
return { ok: true };
|
|
289
|
+
}
|
|
290
|
+
case 'uncheck': {
|
|
291
|
+
await client.uncheck(params.ref);
|
|
292
|
+
return { ok: true };
|
|
293
|
+
}
|
|
294
|
+
case 'find': {
|
|
295
|
+
return client.find(params.text, {
|
|
296
|
+
role: params.role,
|
|
297
|
+
first: params.first,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
case 'read': {
|
|
301
|
+
return client.read(params.ref, params.attr);
|
|
302
|
+
}
|
|
303
|
+
case 'box': {
|
|
304
|
+
return client.box(params.ref);
|
|
305
|
+
}
|
|
306
|
+
case 'is': {
|
|
307
|
+
return client.is(params.state, params.ref);
|
|
308
|
+
}
|
|
309
|
+
case 'children': {
|
|
310
|
+
return client.children(params.ref);
|
|
311
|
+
}
|
|
312
|
+
case 'changed': {
|
|
313
|
+
const changed = await client.changed();
|
|
314
|
+
return { ok: true, changed };
|
|
315
|
+
}
|
|
316
|
+
case 'diff': {
|
|
317
|
+
const diff = await client.diff();
|
|
318
|
+
return { ok: true, ...diff };
|
|
319
|
+
}
|
|
320
|
+
default:
|
|
321
|
+
// Fall through to native for unhandled CDP methods
|
|
322
|
+
return this.sendToNative(method, params);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async handleLaunch(params) {
|
|
326
|
+
const name = params.name;
|
|
327
|
+
if (!name)
|
|
328
|
+
return this.sendToNative('launch', params);
|
|
329
|
+
// Check if app is Chromium-based
|
|
330
|
+
let isChromium = false;
|
|
331
|
+
try {
|
|
332
|
+
const result = await this.sendToNative('is_chromium', { name });
|
|
333
|
+
isChromium = result.is_chromium;
|
|
334
|
+
}
|
|
335
|
+
catch { /* method unavailable — old daemon */ }
|
|
336
|
+
if (!isChromium) {
|
|
337
|
+
return this.sendToNative('launch', params);
|
|
338
|
+
}
|
|
339
|
+
// Chromium app — tell daemon to launch with CDP
|
|
340
|
+
const port = await (0, port_manager_js_1.findFreePort)();
|
|
341
|
+
const result = await this.sendToNative('launch_cdp', { name, port });
|
|
342
|
+
// Wait for CDP to be ready
|
|
343
|
+
await (0, discovery_js_1.waitForCDP)(port, 15000);
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
async handleRelaunch(params) {
|
|
347
|
+
const name = params.name;
|
|
348
|
+
if (!name)
|
|
349
|
+
throw new Error('Missing app name for relaunch');
|
|
350
|
+
// Quit the app first
|
|
351
|
+
try {
|
|
352
|
+
await this.sendToNative('quit', { name, force: true });
|
|
353
|
+
}
|
|
354
|
+
catch { /* ok if not running */ }
|
|
355
|
+
// Wait for it to exit
|
|
356
|
+
await sleep(2000);
|
|
357
|
+
// Relaunch with CDP
|
|
358
|
+
return this.handleLaunch({ ...params, name });
|
|
359
|
+
}
|
|
360
|
+
async handleGrab(params) {
|
|
361
|
+
// Send grab to native daemon first
|
|
362
|
+
const result = await this.sendToNative('grab', params);
|
|
363
|
+
// Window info is nested inside result.window
|
|
364
|
+
const windowInfo = (result.window ?? result);
|
|
365
|
+
const pid = windowInfo.process_id;
|
|
366
|
+
const app = windowInfo.app;
|
|
367
|
+
// Store grab info; CDP connection is established lazily by ensureCDPIfNeeded
|
|
368
|
+
this.grabbedAppInfo = pid && app ? { pid, isCDP: false, app } : null;
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
async handleUngrab(params) {
|
|
372
|
+
this.grabbedAppInfo = null;
|
|
373
|
+
return this.sendToNative('ungrab', params);
|
|
374
|
+
}
|
|
375
|
+
async handleBatch(params) {
|
|
376
|
+
const commands = params.commands;
|
|
377
|
+
const stopOnError = params.stop_on_error !== false;
|
|
378
|
+
if (!commands || !Array.isArray(commands)) {
|
|
379
|
+
return this.sendToNative('batch', params);
|
|
380
|
+
}
|
|
381
|
+
// Route each sub-command individually through the CDP/native decision
|
|
382
|
+
const results = [];
|
|
383
|
+
for (const cmd of commands) {
|
|
384
|
+
const [method, ...rest] = cmd;
|
|
385
|
+
const cmdParams = (rest[0] && typeof rest[0] === 'object') ? rest[0] : {};
|
|
386
|
+
try {
|
|
387
|
+
const result = await this.send(method, cmdParams);
|
|
388
|
+
results.push({ ok: true, result });
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
results.push({ ok: false, error: err.message });
|
|
392
|
+
if (stopOnError)
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return { ok: true, results };
|
|
397
|
+
}
|
|
398
|
+
async handleWait(params) {
|
|
399
|
+
// waitForText through CDP needs to poll snapshot
|
|
400
|
+
if (params.text && this.grabbedAppInfo?.isCDP) {
|
|
401
|
+
const text = params.text;
|
|
402
|
+
const timeout = params.timeout ?? 10000;
|
|
403
|
+
const gone = params.gone === true;
|
|
404
|
+
const start = Date.now();
|
|
405
|
+
while (Date.now() - start < timeout) {
|
|
406
|
+
const pid = this.grabbedAppInfo.pid;
|
|
407
|
+
const client = this.cdpClients.get(pid);
|
|
408
|
+
if (client?.isConnected()) {
|
|
409
|
+
try {
|
|
410
|
+
const windowInfo = await this.sendToNative('windows', { app: this.grabbedAppInfo.app });
|
|
411
|
+
const win = windowInfo.windows?.[0];
|
|
412
|
+
if (win) {
|
|
413
|
+
const snap = await client.snapshot({}, win);
|
|
414
|
+
const found = this.flattenElements(snap.elements).some(el => el.label?.includes(text) || el.value?.includes(text));
|
|
415
|
+
if (gone ? !found : found)
|
|
416
|
+
return { ok: true };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
catch { /* retry */ }
|
|
420
|
+
}
|
|
421
|
+
await sleep(500);
|
|
422
|
+
}
|
|
423
|
+
throw new errors_js_1.TimeoutError(`Text "${text}" ${gone ? 'did not disappear' : 'not found'} within ${timeout}ms`);
|
|
424
|
+
}
|
|
425
|
+
// waitForApp and waitForWindow stay native
|
|
426
|
+
return this.sendToNative('wait', params);
|
|
427
|
+
}
|
|
428
|
+
flattenElements(elements) {
|
|
429
|
+
const result = [];
|
|
430
|
+
const walk = (els) => {
|
|
431
|
+
for (const el of els) {
|
|
432
|
+
result.push(el);
|
|
433
|
+
if (el.children)
|
|
434
|
+
walk(el.children);
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
walk(elements);
|
|
438
|
+
return result;
|
|
439
|
+
}
|
|
440
|
+
async ensureDaemon() {
|
|
441
|
+
// Check if a daemon is already running
|
|
442
|
+
const info = this.readDaemonInfo();
|
|
443
|
+
if (info && this.isProcessAlive(info.pid)) {
|
|
444
|
+
try {
|
|
445
|
+
await this.connectToSocket(info.socket);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// Socket exists but connection failed — daemon is dead
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// Clean up stale state
|
|
453
|
+
this.cleanupStaleFiles();
|
|
454
|
+
// Spawn new daemon
|
|
455
|
+
await this.spawnDaemon();
|
|
456
|
+
await this.waitForSocket();
|
|
457
|
+
// Poll for daemon.json to appear (the daemon may flush it slightly after the socket/pipe is ready)
|
|
458
|
+
const djStart = Date.now();
|
|
459
|
+
while (Date.now() - djStart < 2000) {
|
|
460
|
+
if ((0, fs_1.existsSync)(index_js_1.DAEMON_JSON_PATH))
|
|
461
|
+
break;
|
|
462
|
+
await sleep(50);
|
|
463
|
+
}
|
|
464
|
+
const newInfo = this.readDaemonInfo();
|
|
465
|
+
if (!newInfo) {
|
|
466
|
+
throw new Error('Daemon started but daemon.json not found');
|
|
467
|
+
}
|
|
468
|
+
await this.connectToSocket(newInfo.socket);
|
|
469
|
+
}
|
|
470
|
+
async spawnDaemon() {
|
|
471
|
+
(0, fs_1.mkdirSync)(index_js_1.AC_DIR, { recursive: true });
|
|
472
|
+
this.daemonProcess = (0, child_process_1.spawn)(this.binaryPath, ['--daemon'], {
|
|
473
|
+
detached: true,
|
|
474
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
475
|
+
});
|
|
476
|
+
this.daemonProcess.unref();
|
|
477
|
+
// Log daemon stderr for debugging
|
|
478
|
+
this.daemonProcess.stderr?.on('data', (data) => {
|
|
479
|
+
if (process.env.AC_VERBOSE === '1') {
|
|
480
|
+
process.stderr.write(data);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
this.daemonProcess.on('exit', (code) => {
|
|
484
|
+
if (process.env.AC_VERBOSE === '1') {
|
|
485
|
+
process.stderr.write(`[bridge] daemon exited with code ${code}\n`);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
async waitForSocket(maxWait = 5000) {
|
|
490
|
+
const start = Date.now();
|
|
491
|
+
if (index_js_1.IS_NAMED_PIPE) {
|
|
492
|
+
// Named pipes don't exist as files — probe by attempting connection
|
|
493
|
+
while (Date.now() - start < maxWait) {
|
|
494
|
+
try {
|
|
495
|
+
await this.probeConnection(index_js_1.SOCKET_PATH);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
catch { /* not ready yet */ }
|
|
499
|
+
await sleep(100);
|
|
500
|
+
}
|
|
501
|
+
throw new Error(`Daemon pipe did not become available within ${maxWait}ms`);
|
|
502
|
+
}
|
|
503
|
+
// Unix socket: wait for file to appear on disk
|
|
504
|
+
while (Date.now() - start < maxWait) {
|
|
505
|
+
if ((0, fs_1.existsSync)(index_js_1.SOCKET_PATH)) {
|
|
506
|
+
await sleep(50);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
await sleep(50);
|
|
510
|
+
}
|
|
511
|
+
throw new Error(`Daemon socket did not appear within ${maxWait}ms`);
|
|
512
|
+
}
|
|
513
|
+
probeConnection(path) {
|
|
514
|
+
return new Promise((resolve, reject) => {
|
|
515
|
+
const sock = (0, net_1.connect)({ path });
|
|
516
|
+
const timeout = setTimeout(() => { sock.destroy(); reject(new Error('probe timeout')); }, 500);
|
|
517
|
+
sock.on('connect', () => { clearTimeout(timeout); sock.destroy(); resolve(); });
|
|
518
|
+
sock.on('error', (err) => { clearTimeout(timeout); reject(err); });
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
connectToSocket(socketPath) {
|
|
522
|
+
return new Promise((resolve, reject) => {
|
|
523
|
+
const sock = (0, net_1.connect)({ path: socketPath });
|
|
524
|
+
const timeout = setTimeout(() => {
|
|
525
|
+
sock.destroy();
|
|
526
|
+
reject(new Error('Socket connection timed out'));
|
|
527
|
+
}, 3000);
|
|
528
|
+
sock.on('connect', () => {
|
|
529
|
+
clearTimeout(timeout);
|
|
530
|
+
this.socket = sock;
|
|
531
|
+
this.buffer = '';
|
|
532
|
+
this.bomChecked = false;
|
|
533
|
+
this.setupSocketHandlers();
|
|
534
|
+
resolve();
|
|
535
|
+
});
|
|
536
|
+
sock.on('error', (err) => {
|
|
537
|
+
clearTimeout(timeout);
|
|
538
|
+
reject(err);
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
setupSocketHandlers() {
|
|
543
|
+
if (!this.socket)
|
|
544
|
+
return;
|
|
545
|
+
this.socket.on('data', (chunk) => {
|
|
546
|
+
let str = chunk.toString();
|
|
547
|
+
// Strip UTF-8 BOM if present (Windows .NET may emit one on first chunk)
|
|
548
|
+
if (!this.bomChecked) {
|
|
549
|
+
if (str.charCodeAt(0) === 0xFEFF)
|
|
550
|
+
str = str.slice(1);
|
|
551
|
+
this.bomChecked = true;
|
|
552
|
+
}
|
|
553
|
+
this.buffer += str;
|
|
554
|
+
this.processBuffer();
|
|
555
|
+
});
|
|
556
|
+
this.socket.on('close', () => {
|
|
557
|
+
// Reject all pending requests
|
|
558
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
559
|
+
clearTimeout(pending.timer);
|
|
560
|
+
pending.reject(new Error('Socket closed'));
|
|
561
|
+
this.pendingRequests.delete(id);
|
|
562
|
+
}
|
|
563
|
+
this.socket = null;
|
|
564
|
+
});
|
|
565
|
+
this.socket.on('error', (err) => {
|
|
566
|
+
if (process.env.AC_VERBOSE === '1') {
|
|
567
|
+
process.stderr.write(`[bridge] socket error: ${err.message}\n`);
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
processBuffer() {
|
|
572
|
+
let newlineIdx;
|
|
573
|
+
while ((newlineIdx = this.buffer.indexOf('\n')) !== -1) {
|
|
574
|
+
const line = this.buffer.slice(0, newlineIdx);
|
|
575
|
+
this.buffer = this.buffer.slice(newlineIdx + 1);
|
|
576
|
+
if (!line.trim())
|
|
577
|
+
continue;
|
|
578
|
+
try {
|
|
579
|
+
const raw = JSON.parse(line);
|
|
580
|
+
const pending = this.pendingRequests.get(raw.id);
|
|
581
|
+
if (pending) {
|
|
582
|
+
this.pendingRequests.delete(raw.id);
|
|
583
|
+
clearTimeout(pending.timer);
|
|
584
|
+
try {
|
|
585
|
+
const result = parseResponse(raw);
|
|
586
|
+
pending.resolve(result);
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
pending.reject(err);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
// Malformed response — ignore
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
readDaemonInfo() {
|
|
599
|
+
try {
|
|
600
|
+
if (!(0, fs_1.existsSync)(index_js_1.DAEMON_JSON_PATH))
|
|
601
|
+
return null;
|
|
602
|
+
const raw = (0, fs_1.readFileSync)(index_js_1.DAEMON_JSON_PATH, 'utf-8');
|
|
603
|
+
return JSON.parse(raw);
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
isProcessAlive(pid) {
|
|
610
|
+
try {
|
|
611
|
+
process.kill(pid, 0);
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
cleanupStaleFiles() {
|
|
619
|
+
// Named pipes are kernel objects — no file to unlink
|
|
620
|
+
if (!index_js_1.IS_NAMED_PIPE) {
|
|
621
|
+
try {
|
|
622
|
+
(0, fs_1.unlinkSync)(index_js_1.SOCKET_PATH);
|
|
623
|
+
}
|
|
624
|
+
catch { /* ok */ }
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
(0, fs_1.unlinkSync)(index_js_1.DAEMON_JSON_PATH);
|
|
628
|
+
}
|
|
629
|
+
catch { /* ok */ }
|
|
630
|
+
}
|
|
631
|
+
isRunning() {
|
|
632
|
+
return this.socket !== null && !this.socket.destroyed;
|
|
633
|
+
}
|
|
634
|
+
daemonPid() {
|
|
635
|
+
const info = this.readDaemonInfo();
|
|
636
|
+
return info?.pid ?? null;
|
|
637
|
+
}
|
|
638
|
+
async disconnect() {
|
|
639
|
+
// Disconnect all CDP clients
|
|
640
|
+
for (const [pid, client] of this.cdpClients) {
|
|
641
|
+
try {
|
|
642
|
+
await client.disconnect();
|
|
643
|
+
}
|
|
644
|
+
catch { /* ok */ }
|
|
645
|
+
}
|
|
646
|
+
this.cdpClients.clear();
|
|
647
|
+
this.grabbedAppInfo = null;
|
|
648
|
+
if (this.socket && !this.socket.destroyed) {
|
|
649
|
+
this.socket.destroy();
|
|
650
|
+
this.socket = null;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
async shutdown() {
|
|
654
|
+
// Disconnect all CDP clients first
|
|
655
|
+
for (const [pid, client] of this.cdpClients) {
|
|
656
|
+
try {
|
|
657
|
+
await client.disconnect();
|
|
658
|
+
}
|
|
659
|
+
catch { /* ok */ }
|
|
660
|
+
}
|
|
661
|
+
this.cdpClients.clear();
|
|
662
|
+
this.grabbedAppInfo = null;
|
|
663
|
+
if (this.socket && !this.socket.destroyed) {
|
|
664
|
+
try {
|
|
665
|
+
await this.sendToNative('shutdown', {});
|
|
666
|
+
}
|
|
667
|
+
catch { /* ok — socket may close before response */ }
|
|
668
|
+
await sleep(100);
|
|
669
|
+
}
|
|
670
|
+
this.socket?.destroy();
|
|
671
|
+
this.socket = null;
|
|
672
|
+
}
|
|
673
|
+
// Kill the daemon process directly (for testing crash recovery)
|
|
674
|
+
_killDaemonProcess() {
|
|
675
|
+
const info = this.readDaemonInfo();
|
|
676
|
+
if (info) {
|
|
677
|
+
try {
|
|
678
|
+
process.kill(info.pid, 'SIGKILL');
|
|
679
|
+
}
|
|
680
|
+
catch { /* ok */ }
|
|
681
|
+
}
|
|
682
|
+
this.socket?.destroy();
|
|
683
|
+
this.socket = null;
|
|
684
|
+
}
|
|
685
|
+
// Send raw data directly to socket (for testing malformed input handling)
|
|
686
|
+
_sendRawToSocket(data) {
|
|
687
|
+
this.socket?.write(data);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
exports.Bridge = Bridge;
|
|
691
|
+
function sleep(ms) {
|
|
692
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
693
|
+
}
|