neoagent 2.4.0 → 2.4.1-beta.10

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 +391 -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 +144 -0
  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 +73834 -72596
  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
@@ -25,6 +25,18 @@ class DesktopCompanionManager extends ChangeNotifier {
25
25
  final DesktopCompanionActions _actions;
26
26
  WebSocket? _socket;
27
27
  Timer? _reconnectTimer;
28
+ Timer? _streamTimer;
29
+ bool _streamCaptureInFlight = false;
30
+ // Set true while a click / drag / scroll / typeText / pressKey command is
31
+ // being executed. _captureAndSendBinaryFrame respects this flag so it does
32
+ // not compete with the input command for the native bridge or the WebSocket
33
+ // send buffer, and a fresh frame is forced immediately after the action.
34
+ bool _inputCommandInFlight = false;
35
+ int _frameSeq = 0;
36
+ int _streamGeneration = 0;
37
+ // Tracks the current stream quality so the forced post-input capture can use
38
+ // the same setting without re-parsing the original startStream payload.
39
+ int _currentStreamQuality = 80;
28
40
 
29
41
  String _backendUrl = '';
30
42
  String _sessionCookie = '';
@@ -52,7 +64,8 @@ class DesktopCompanionManager extends ChangeNotifier {
52
64
 
53
65
  Future<void> bootstrap(SharedPreferences prefs) async {
54
66
  _enabled = prefs.getBool(desktopCompanionEnabledPrefsKey) ?? false;
55
- _paused = prefs.getBool(desktopCompanionPausedPrefsKey) ?? false;
67
+ // Always start unpaused — paused state must not carry over across restarts.
68
+ _paused = false;
56
69
  _label =
57
70
  prefs.getString(desktopCompanionLabelPrefsKey)?.trim() ??
58
71
  _defaultLabel();
@@ -116,7 +129,6 @@ class DesktopCompanionManager extends ChangeNotifier {
116
129
 
117
130
  Future<void> setPaused(bool value, SharedPreferences prefs) async {
118
131
  _paused = value;
119
- await prefs.setBool(desktopCompanionPausedPrefsKey, value);
120
132
  notifyListeners();
121
133
  if (_connected) {
122
134
  await _sendEvent('statusChanged', <String, Object?>{'paused': value});
@@ -126,6 +138,7 @@ class DesktopCompanionManager extends ChangeNotifier {
126
138
  Future<void> disconnect() async {
127
139
  _reconnectTimer?.cancel();
128
140
  _reconnectTimer = null;
141
+ _stopStreaming();
129
142
  _connecting = false;
130
143
  _connected = false;
131
144
  final socket = _socket;
@@ -281,6 +294,19 @@ class DesktopCompanionManager extends ChangeNotifier {
281
294
  }
282
295
  }
283
296
 
297
+ // Commands that interact with the remote machine's input system. While one
298
+ // of these is executing we pause frame captures so the WebSocket send buffer
299
+ // is clear for the result message, and to avoid the native bridge being busy
300
+ // with a screenshot when the click/drag/etc. needs to run.
301
+ static const _inputCommands = <String>{
302
+ 'click',
303
+ 'mouseMove',
304
+ 'drag',
305
+ 'scroll',
306
+ 'typeText',
307
+ 'pressKey',
308
+ };
309
+
284
310
  Future<void> _handleCommand(Map<String, Object?> message) async {
285
311
  final id = message['id']?.toString() ?? '';
286
312
  final command = message['command']?.toString() ?? '';
@@ -289,6 +315,10 @@ class DesktopCompanionManager extends ChangeNotifier {
289
315
  (key, value) => MapEntry(key.toString(), value),
290
316
  )
291
317
  : const <String, Object?>{};
318
+
319
+ final isInput = _inputCommands.contains(command);
320
+ if (isInput) _inputCommandInFlight = true;
321
+
292
322
  try {
293
323
  final response = await _dispatchCommand(command, payload);
294
324
  _socket?.add(
@@ -299,6 +329,18 @@ class DesktopCompanionManager extends ChangeNotifier {
299
329
  'payload': response,
300
330
  }),
301
331
  );
332
+ // Immediately capture a fresh frame after an input action so the user
333
+ // sees the result of their interaction without waiting for the next
334
+ // timer tick.
335
+ if (isInput && _streamTimer != null && _connected) {
336
+ unawaited(
337
+ _captureAndSendBinaryFrame(
338
+ _currentStreamQuality,
339
+ _streamGeneration,
340
+ forced: true,
341
+ ),
342
+ );
343
+ }
302
344
  } catch (error) {
303
345
  _socket?.add(
304
346
  jsonEncode(<String, Object?>{
@@ -308,6 +350,8 @@ class DesktopCompanionManager extends ChangeNotifier {
308
350
  'error': '$error',
309
351
  }),
310
352
  );
353
+ } finally {
354
+ if (isInput) _inputCommandInFlight = false;
311
355
  }
312
356
  }
313
357
 
@@ -327,6 +371,10 @@ class DesktopCompanionManager extends ChangeNotifier {
327
371
  );
328
372
  case 'captureFrame':
329
373
  return _actions.captureFrame(activeDisplayId: _activeDisplayId);
374
+ case 'startStream':
375
+ return _startStreaming(payload);
376
+ case 'stopStream':
377
+ return _stopStreaming();
330
378
  case 'observe':
331
379
  return _actions.observe(
332
380
  includeTree: payload['includeTree'] == true,
@@ -339,6 +387,12 @@ class DesktopCompanionManager extends ChangeNotifier {
339
387
  button: payload['button']?.toString() ?? 'left',
340
388
  displayId: _activeDisplayId,
341
389
  );
390
+ case 'mouseMove':
391
+ return _actions.mouseMove(
392
+ x: (payload['x'] as num?)?.round() ?? 0,
393
+ y: (payload['y'] as num?)?.round() ?? 0,
394
+ displayId: _activeDisplayId,
395
+ );
342
396
  case 'drag':
343
397
  return _actions.drag(
344
398
  x1: (payload['x1'] as num?)?.round() ?? 0,
@@ -387,10 +441,15 @@ class DesktopCompanionManager extends ChangeNotifier {
387
441
  case 'pauseControl':
388
442
  final paused = payload['paused'] != false;
389
443
  _paused = paused;
390
- final prefs = await SharedPreferences.getInstance();
391
- await prefs.setBool(desktopCompanionPausedPrefsKey, paused);
392
444
  notifyListeners();
393
445
  return <String, Object?>{'success': true, 'paused': _paused};
446
+ case 'executeCommand':
447
+ return _actions.executeShellCommand(
448
+ command: payload['command']?.toString() ?? '',
449
+ cwd: payload['cwd']?.toString(),
450
+ timeoutMs: (payload['timeout'] as num?)?.toInt(),
451
+ stdinInput: payload['stdin_input']?.toString(),
452
+ );
394
453
  case 'ping':
395
454
  return <String, Object?>{'pong': true};
396
455
  default:
@@ -399,6 +458,7 @@ class DesktopCompanionManager extends ChangeNotifier {
399
458
  }
400
459
 
401
460
  void _handleSocketClosed() {
461
+ _stopStreaming();
402
462
  _socket = null;
403
463
  _connecting = false;
404
464
  _connected = false;
@@ -410,6 +470,7 @@ class DesktopCompanionManager extends ChangeNotifier {
410
470
  void dispose() {
411
471
  _reconnectTimer?.cancel();
412
472
  _reconnectTimer = null;
473
+ _stopStreaming();
413
474
  _connecting = false;
414
475
  _connected = false;
415
476
  _enabled = false;
@@ -443,6 +504,86 @@ class DesktopCompanionManager extends ChangeNotifier {
443
504
  );
444
505
  }
445
506
 
507
+ Future<Map<String, Object?>> _startStreaming(
508
+ Map<String, Object?> payload,
509
+ ) async {
510
+ _streamTimer?.cancel();
511
+ final generation = ++_streamGeneration;
512
+ final fps = ((payload['fps'] as num?)?.round() ?? 15).clamp(1, 20);
513
+ final quality = ((payload['quality'] as num?)?.round() ?? 80).clamp(30, 95);
514
+ final displayId = payload['displayId']?.toString().trim();
515
+ if (displayId != null && displayId.isNotEmpty) {
516
+ _activeDisplayId = displayId;
517
+ }
518
+ final interval = Duration(milliseconds: max(1, (1000 / fps).floor()));
519
+ _frameSeq = 0;
520
+ _currentStreamQuality = quality;
521
+ _streamTimer = Timer.periodic(interval, (_) {
522
+ unawaited(_captureAndSendBinaryFrame(quality, generation));
523
+ });
524
+ unawaited(_captureAndSendBinaryFrame(quality, generation));
525
+ return <String, Object?>{
526
+ 'success': true,
527
+ 'fps': fps,
528
+ 'quality': quality,
529
+ 'displayId': _activeDisplayId,
530
+ };
531
+ }
532
+
533
+ Map<String, Object?> _stopStreaming() {
534
+ _streamTimer?.cancel();
535
+ _streamTimer = null;
536
+ _streamGeneration++;
537
+ _streamCaptureInFlight = false;
538
+ return <String, Object?>{'success': true};
539
+ }
540
+
541
+ Future<void> _captureAndSendBinaryFrame(
542
+ int quality,
543
+ int generation, {
544
+ bool forced = false,
545
+ }) async {
546
+ final socket = _socket;
547
+ if (socket == null ||
548
+ !_connected ||
549
+ _streamCaptureInFlight ||
550
+ generation != _streamGeneration) {
551
+ return;
552
+ }
553
+ // If an input command is actively running, skip this frame unless we were
554
+ // explicitly forced (i.e. this IS the post-input refresh capture).
555
+ if (!forced && _inputCommandInFlight) return;
556
+ _streamCaptureInFlight = true;
557
+ try {
558
+ final snapshot = await _actions.captureSnapshot(
559
+ activeDisplayId: _activeDisplayId,
560
+ );
561
+ if (snapshot == null) return;
562
+ final jpeg = await _actions.compressToJpeg(snapshot, quality);
563
+ if (jpeg.isEmpty) return;
564
+ if (!_connected || generation != _streamGeneration || _socket != socket) {
565
+ return;
566
+ }
567
+ final frame = Uint8List(10 + jpeg.length);
568
+ final header = ByteData.sublistView(frame, 0, 10);
569
+ header.setUint8(0, 0x01);
570
+ header.setUint32(1, _frameSeq++ & 0xffffffff, Endian.big);
571
+ header.setUint32(
572
+ 5,
573
+ DateTime.now().millisecondsSinceEpoch & 0xffffffff,
574
+ Endian.big,
575
+ );
576
+ header.setUint8(9, 0x01);
577
+ frame.setRange(10, frame.length, jpeg);
578
+ socket.add(frame);
579
+ } catch (error) {
580
+ _errorMessage = 'Desktop stream capture failed: $error';
581
+ notifyListeners();
582
+ } finally {
583
+ _streamCaptureInFlight = false;
584
+ }
585
+ }
586
+
446
587
  Future<void> _openMacPermissionSettings(String key) async {
447
588
  final uri = switch (key) {
448
589
  'screencapture' =>
@@ -44,6 +44,19 @@ class DesktopNativeBridge {
44
44
  });
45
45
  }
46
46
 
47
+ Future<void> mouseMove({
48
+ required int x,
49
+ required int y,
50
+ String? displayId,
51
+ }) {
52
+ return _channel.invokeMethod<void>('mouseMove', <String, Object?>{
53
+ 'x': x,
54
+ 'y': y,
55
+ if (displayId != null && displayId.trim().isNotEmpty)
56
+ 'displayId': displayId.trim(),
57
+ });
58
+ }
59
+
47
60
  Future<void> drag({
48
61
  required int x1,
49
62
  required int y1,
@@ -0,0 +1,286 @@
1
+ import 'dart:async';
2
+ import 'dart:typed_data';
3
+
4
+ import 'package:flutter/material.dart';
5
+ import 'package:socket_io_client/socket_io_client.dart' as io;
6
+
7
+ class StreamRenderer extends StatefulWidget {
8
+ const StreamRenderer({
9
+ super.key,
10
+ required this.socket,
11
+ required this.deviceId,
12
+ required this.platform,
13
+ this.remoteResolution,
14
+ this.onTap,
15
+ this.onSwipe,
16
+ this.onType,
17
+ this.onHover,
18
+ this.fit = BoxFit.contain,
19
+ this.alignment = Alignment.center,
20
+ });
21
+
22
+ final io.Socket socket;
23
+ final String deviceId;
24
+ final String platform;
25
+ final Size? remoteResolution;
26
+ final void Function(double x, double y)? onTap;
27
+ final void Function(double x1, double y1, double x2, double y2)? onSwipe;
28
+ final void Function(String text)? onType;
29
+ final void Function(double x, double y)? onHover;
30
+ final BoxFit fit;
31
+ final Alignment alignment;
32
+
33
+ @override
34
+ State<StreamRenderer> createState() => _StreamRendererState();
35
+ }
36
+
37
+ class _StreamRendererState extends State<StreamRenderer> {
38
+ Uint8List? _frame;
39
+ Size? _frameSize;
40
+ ImageStream? _imageStream;
41
+ ImageStreamListener? _imageListener;
42
+ Offset? _dragStart;
43
+ Offset? _dragEnd;
44
+
45
+ Timer? _hoverThrottleTimer;
46
+ Offset? _pendingHoverOffset;
47
+ DateTime _lastHoverTime = DateTime.fromMillisecondsSinceEpoch(0);
48
+
49
+ @override
50
+ void initState() {
51
+ super.initState();
52
+ widget.socket.on('stream:frame', _onFrame);
53
+ widget.socket.emit('stream:subscribe', <String, Object?>{
54
+ 'deviceId': widget.deviceId,
55
+ 'platform': widget.platform,
56
+ });
57
+ }
58
+
59
+ @override
60
+ void didUpdateWidget(StreamRenderer oldWidget) {
61
+ super.didUpdateWidget(oldWidget);
62
+ if (oldWidget.socket == widget.socket &&
63
+ oldWidget.deviceId == widget.deviceId &&
64
+ oldWidget.platform == widget.platform) {
65
+ return;
66
+ }
67
+ _frameSize = null;
68
+ _detachImageListener();
69
+ oldWidget.socket.off('stream:frame', _onFrame);
70
+ oldWidget.socket.emit('stream:unsubscribe', <String, Object?>{
71
+ 'deviceId': oldWidget.deviceId,
72
+ 'platform': oldWidget.platform,
73
+ });
74
+ widget.socket.on('stream:frame', _onFrame);
75
+ widget.socket.emit('stream:subscribe', <String, Object?>{
76
+ 'deviceId': widget.deviceId,
77
+ 'platform': widget.platform,
78
+ });
79
+ }
80
+
81
+ void _onFrame(dynamic data) {
82
+ Object? meta;
83
+ Object? bytes;
84
+ if (data is List && data.length >= 2) {
85
+ meta = data[0];
86
+ bytes = data[1];
87
+ }
88
+ if (meta is Map) {
89
+ final frameDeviceId = meta['deviceId']?.toString() ?? '';
90
+ final framePlatform = meta['platform']?.toString() ?? '';
91
+ if (frameDeviceId.isNotEmpty && frameDeviceId != widget.deviceId) {
92
+ return;
93
+ }
94
+ if (framePlatform.isNotEmpty && framePlatform != widget.platform) {
95
+ return;
96
+ }
97
+ }
98
+ final frame = switch (bytes) {
99
+ Uint8List value => value,
100
+ List<int> value => Uint8List.fromList(value),
101
+ ByteBuffer value => Uint8List.view(value),
102
+ _ => null,
103
+ };
104
+ if (frame == null || frame.isEmpty || !mounted) return;
105
+ if (_frameSize == null) {
106
+ _resolveFrameSize(frame);
107
+ }
108
+ setState(() => _frame = frame);
109
+ }
110
+
111
+ void _resolveFrameSize(Uint8List frame) {
112
+ _detachImageListener();
113
+ final provider = MemoryImage(frame);
114
+ final stream = provider.resolve(const ImageConfiguration());
115
+ final listener = ImageStreamListener((image, _) {
116
+ if (!mounted || _frameSize != null) return;
117
+ setState(() {
118
+ _frameSize = Size(
119
+ image.image.width.toDouble(),
120
+ image.image.height.toDouble(),
121
+ );
122
+ });
123
+ _detachImageListener();
124
+ });
125
+ _imageStream = stream;
126
+ _imageListener = listener;
127
+ stream.addListener(listener);
128
+ }
129
+
130
+ void _detachImageListener() {
131
+ if (_imageStream != null && _imageListener != null) {
132
+ _imageStream!.removeListener(_imageListener!);
133
+ }
134
+ _imageStream = null;
135
+ _imageListener = null;
136
+ }
137
+
138
+ @override
139
+ Widget build(BuildContext context) {
140
+ final frame = _frame;
141
+ if (frame == null) {
142
+ return const Center(child: CircularProgressIndicator());
143
+ }
144
+ return LayoutBuilder(
145
+ builder: (context, constraints) {
146
+ return MouseRegion(
147
+ onHover: widget.onHover == null
148
+ ? null
149
+ : (event) => _handleHoverEvent(event.localPosition, constraints.biggest),
150
+ child: GestureDetector(
151
+ behavior: HitTestBehavior.opaque,
152
+ onTapDown: widget.onTap == null
153
+ ? null
154
+ : (details) => _handleTap(details, constraints.biggest),
155
+ onPanStart: widget.onSwipe == null
156
+ ? null
157
+ : (details) {
158
+ _dragStart = details.localPosition;
159
+ _dragEnd = details.localPosition;
160
+ },
161
+ onPanUpdate: widget.onSwipe == null
162
+ ? null
163
+ : (details) {
164
+ _dragEnd = details.localPosition;
165
+ },
166
+ onPanEnd: widget.onSwipe == null
167
+ ? null
168
+ : (_) {
169
+ final start = _dragStart;
170
+ final end = _dragEnd;
171
+ _dragStart = null;
172
+ _dragEnd = null;
173
+ if (start == null ||
174
+ end == null ||
175
+ (start - end).distance < 12) {
176
+ return;
177
+ }
178
+ _handleSwipe(start, end, constraints.biggest);
179
+ },
180
+ child: Image.memory(
181
+ frame,
182
+ gaplessPlayback: true,
183
+ fit: widget.fit,
184
+ alignment: widget.alignment,
185
+ width: constraints.maxWidth,
186
+ height: constraints.maxHeight,
187
+ ),
188
+ ),
189
+ );
190
+ },
191
+ );
192
+ }
193
+
194
+ void _handleHoverEvent(Offset localPosition, Size boxSize) {
195
+ if (widget.onHover == null) return;
196
+ _pendingHoverOffset = localPosition;
197
+ final now = DateTime.now();
198
+ final elapsed = now.difference(_lastHoverTime);
199
+ const throttleDuration = Duration(milliseconds: 70);
200
+
201
+ if (elapsed >= throttleDuration) {
202
+ _sendPendingHover(boxSize);
203
+ } else {
204
+ _hoverThrottleTimer ??= Timer(throttleDuration - elapsed, () {
205
+ _hoverThrottleTimer = null;
206
+ _sendPendingHover(boxSize);
207
+ });
208
+ }
209
+ }
210
+
211
+ void _sendPendingHover(Size boxSize) {
212
+ final offset = _pendingHoverOffset;
213
+ if (offset == null) return;
214
+ _pendingHoverOffset = null;
215
+ _lastHoverTime = DateTime.now();
216
+
217
+ final point = _mapToRemote(offset, boxSize);
218
+ if (point != null) {
219
+ widget.onHover?.call(point.dx, point.dy);
220
+ }
221
+ }
222
+
223
+ void _handleTap(TapDownDetails details, Size boxSize) {
224
+ final point = _mapToRemote(details.localPosition, boxSize);
225
+ if (point == null) {
226
+ return;
227
+ }
228
+ widget.onTap?.call(point.dx, point.dy);
229
+ }
230
+
231
+ void _handleSwipe(Offset start, Offset end, Size boxSize) {
232
+ final mappedStart = _mapToRemote(start, boxSize);
233
+ final mappedEnd = _mapToRemote(end, boxSize);
234
+ if (mappedStart == null || mappedEnd == null) {
235
+ return;
236
+ }
237
+ widget.onSwipe?.call(
238
+ mappedStart.dx,
239
+ mappedStart.dy,
240
+ mappedEnd.dx,
241
+ mappedEnd.dy,
242
+ );
243
+ }
244
+
245
+ Offset? _mapToRemote(Offset localPosition, Size boxSize) {
246
+ final remote = widget.remoteResolution ?? _frameSize;
247
+ if (remote == null ||
248
+ remote.width <= 0 ||
249
+ remote.height <= 0 ||
250
+ boxSize.width <= 0 ||
251
+ boxSize.height <= 0) {
252
+ return null;
253
+ }
254
+ final remoteSize = Size(remote.width, remote.height);
255
+ final fitted = applyBoxFit(widget.fit, remoteSize, boxSize);
256
+ final sourceRect = widget.alignment.inscribe(
257
+ fitted.source,
258
+ Offset.zero & remoteSize,
259
+ );
260
+ final destRect = widget.alignment.inscribe(
261
+ fitted.destination,
262
+ Offset.zero & boxSize,
263
+ );
264
+ if (!destRect.contains(localPosition)) {
265
+ return null;
266
+ }
267
+ final localX = localPosition.dx - destRect.left;
268
+ final localY = localPosition.dy - destRect.top;
269
+ return Offset(
270
+ sourceRect.left + localX * fitted.source.width / destRect.width,
271
+ sourceRect.top + localY * fitted.source.height / destRect.height,
272
+ );
273
+ }
274
+
275
+ @override
276
+ void dispose() {
277
+ _hoverThrottleTimer?.cancel();
278
+ widget.socket.emit('stream:unsubscribe', <String, Object?>{
279
+ 'deviceId': widget.deviceId,
280
+ 'platform': widget.platform,
281
+ });
282
+ widget.socket.off('stream:frame', _onFrame);
283
+ _detachImageListener();
284
+ super.dispose();
285
+ }
286
+ }
@@ -106,6 +106,36 @@ final class DesktopCompanionNativePlugin: NSObject {
106
106
  displayId: displayId
107
107
  )
108
108
  result(nil)
109
+ case "mouseMove":
110
+ guard isAccessibilityTrusted() else {
111
+ result(
112
+ FlutterError(
113
+ code: "accessibility_permission_denied",
114
+ message: "Accessibility permission is required for input control.",
115
+ details: nil
116
+ )
117
+ )
118
+ return
119
+ }
120
+ guard let arguments = call.arguments as? [String: Any],
121
+ let x = arguments["x"] as? NSNumber,
122
+ let y = arguments["y"] as? NSNumber else {
123
+ result(
124
+ FlutterError(
125
+ code: "invalid_arguments",
126
+ message: "Missing mouseMove coordinates.",
127
+ details: nil
128
+ )
129
+ )
130
+ return
131
+ }
132
+ let displayId = arguments["displayId"] as? String
133
+ performMouseMove(
134
+ x: x.doubleValue,
135
+ y: y.doubleValue,
136
+ displayId: displayId
137
+ )
138
+ result(nil)
109
139
  case "drag":
110
140
  guard isAccessibilityTrusted() else {
111
141
  result(
@@ -316,7 +346,18 @@ final class DesktopCompanionNativePlugin: NSObject {
316
346
  }
317
347
 
318
348
  private func isAccessibilityTrusted() -> Bool {
319
- AXIsProcessTrusted()
349
+ if AXIsProcessTrusted() { return true }
350
+ // AXIsProcessTrusted() may cache false on macOS 14+ after a System Settings grant.
351
+ // Probe with a live AX read: .apiDisabled is the error returned when the process
352
+ // lacks accessibility permission (AXError has no .notTrusted case in the macOS SDK).
353
+ let sysElement = AXUIElementCreateSystemWide()
354
+ var value: CFTypeRef?
355
+ let status = AXUIElementCopyAttributeValue(
356
+ sysElement,
357
+ kAXFocusedApplicationAttribute as CFString,
358
+ &value
359
+ )
360
+ return status != .apiDisabled
320
361
  }
321
362
 
322
363
  private func resolveDisplayId(_ raw: String?) -> CGDirectDisplayID {
@@ -406,6 +447,20 @@ final class DesktopCompanionNativePlugin: NSObject {
406
447
  up.post(tap: .cghidEventTap)
407
448
  }
408
449
 
450
+ private func performMouseMove(x: Double, y: Double, displayId: String?) {
451
+ let point = nativePointForCapturedPixel(x: x, y: y, displayId: displayId)
452
+ CGWarpMouseCursorPosition(point)
453
+ guard let moveEvent = CGEvent(
454
+ mouseEventSource: nil,
455
+ mouseType: .mouseMoved,
456
+ mouseCursorPosition: point,
457
+ mouseButton: .left
458
+ ) else {
459
+ return
460
+ }
461
+ moveEvent.post(tap: .cghidEventTap)
462
+ }
463
+
409
464
  private func performDrag(
410
465
  x1: Double,
411
466
  y1: Double,
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "2.4.0",
3
+ "version": "2.4.1-beta.10",
4
4
  "description": "Proactive personal AI agent with no limits",
5
- "license": "MIT",
5
+ "license": "AGPL-3.0-only",
6
6
  "main": "server/index.js",
7
7
  "engines": {
8
8
  "node": ">=20"
@@ -62,7 +62,18 @@ function isInsideAllowedRoots(targetPath) {
62
62
  }
63
63
 
64
64
  function requireToken(req, res, next) {
65
- next();
65
+ if (!AUTH_TOKEN) {
66
+ // Token not configured in this environment — allow but unauthenticated.
67
+ // Pass NEOAGENT_VM_GUEST_TOKEN to the container to enforce auth.
68
+ return next();
69
+ }
70
+ const header = String(req.headers?.authorization || '').trim();
71
+ const prefix = 'Bearer ';
72
+ const provided = header.startsWith(prefix) ? header.slice(prefix.length).trim() : '';
73
+ if (!provided || provided !== AUTH_TOKEN) {
74
+ return res.status(401).json({ error: 'Unauthorized.' });
75
+ }
76
+ return next();
66
77
  }
67
78
 
68
79
  function sanitizeError(err) {
@@ -184,6 +195,13 @@ function requireCapability(controller, name) {
184
195
  app.post('/browser/launch', async (req, res) => handle(res, () => requireCapability(browserController, 'browser').launch(req.body || {})));
185
196
  app.post('/browser/navigate', async (req, res) => handle(res, () => requireCapability(browserController, 'browser').navigate(req.body?.url, req.body || {})));
186
197
  app.post('/browser/screenshot', async (req, res) => handle(res, () => requireCapability(browserController, 'browser').screenshot(req.body || {})));
198
+ app.post('/browser/screenshot-jpeg', async (req, res) => handle(res, async () => {
199
+ const jpeg = await requireCapability(browserController, 'browser').screenshotJpeg(req.body?.quality, req.body || {});
200
+ return {
201
+ contentType: 'image/jpeg',
202
+ contentBase64: Buffer.from(jpeg).toString('base64'),
203
+ };
204
+ }));
187
205
  app.post('/browser/click', async (req, res) => handle(res, () => requireCapability(browserController, 'browser').click(req.body?.selector, req.body?.text, req.body?.screenshot !== false)));
188
206
  app.post('/browser/click-point', async (req, res) => handle(res, () => requireCapability(browserController, 'browser').clickPoint(req.body?.x, req.body?.y, req.body?.screenshot !== false)));
189
207
  app.post('/browser/fill', async (req, res) => handle(res, () => requireCapability(browserController, 'browser').type(req.body?.selector, String(req.body?.value ?? req.body?.text ?? ''), req.body || {})));