neoagent 2.4.0 → 2.4.1-beta.11

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.
Files changed (57) hide show
  1. package/LICENSE +619 -21
  2. package/README.md +1 -1
  3. package/extensions/chrome-browser/background.mjs +19 -7
  4. package/extensions/chrome-browser/icons/icon128.png +0 -0
  5. package/extensions/chrome-browser/icons/icon16.png +0 -0
  6. package/extensions/chrome-browser/icons/icon48.png +0 -0
  7. package/extensions/chrome-browser/icons/logo.svg +12 -0
  8. package/extensions/chrome-browser/manifest.json +13 -2
  9. package/extensions/chrome-browser/popup.css +5 -0
  10. package/extensions/chrome-browser/popup.html +7 -5
  11. package/extensions/chrome-browser/popup.js +16 -7
  12. package/flutter_app/lib/features/onboarding/onboarding_companion_step.dart +721 -0
  13. package/flutter_app/lib/features/onboarding/onboarding_shell.dart +6 -0
  14. package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +1 -1
  15. package/flutter_app/lib/main.dart +1 -0
  16. package/flutter_app/lib/main_controller.dart +156 -3
  17. package/flutter_app/lib/main_devices.dart +485 -119
  18. package/flutter_app/lib/main_settings.dart +289 -30
  19. package/flutter_app/lib/src/backend_client.dart +89 -0
  20. package/flutter_app/lib/src/desktop_companion_actions.dart +153 -3
  21. package/flutter_app/lib/src/desktop_companion_io.dart +145 -4
  22. package/flutter_app/lib/src/desktop_native_bridge.dart +13 -0
  23. package/flutter_app/lib/src/stream_renderer.dart +286 -0
  24. package/flutter_app/macos/Runner/AppDelegate.swift +56 -1
  25. package/package.json +2 -2
  26. package/server/guest_agent.js +19 -1
  27. package/server/http/routes.js +191 -0
  28. package/server/http/socket.js +1 -1
  29. package/server/index.js +4 -1
  30. package/server/public/.last_build_id +1 -1
  31. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  32. package/server/public/flutter_bootstrap.js +1 -1
  33. package/server/public/main.dart.js +75438 -74005
  34. package/server/routes/browser.js +14 -0
  35. package/server/routes/browser_extension.js +21 -4
  36. package/server/routes/desktop.js +10 -0
  37. package/server/routes/settings.js +4 -0
  38. package/server/routes/stream.js +187 -0
  39. package/server/services/ai/tools.js +40 -29
  40. package/server/services/android/controller.js +41 -2
  41. package/server/services/browser/controller.js +34 -0
  42. package/server/services/browser/extension/manifest.js +33 -0
  43. package/server/services/browser/extension/provider.js +12 -6
  44. package/server/services/browser/extension/registry.js +188 -18
  45. package/server/services/desktop/gateway.js +28 -3
  46. package/server/services/desktop/protocol.js +34 -0
  47. package/server/services/desktop/provider.js +25 -0
  48. package/server/services/desktop/registry.js +92 -10
  49. package/server/services/manager.js +19 -2
  50. package/server/services/runtime/backends/local-vm.js +6 -0
  51. package/server/services/runtime/docker-vm-manager.js +26 -3
  52. package/server/services/runtime/manager.js +36 -5
  53. package/server/services/runtime/settings.js +17 -0
  54. package/server/services/streaming/android-stream.js +298 -0
  55. package/server/services/streaming/browser-stream.js +87 -0
  56. package/server/services/streaming/stream-hub.js +231 -0
  57. package/server/services/websocket.js +73 -0
@@ -141,6 +141,20 @@ router.post('/click-point', async (req, res) => {
141
141
  }
142
142
  });
143
143
 
144
+ router.post('/mouse-move', async (req, res) => {
145
+ try {
146
+ const { x, y } = req.body || {};
147
+ if (!Number.isFinite(Number(x)) || !Number.isFinite(Number(y))) {
148
+ return res.status(400).json({ error: 'x and y required' });
149
+ }
150
+ const bc = await getBrowserController(req);
151
+ const result = await bc.hoverPoint(x, y, req.body || {});
152
+ res.json(result);
153
+ } catch (err) {
154
+ res.status(500).json({ error: sanitizeError(err) });
155
+ }
156
+ });
157
+
144
158
  // Fill form field
145
159
  router.post('/fill', async (req, res) => {
146
160
  try {
@@ -4,10 +4,10 @@ const rateLimit = require('express-rate-limit');
4
4
  const { requireAuth } = require('../middleware/auth');
5
5
  const { sanitizeError } = require('../utils/security');
6
6
  const { createZipFromDirectory } = require('../services/browser/extension/zip');
7
+ const { getExtensionManifest } = require('../services/browser/extension/manifest');
7
8
 
8
9
  const router = express.Router();
9
10
  const EXTENSION_DIR = path.join(__dirname, '..', '..', 'extensions', 'chrome-browser');
10
- const EXTENSION_MANIFEST = require('../../extensions/chrome-browser/manifest.json');
11
11
  const pairingLimiter = rateLimit({
12
12
  windowMs: 15 * 60 * 1000,
13
13
  max: 30,
@@ -55,10 +55,12 @@ router.post('/pairing/request', pairingLimiter, (req, res) => {
55
55
  });
56
56
 
57
57
  router.get('/latest', (req, res) => {
58
+ const manifest = getExtensionManifest();
58
59
  res.json({
59
- name: EXTENSION_MANIFEST.name,
60
- version: EXTENSION_MANIFEST.version,
61
- minimumChromeVersion: EXTENSION_MANIFEST.minimum_chrome_version,
60
+ name: manifest.name,
61
+ version: manifest.version,
62
+ versionName: manifest.version_name,
63
+ minimumChromeVersion: manifest.minimum_chrome_version,
62
64
  downloadUrl: `${baseUrlFor(req)}/api/browser-extension/download`,
63
65
  });
64
66
  });
@@ -123,6 +125,19 @@ router.get('/status', requireAuth, (req, res) => {
123
125
  }
124
126
  });
125
127
 
128
+ router.post('/select-token', requireAuth, (req, res) => {
129
+ try {
130
+ const registry = getRegistry(req);
131
+ const result = registry.setSelectedTokenId(req.session.userId, req.body?.tokenId);
132
+ res.json({
133
+ ...result,
134
+ status: registry.getStatus(req.session.userId),
135
+ });
136
+ } catch (err) {
137
+ res.status(err.status || 500).json({ error: sanitizeError(err) });
138
+ }
139
+ });
140
+
126
141
  router.post('/revoke', requireAuth, (req, res) => {
127
142
  try {
128
143
  res.json(getRegistry(req).revoke(req.session.userId, req.body?.tokenId || null));
@@ -133,8 +148,10 @@ router.post('/revoke', requireAuth, (req, res) => {
133
148
 
134
149
  router.get('/download', requireAuth, (req, res) => {
135
150
  try {
151
+ const manifest = getExtensionManifest();
136
152
  const zip = createZipFromDirectory(EXTENSION_DIR, {
137
153
  overrides: {
154
+ 'manifest.json': `${JSON.stringify(manifest, null, 2)}\n`,
138
155
  'config.mjs': extensionConfigFor(req),
139
156
  },
140
157
  });
@@ -163,6 +163,16 @@ router.post('/click', (req, res) =>
163
163
  },
164
164
  )));
165
165
 
166
+ router.post('/mouse-move', (req, res) =>
167
+ handleDesktopAction(req, res, (provider, request) => provider.mouseMove(
168
+ parseFiniteNumber(request.body?.x, 'x', { min: -100000, max: 100000 }),
169
+ parseFiniteNumber(request.body?.y, 'y', { min: -100000, max: 100000 }),
170
+ {
171
+ ...(request.body || {}),
172
+ deviceId: parseDeviceId(request.body?.deviceId),
173
+ },
174
+ )));
175
+
166
176
  router.post('/drag', (req, res) =>
167
177
  handleDesktopAction(req, res, (provider, request) => provider.drag({
168
178
  ...(request.body || {}),
@@ -297,7 +297,11 @@ router.put('/', async (req, res) => {
297
297
  'runtime_profile' in normalizedBody
298
298
  || 'runtime_backend' in normalizedBody
299
299
  || 'browser_backend' in normalizedBody
300
+ || 'browser_extension_token_id' in normalizedBody
301
+ || 'selected_browser_extension_token_id' in normalizedBody
300
302
  || 'android_backend' in normalizedBody
303
+ || 'cli_backend' in normalizedBody
304
+ || 'cli_desktop_device_id' in normalizedBody
301
305
  || 'mcp_backend' in normalizedBody
302
306
  ) {
303
307
  const validation = validateRuntimeSettings({
@@ -0,0 +1,187 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const { requireAuth } = require('../middleware/auth');
5
+ const { sanitizeError } = require('../utils/security');
6
+ const { AndroidStream } = require('../services/streaming/android-stream');
7
+ const { BrowserStream } = require('../services/streaming/browser-stream');
8
+
9
+ const router = express.Router();
10
+ router.use(requireAuth);
11
+
12
+ function boundedInt(value, fallback, min, max) {
13
+ const parsed = Number(value);
14
+ if (!Number.isFinite(parsed)) return fallback;
15
+ return Math.min(max, Math.max(min, Math.floor(parsed)));
16
+ }
17
+
18
+ function streamHub(req) {
19
+ const hub = req.app?.locals?.streamHub;
20
+ if (!hub) throw new Error('Stream hub is unavailable.');
21
+ return hub;
22
+ }
23
+
24
+ async function desktopProvider(req) {
25
+ const factory = req.app?.locals?.getDesktopProviderForUser;
26
+ if (typeof factory !== 'function') {
27
+ throw new Error('Desktop provider is unavailable.');
28
+ }
29
+ return factory(req.session?.userId);
30
+ }
31
+
32
+ async function androidController(req) {
33
+ const runtimeManager = req.app?.locals?.runtimeManager;
34
+ if (runtimeManager && typeof runtimeManager.getAndroidProviderForUser === 'function') {
35
+ return runtimeManager.getAndroidProviderForUser(req.session?.userId);
36
+ }
37
+ throw new Error('Android controller is unavailable.');
38
+ }
39
+
40
+ async function browserController(req) {
41
+ const runtimeManager = req.app?.locals?.runtimeManager;
42
+ if (runtimeManager && typeof runtimeManager.getBrowserProviderForUser === 'function') {
43
+ return runtimeManager.getBrowserProviderForUser(req.session?.userId);
44
+ }
45
+ throw new Error('Browser controller is unavailable.');
46
+ }
47
+
48
+ function normalizePlatform(value) {
49
+ const platform = String(value || '').trim().toLowerCase();
50
+ if (platform === 'desktop' || platform === 'android' || platform === 'browser') return platform;
51
+ const error = new Error('platform must be desktop, android, or browser.');
52
+ error.status = 400;
53
+ throw error;
54
+ }
55
+
56
+ function normalizeDeviceId(platform, value) {
57
+ const deviceId = String(value || '').trim();
58
+ if (deviceId) return deviceId;
59
+ if (platform === 'browser') return 'browser';
60
+ const error = new Error('deviceId is required.');
61
+ error.status = 400;
62
+ throw error;
63
+ }
64
+
65
+ async function resolveStartDeviceId(platform, req) {
66
+ const rawDeviceId = String(req.body?.deviceId || '').trim();
67
+ if (platform === 'browser') return { deviceId: 'browser', controller: null };
68
+ if (platform === 'android') {
69
+ const controller = await androidController(req);
70
+ const status = typeof controller.getStatus === 'function'
71
+ ? await controller.getStatus().catch(() => ({}))
72
+ : {};
73
+ const adbSerial = String(status?.adbSerial || controller.adbSerial || '').trim();
74
+ if (rawDeviceId && adbSerial && rawDeviceId !== adbSerial) {
75
+ const error = new Error('Android deviceId does not match the active emulator serial.');
76
+ error.status = 400;
77
+ throw error;
78
+ }
79
+ if (rawDeviceId) return { deviceId: rawDeviceId, controller };
80
+ if (!adbSerial) throw new Error('Android deviceId is required when no emulator serial is available.');
81
+ return { deviceId: adbSerial, controller };
82
+ }
83
+ if (rawDeviceId) return { deviceId: rawDeviceId, controller: null };
84
+ return { deviceId: normalizeDeviceId(platform, rawDeviceId), controller: null };
85
+ }
86
+
87
+ async function resolveStopDeviceId(platform, req) {
88
+ const rawDeviceId = String(req.body?.deviceId || '').trim();
89
+ if (rawDeviceId) return rawDeviceId;
90
+ if (platform === 'browser') return 'browser';
91
+ if (platform === 'android') {
92
+ const controller = await androidController(req);
93
+ const status = typeof controller.getStatus === 'function'
94
+ ? await controller.getStatus().catch(() => ({}))
95
+ : {};
96
+ const adbSerial = String(status?.adbSerial || controller.adbSerial || '').trim();
97
+ if (adbSerial) return adbSerial;
98
+ }
99
+ const error = new Error('deviceId is required.');
100
+ error.status = 400;
101
+ throw error;
102
+ }
103
+
104
+ function sendError(res, err) {
105
+ res.status(Number(err?.status || 500)).json({ error: sanitizeError(err) });
106
+ }
107
+
108
+ router.post('/start', async (req, res) => {
109
+ try {
110
+ const userId = req.session?.userId;
111
+ const platform = normalizePlatform(req.body?.platform);
112
+ const resolved = await resolveStartDeviceId(platform, req);
113
+ const deviceId = resolved.deviceId;
114
+ const fps = boundedInt(req.body?.fps, platform === 'android' ? 12 : 15, 1, platform === 'android' ? 30 : 20);
115
+ const quality = boundedInt(req.body?.quality, platform === 'android' ? 75 : 80, 30, 95);
116
+ const hub = streamHub(req);
117
+ await hub.stopStream(userId, platform, deviceId, 'restart');
118
+
119
+ if (platform === 'desktop') {
120
+ const provider = await desktopProvider(req);
121
+ const result = await provider.startStream({
122
+ deviceId,
123
+ fps,
124
+ quality,
125
+ displayId: req.body?.displayId || null,
126
+ });
127
+ const resolvedDeviceId = result?.deviceId || result?.device?.deviceId || deviceId;
128
+ hub.markStarted(userId, resolvedDeviceId, platform, { fps, quality }, async () => {
129
+ try {
130
+ await provider.stopStream({ deviceId: resolvedDeviceId });
131
+ } catch (error) {
132
+ console.error('[StreamRoute] failed to stop desktop stream', {
133
+ userId,
134
+ deviceId: resolvedDeviceId,
135
+ error: String(error?.message || error),
136
+ });
137
+ }
138
+ });
139
+ return res.json({ ok: true, platform, deviceId: resolvedDeviceId, fps, quality });
140
+ }
141
+
142
+ if (platform === 'android') {
143
+ const controller = resolved.controller || await androidController(req);
144
+ const stream = new AndroidStream({ userId, deviceId, controller, streamHub: hub, fps, quality });
145
+ stream.start();
146
+ hub.markStarted(userId, deviceId, platform, { fps, quality }, () => stream.stop());
147
+ return res.json({ ok: true, platform, deviceId, fps, quality });
148
+ }
149
+
150
+ const controller = await browserController(req);
151
+ const stream = new BrowserStream({ userId, deviceId, controller, streamHub: hub, fps, quality });
152
+ stream.start();
153
+ hub.markStarted(userId, deviceId, platform, { fps, quality }, () => stream.stop());
154
+ return res.json({ ok: true, platform, deviceId, fps, quality });
155
+ } catch (err) {
156
+ sendError(res, err);
157
+ }
158
+ });
159
+
160
+ router.post('/stop', async (req, res) => {
161
+ try {
162
+ const platform = normalizePlatform(req.body?.platform);
163
+ const deviceId = await resolveStopDeviceId(platform, req);
164
+ const stopped = await streamHub(req).stopStream(req.session?.userId, platform, deviceId, 'api_stop');
165
+ res.json({ ok: true, stopped, platform, deviceId });
166
+ } catch (err) {
167
+ sendError(res, err);
168
+ }
169
+ });
170
+
171
+ router.get('/status', async (req, res) => {
172
+ try {
173
+ const platform = req.query?.platform ? normalizePlatform(req.query.platform) : null;
174
+ const deviceId = req.query?.deviceId
175
+ ? normalizeDeviceId(platform || 'desktop', req.query.deviceId)
176
+ : null;
177
+ const hub = streamHub(req);
178
+ if (deviceId) {
179
+ return res.json(hub.status(req.session?.userId, platform || 'desktop', deviceId));
180
+ }
181
+ return res.json({ streams: hub.listStatus(req.session?.userId), platform });
182
+ } catch (err) {
183
+ sendError(res, err);
184
+ }
185
+ });
186
+
187
+ module.exports = router;
@@ -292,7 +292,7 @@ function getAvailableTools(app, options = {}) {
292
292
  const tools = [
293
293
  {
294
294
  name: 'execute_command',
295
- description: 'Execute a terminal/shell command as a normal recoverable agent step. Waits for the process to exit, supports PTY for interactive programs, and returns stdout, stderr, exit code, timeout state, and duration so later reasoning can inspect failures, install missing dependencies, and retry when needed.',
295
+ description: 'Execute a terminal/shell command as a normal recoverable agent step. Waits for the process to exit, supports PTY for interactive programs, and returns stdout, stderr, exit code, timeout state, duration, and a backend field ("vm" or "desktop-companion") indicating where the command ran. Commands run inside the isolated VM unless the cli_backend setting is set to "desktop" and a companion app is connected, in which case the command runs on the companion desktop machine.',
296
296
  parameters: {
297
297
  type: 'object',
298
298
  properties: {
@@ -308,7 +308,7 @@ function getAvailableTools(app, options = {}) {
308
308
  },
309
309
  {
310
310
  name: 'browser_navigate',
311
- description: 'Navigate the browser to a URL and return page content/screenshot',
311
+ description: 'Navigate the browser to a URL and return page content/screenshot. The result includes a backend field ("vm" or "extension") indicating whether the VM browser or the paired browser extension handled the request.',
312
312
  parameters: {
313
313
  type: 'object',
314
314
  properties: {
@@ -1401,10 +1401,13 @@ async function executeTool(toolName, args, context, engine) {
1401
1401
  allowMultipleProactiveMessages = false
1402
1402
  } = context;
1403
1403
  const runtime = () => app?.locals?.runtimeManager || engine.runtimeManager || null;
1404
- const bc = () => {
1404
+ const bc = async () => {
1405
1405
  const manager = runtime();
1406
1406
  if (manager && typeof manager.getBrowserProviderForUser === 'function') {
1407
- return manager.getBrowserProviderForUser(userId);
1407
+ const backend = typeof manager.getActiveBrowserBackend === 'function'
1408
+ ? await Promise.resolve(manager.getActiveBrowserBackend(userId))
1409
+ : 'vm';
1410
+ return { provider: await manager.getBrowserProviderForUser(userId), backend };
1408
1411
  }
1409
1412
  throw new Error('Browser provider is unavailable. VM runtime is required.');
1410
1413
  };
@@ -1478,59 +1481,67 @@ async function executeTool(toolName, args, context, engine) {
1478
1481
 
1479
1482
  case 'execute_command': {
1480
1483
  const runtimeManager = runtime();
1481
- if (!runtimeManager || typeof runtimeManager.executeCommand !== 'function') {
1482
- return { error: 'Command execution is unavailable. VM runtime is required.' };
1484
+ if (!runtimeManager) {
1485
+ return { error: 'Command execution is unavailable. No runtime manager found.' };
1483
1486
  }
1484
- return await runtimeManager.executeCommand(userId, args.command, {
1487
+ const execOptions = {
1485
1488
  cwd: args.cwd,
1486
1489
  timeout: args.timeout || (args.pty ? 20 * 60 * 1000 : 15 * 60 * 1000),
1487
1490
  stdinInput: args.stdin_input,
1488
1491
  pty: args.pty === true,
1489
- inputs: args.inputs || [],
1490
- });
1492
+ inputs: Array.isArray(args.inputs) ? args.inputs : [],
1493
+ };
1494
+ if (typeof runtimeManager.executeCliCommand === 'function') {
1495
+ return await runtimeManager.executeCliCommand(userId, args.command, execOptions);
1496
+ }
1497
+ // Legacy fallback — older runtime manager without CLI routing.
1498
+ if (typeof runtimeManager.executeCommand !== 'function') {
1499
+ return { error: 'Command execution is unavailable. VM runtime is required.' };
1500
+ }
1501
+ return { ...await runtimeManager.executeCommand(userId, args.command, execOptions), backend: 'vm' };
1491
1502
  }
1492
1503
 
1493
1504
  case 'browser_navigate': {
1494
- const controller = await bc();
1495
- if (!controller) return { error: 'Browser controller not available' };
1496
- return await controller.navigate(args.url, {
1505
+ const { provider, backend } = await bc();
1506
+ if (!provider) return { error: 'Browser controller not available' };
1507
+ return { ...await provider.navigate(args.url, {
1497
1508
  screenshot: args.screenshot !== false,
1498
1509
  waitFor: args.waitFor,
1499
1510
  fullPage: args.fullPage
1500
- });
1511
+ }), backend };
1501
1512
  }
1502
1513
 
1503
1514
  case 'browser_click': {
1504
- const controller = await bc();
1505
- if (!controller) return { error: 'Browser controller not available' };
1506
- return await controller.click(args.selector, args.text, args.screenshot !== false);
1515
+ const { provider, backend } = await bc();
1516
+ if (!provider) return { error: 'Browser controller not available' };
1517
+ return { ...await provider.click(args.selector, args.text, args.screenshot !== false), backend };
1507
1518
  }
1508
1519
 
1509
1520
  case 'browser_type': {
1510
- const controller = await bc();
1511
- if (!controller) return { error: 'Browser controller not available' };
1512
- return await controller.type(args.selector, args.text, {
1521
+ const { provider, backend } = await bc();
1522
+ if (!provider) return { error: 'Browser controller not available' };
1523
+ return { ...await provider.type(args.selector, args.text, {
1513
1524
  clear: args.clear !== false,
1514
1525
  pressEnter: args.pressEnter
1515
- });
1526
+ }), backend };
1516
1527
  }
1517
1528
 
1518
1529
  case 'browser_extract': {
1519
- const controller = await bc();
1520
- if (!controller) return { error: 'Browser controller not available' };
1521
- return await controller.extract(args.selector, args.attribute, args.all);
1530
+ const { provider, backend } = await bc();
1531
+ if (!provider) return { error: 'Browser controller not available' };
1532
+ return { ...await provider.extract(args.selector, args.attribute, args.all), backend };
1522
1533
  }
1523
1534
 
1524
1535
  case 'browser_screenshot': {
1525
- const controller = await bc();
1526
- if (!controller) return { error: 'Browser controller not available' };
1527
- return await controller.screenshot({ fullPage: args.fullPage, selector: args.selector });
1536
+ const { provider, backend } = await bc();
1537
+ if (!provider) return { error: 'Browser controller not available' };
1538
+ return { ...await provider.screenshot({ fullPage: args.fullPage, selector: args.selector }), backend };
1528
1539
  }
1529
1540
 
1530
1541
  case 'browser_evaluate': {
1531
- const controller = await bc();
1532
- if (!controller) return { error: 'Browser controller not available' };
1533
- return await controller.evaluate(args.script);
1542
+ const { provider, backend } = await bc();
1543
+ if (!provider) return { error: 'Browser controller not available' };
1544
+ return { ...await provider.evaluate(args.script), backend };
1534
1545
  }
1535
1546
 
1536
1547
  case 'android_start_emulator': {
@@ -347,12 +347,16 @@ class AndroidController {
347
347
  // ── Actions ───────────────────────────────────────────────────────────────
348
348
 
349
349
  async screenshot(_opts = {}) {
350
- const serial = this.#requireSerial();
351
- const r = this.#adbCapture(serial, ['exec-out', 'screencap', '-p']);
350
+ const r = await this.capturePng();
352
351
  if (!r?.length) throw new Error('screencap returned no data');
353
352
  return { screenshotPath: this.#saveArtifact(r) };
354
353
  }
355
354
 
355
+ async capturePng(_opts = {}) {
356
+ const serial = this.#requireSerial();
357
+ return this.#adbCaptureAsync(serial, ['exec-out', 'screencap', '-p']);
358
+ }
359
+
356
360
  async observe(_opts = {}) { return this.screenshot(); }
357
361
 
358
362
  async tap({ x, y } = {}) {
@@ -469,6 +473,41 @@ class AndroidController {
469
473
  return (r.status === 0 && r.stdout?.length) ? r.stdout : null;
470
474
  }
471
475
 
476
+ #adbCaptureAsync(serial, args) {
477
+ return new Promise((resolve, reject) => {
478
+ const proc = spawn(adbBin(this.sdkDir), ['-s', serial, ...args]);
479
+ const chunks = [];
480
+ const errors = [];
481
+ let settled = false;
482
+ const timer = setTimeout(() => {
483
+ if (settled) return;
484
+ settled = true;
485
+ try { proc.kill('SIGKILL'); } catch {}
486
+ reject(new Error('adb capture timed out'));
487
+ }, 15000);
488
+ timer.unref?.();
489
+ proc.stdout?.on('data', (chunk) => chunks.push(chunk));
490
+ proc.stderr?.on('data', (chunk) => errors.push(chunk));
491
+ proc.on('error', (error) => {
492
+ if (settled) return;
493
+ settled = true;
494
+ clearTimeout(timer);
495
+ reject(error);
496
+ });
497
+ proc.on('close', (code) => {
498
+ if (settled) return;
499
+ settled = true;
500
+ clearTimeout(timer);
501
+ if (code === 0 && chunks.length) {
502
+ resolve(Buffer.concat(chunks));
503
+ return;
504
+ }
505
+ const message = Buffer.concat(errors).toString('utf8').trim();
506
+ reject(new Error(message || `adb capture exit ${code}`));
507
+ });
508
+ });
509
+ }
510
+
472
511
  #saveArtifact(data) {
473
512
  if (!data || !this.artifactStore) return null;
474
513
  const alloc = this.artifactStore.allocateFile(this.userId, { kind: 'screenshot', extension: 'png', contentType: 'image/png' });
@@ -453,6 +453,22 @@ class BrowserController {
453
453
  };
454
454
  }
455
455
 
456
+ async screenshotJpeg(quality = 80, options = {}) {
457
+ const page = await this.ensurePage();
458
+ const screenshotOptions = {
459
+ type: 'jpeg',
460
+ quality: Math.min(95, Math.max(30, Math.floor(Number(quality) || 80))),
461
+ fullPage: options.fullPage === true,
462
+ };
463
+ if (options.selector) {
464
+ const element = await page.$(options.selector);
465
+ if (element) {
466
+ return element.screenshot(screenshotOptions);
467
+ }
468
+ }
469
+ return page.screenshot(screenshotOptions);
470
+ }
471
+
456
472
  async navigate(url, options = {}) {
457
473
  const page = await this.ensurePage();
458
474
 
@@ -584,6 +600,24 @@ class BrowserController {
584
600
  }
585
601
  }
586
602
 
603
+ async hoverPoint(x, y, options = {}) {
604
+ const page = await this.ensurePage();
605
+ try {
606
+ const px = Math.max(0, Math.round(Number(x) || 0));
607
+ const py = Math.max(0, Math.round(Number(y) || 0));
608
+ await page.mouse.move(px, py, { steps: options.steps || 1 });
609
+ return {
610
+ success: true,
611
+ x: px,
612
+ y: py,
613
+ url: page.url(),
614
+ title: await page.title()
615
+ };
616
+ } catch (err) {
617
+ return { error: err.message };
618
+ }
619
+ }
620
+
587
621
  async scroll(deltaX = 0, deltaY = 0, screenshot = true) {
588
622
  const page = await this.ensurePage();
589
623
 
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ const BASE_MANIFEST = require('../../../../extensions/chrome-browser/manifest.json');
4
+ const { getVersionInfo } = require('../../../utils/version');
5
+
6
+ function chromeVersionFor(packageVersion) {
7
+ const parts = String(packageVersion || '')
8
+ .match(/\d+/g)
9
+ ?.slice(0, 4)
10
+ .map((part) => Math.min(Number(part) || 0, 65535)) || [];
11
+
12
+ while (parts.length < 3) {
13
+ parts.push(0);
14
+ }
15
+
16
+ return parts.join('.');
17
+ }
18
+
19
+ function getExtensionManifest() {
20
+ const { packageVersion } = getVersionInfo();
21
+ const displayVersion = packageVersion || BASE_MANIFEST.version_name || BASE_MANIFEST.version;
22
+
23
+ return {
24
+ ...BASE_MANIFEST,
25
+ version: chromeVersionFor(displayVersion),
26
+ version_name: displayVersion,
27
+ };
28
+ }
29
+
30
+ module.exports = {
31
+ chromeVersionFor,
32
+ getExtensionManifest,
33
+ };
@@ -17,6 +17,7 @@ class ExtensionBrowserProvider {
17
17
  constructor(options = {}) {
18
18
  this.registry = options.registry;
19
19
  this.userId = options.userId != null ? String(options.userId) : null;
20
+ this.tokenId = options.tokenId ? String(options.tokenId) : null;
20
21
  this.artifactStore = options.artifactStore || null;
21
22
  this.headless = false;
22
23
  }
@@ -29,13 +30,16 @@ class ExtensionBrowserProvider {
29
30
 
30
31
  async #dispatch(command, payload = {}, options = {}) {
31
32
  this.#assertReady();
32
- const result = await this.registry.dispatch(this.userId, command, payload, options);
33
+ const result = await this.registry.dispatch(this.userId, command, payload, {
34
+ ...options,
35
+ tokenId: options.tokenId || this.tokenId,
36
+ });
33
37
  return this.#materialize(result);
34
38
  }
35
39
 
36
40
  #disconnect() {
37
41
  if (!this.registry || this.userId == null) return;
38
- const connection = this.registry.getConnection(this.userId);
42
+ const connection = this.registry.getConnection(this.userId, this.tokenId);
39
43
  if (connection) {
40
44
  connection.close('browser extension provider closed');
41
45
  }
@@ -131,7 +135,7 @@ class ExtensionBrowserProvider {
131
135
  }
132
136
 
133
137
  async closeBrowser() {
134
- if (!this.registry || this.userId == null || !this.registry.isConnected(this.userId)) {
138
+ if (!this.registry || this.userId == null || !this.registry.isConnected(this.userId, this.tokenId)) {
135
139
  return { success: true, extensionConnected: false };
136
140
  }
137
141
  const result = await this.#dispatch(EXTENSION_COMMANDS.CLOSE, {});
@@ -152,14 +156,16 @@ class ExtensionBrowserProvider {
152
156
  }
153
157
 
154
158
  async getPageInfo() {
155
- if (!this.registry || this.userId == null || !this.registry.isConnected(this.userId)) {
159
+ if (!this.registry || this.userId == null || !this.registry.isConnected(this.userId, this.tokenId)) {
156
160
  return { url: null, title: null, extensionConnected: false };
157
161
  }
158
- return this.registry.dispatch(this.userId, EXTENSION_COMMANDS.GET_PAGE_INFO, {});
162
+ return this.registry.dispatch(this.userId, EXTENSION_COMMANDS.GET_PAGE_INFO, {}, {
163
+ tokenId: this.tokenId,
164
+ });
159
165
  }
160
166
 
161
167
  isLaunched() {
162
- return Boolean(this.registry && this.userId != null && this.registry.isConnected(this.userId));
168
+ return Boolean(this.registry && this.userId != null && this.registry.isConnected(this.userId, this.tokenId));
163
169
  }
164
170
 
165
171
  getPageCount() {