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.
- package/LICENSE +619 -21
- package/README.md +1 -1
- package/extensions/chrome-browser/background.mjs +19 -7
- package/extensions/chrome-browser/icons/icon128.png +0 -0
- package/extensions/chrome-browser/icons/icon16.png +0 -0
- package/extensions/chrome-browser/icons/icon48.png +0 -0
- package/extensions/chrome-browser/icons/logo.svg +12 -0
- package/extensions/chrome-browser/manifest.json +13 -2
- package/extensions/chrome-browser/popup.css +5 -0
- package/extensions/chrome-browser/popup.html +7 -5
- package/extensions/chrome-browser/popup.js +16 -7
- package/flutter_app/lib/features/onboarding/onboarding_companion_step.dart +391 -0
- package/flutter_app/lib/features/onboarding/onboarding_shell.dart +6 -0
- package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +1 -1
- package/flutter_app/lib/main.dart +1 -0
- package/flutter_app/lib/main_controller.dart +156 -3
- package/flutter_app/lib/main_devices.dart +485 -119
- package/flutter_app/lib/main_settings.dart +289 -30
- package/flutter_app/lib/src/backend_client.dart +89 -0
- package/flutter_app/lib/src/desktop_companion_actions.dart +144 -0
- package/flutter_app/lib/src/desktop_companion_io.dart +145 -4
- package/flutter_app/lib/src/desktop_native_bridge.dart +13 -0
- package/flutter_app/lib/src/stream_renderer.dart +286 -0
- package/flutter_app/macos/Runner/AppDelegate.swift +56 -1
- package/package.json +2 -2
- package/server/guest_agent.js +19 -1
- package/server/http/routes.js +191 -0
- package/server/http/socket.js +1 -1
- package/server/index.js +4 -1
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +73834 -72596
- package/server/routes/browser.js +14 -0
- package/server/routes/browser_extension.js +21 -4
- package/server/routes/desktop.js +10 -0
- package/server/routes/settings.js +4 -0
- package/server/routes/stream.js +187 -0
- package/server/services/ai/tools.js +40 -29
- package/server/services/android/controller.js +41 -2
- package/server/services/browser/controller.js +34 -0
- package/server/services/browser/extension/manifest.js +33 -0
- package/server/services/browser/extension/provider.js +12 -6
- package/server/services/browser/extension/registry.js +188 -18
- package/server/services/desktop/gateway.js +28 -3
- package/server/services/desktop/protocol.js +34 -0
- package/server/services/desktop/provider.js +25 -0
- package/server/services/desktop/registry.js +92 -10
- package/server/services/manager.js +19 -2
- package/server/services/runtime/backends/local-vm.js +6 -0
- package/server/services/runtime/docker-vm-manager.js +26 -3
- package/server/services/runtime/manager.js +36 -5
- package/server/services/runtime/settings.js +17 -0
- package/server/services/streaming/android-stream.js +298 -0
- package/server/services/streaming/browser-stream.js +87 -0
- package/server/services/streaming/stream-hub.js +231 -0
- 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
|
-
|
|
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
package/server/guest_agent.js
CHANGED
|
@@ -62,7 +62,18 @@ function isInsideAllowedRoots(targetPath) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
function requireToken(req, res, next) {
|
|
65
|
-
|
|
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 || {})));
|