neoagent 2.4.0 → 2.4.1-beta.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/LICENSE +619 -21
- package/README.md +1 -1
- 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 +11 -1
- package/extensions/chrome-browser/popup.css +5 -0
- package/extensions/chrome-browser/popup.html +2 -4
- package/extensions/chrome-browser/popup.js +1 -5
- package/flutter_app/lib/main_controller.dart +9 -0
- package/flutter_app/lib/main_devices.dart +70 -1
- package/flutter_app/lib/main_settings.dart +172 -31
- package/flutter_app/lib/src/backend_client.dart +12 -0
- package/flutter_app/lib/src/desktop_companion_actions.dart +72 -0
- package/flutter_app/lib/src/desktop_companion_io.dart +9 -4
- package/flutter_app/macos/Runner/AppDelegate.swift +12 -1
- package/package.json +2 -2
- package/server/guest_agent.js +12 -1
- package/server/http/routes.js +190 -0
- 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 +43860 -43589
- package/server/services/ai/tools.js +39 -28
- package/server/services/desktop/protocol.js +1 -0
- package/server/services/desktop/provider.js +11 -0
- package/server/services/desktop/registry.js +51 -10
- package/server/services/runtime/docker-vm-manager.js +26 -3
- package/server/services/runtime/manager.js +25 -2
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
4
|
+
<stop offset="0%" stop-color="#8f6d3e"/>
|
|
5
|
+
<stop offset="100%" stop-color="#2f7d6e"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect x="5.76" y="5.76" width="20.48" height="20.48" rx="6.96" fill="url(#bg)" stroke="#ffffff" stroke-opacity="0.16" stroke-width="1"/>
|
|
9
|
+
<polygon points="16,9.76 9.35,13.12 16,16.48 22.65,13.12" fill="white"/>
|
|
10
|
+
<polyline points="9.35,16.48 16,19.68 22.65,16.48" fill="none" stroke="white" stroke-width="1.44" stroke-linecap="round" stroke-linejoin="round"/>
|
|
11
|
+
<polyline points="9.35,19.68 16,22.72 22.65,19.68" fill="none" stroke="white" stroke-width="1.44" stroke-linecap="round" stroke-linejoin="round"/>
|
|
12
|
+
</svg>
|
|
@@ -6,9 +6,19 @@
|
|
|
6
6
|
"minimum_chrome_version": "118",
|
|
7
7
|
"permissions": ["debugger", "storage", "tabs"],
|
|
8
8
|
"host_permissions": ["http://*/*", "https://*/*"],
|
|
9
|
+
"icons": {
|
|
10
|
+
"16": "icons/icon16.png",
|
|
11
|
+
"48": "icons/icon48.png",
|
|
12
|
+
"128": "icons/icon128.png"
|
|
13
|
+
},
|
|
9
14
|
"action": {
|
|
10
15
|
"default_title": "NeoAgent Browser",
|
|
11
|
-
"default_popup": "popup.html"
|
|
16
|
+
"default_popup": "popup.html",
|
|
17
|
+
"default_icon": {
|
|
18
|
+
"16": "icons/icon16.png",
|
|
19
|
+
"48": "icons/icon48.png",
|
|
20
|
+
"128": "icons/icon128.png"
|
|
21
|
+
}
|
|
12
22
|
},
|
|
13
23
|
"background": {
|
|
14
24
|
"service_worker": "background.mjs",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
<body>
|
|
9
9
|
<main>
|
|
10
10
|
<header>
|
|
11
|
+
<img src="icons/logo.svg" alt="NeoAgent" class="logo">
|
|
11
12
|
<p class="eyebrow">NeoAgent Browser</p>
|
|
12
13
|
<h1>Connect this Chrome</h1>
|
|
13
14
|
<p class="intro">Pair once, then let NeoAgent control this browser when you ask it to.</p>
|
|
@@ -31,8 +32,6 @@
|
|
|
31
32
|
</div>
|
|
32
33
|
</section>
|
|
33
34
|
|
|
34
|
-
<button id="openApp" type="button" class="link-button">Open NeoAgent web page</button>
|
|
35
|
-
|
|
36
35
|
<details id="settings" class="settings">
|
|
37
36
|
<summary>Settings & updates</summary>
|
|
38
37
|
<label>
|
|
@@ -47,8 +46,7 @@
|
|
|
47
46
|
<p class="hint">The server URL is usually filled in by the ZIP downloaded from NeoAgent.</p>
|
|
48
47
|
</details>
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
<p id="message" class="message"></p>
|
|
49
|
+
<p id="message" class="message"></p>
|
|
52
50
|
</main>
|
|
53
51
|
<script src="popup.js" type="module"></script>
|
|
54
52
|
</body>
|
|
@@ -9,7 +9,6 @@ const flowTitleEl = document.querySelector('#flowTitle');
|
|
|
9
9
|
const flowDescriptionEl = document.querySelector('#flowDescription');
|
|
10
10
|
const primaryActionEl = document.querySelector('#primaryAction');
|
|
11
11
|
const secondaryActionEl = document.querySelector('#secondaryAction');
|
|
12
|
-
const openAppEl = document.querySelector('#openApp');
|
|
13
12
|
const disconnectEl = document.querySelector('#disconnect');
|
|
14
13
|
const checkUpdateEl = document.querySelector('#checkUpdate');
|
|
15
14
|
const downloadEl = document.querySelector('#download');
|
|
@@ -59,7 +58,7 @@ function setBusy(isBusy, label = 'Working...') {
|
|
|
59
58
|
}
|
|
60
59
|
const busy = pendingActions > 0;
|
|
61
60
|
|
|
62
|
-
[primaryActionEl, secondaryActionEl,
|
|
61
|
+
[primaryActionEl, secondaryActionEl, disconnectEl, checkUpdateEl, downloadEl].forEach((button) => {
|
|
63
62
|
if (!button || button.hidden) return;
|
|
64
63
|
if (busy) {
|
|
65
64
|
if (!Object.prototype.hasOwnProperty.call(button.dataset, 'wasDisabled')) {
|
|
@@ -99,8 +98,6 @@ function updateFlow() {
|
|
|
99
98
|
const hasToken = Boolean(currentState.token || currentState.tokenId);
|
|
100
99
|
const approvalUrl = currentState.approvalUrl || '';
|
|
101
100
|
|
|
102
|
-
openAppEl.disabled = !hasServerUrl;
|
|
103
|
-
|
|
104
101
|
if (!hasServerUrl) {
|
|
105
102
|
stepLabelEl.textContent = 'Step 1 of 3';
|
|
106
103
|
flowTitleEl.textContent = 'Add your NeoAgent server';
|
|
@@ -243,7 +240,6 @@ serverUrlEl.addEventListener('input', updateFlow);
|
|
|
243
240
|
|
|
244
241
|
bindAsyncClick(primaryActionEl, () => runAction(primaryActionEl.dataset.action));
|
|
245
242
|
bindAsyncClick(secondaryActionEl, () => runAction(secondaryActionEl.dataset.action));
|
|
246
|
-
bindAsyncClick(openAppEl, () => runAction('openApp'));
|
|
247
243
|
bindAsyncClick(disconnectEl, async () => {
|
|
248
244
|
await send('disconnect');
|
|
249
245
|
await refresh();
|
|
@@ -1040,6 +1040,15 @@ class NeoAgentController extends ChangeNotifier {
|
|
|
1040
1040
|
}
|
|
1041
1041
|
}
|
|
1042
1042
|
|
|
1043
|
+
Future<Map<String, dynamic>> testCliRuntime() =>
|
|
1044
|
+
_backendClient.testCli(backendUrl);
|
|
1045
|
+
|
|
1046
|
+
Future<Map<String, dynamic>> testBrowserExtension() =>
|
|
1047
|
+
_backendClient.testExtension(backendUrl);
|
|
1048
|
+
|
|
1049
|
+
Future<Map<String, dynamic>> testDesktopCompanion() =>
|
|
1050
|
+
_backendClient.testDesktop(backendUrl);
|
|
1051
|
+
|
|
1043
1052
|
Future<void> openAppUpdate() async {
|
|
1044
1053
|
final release = availableAppUpdate;
|
|
1045
1054
|
if (release == null || isOpeningAppUpdate) {
|
|
@@ -103,8 +103,13 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
103
103
|
_onlineDesktopDevices.length > 1 &&
|
|
104
104
|
(widget.controller.selectedDesktopDeviceId ?? '').isEmpty;
|
|
105
105
|
|
|
106
|
+
bool get _extensionPreferredButOffline =>
|
|
107
|
+
widget.controller.browserBackend == 'extension' &&
|
|
108
|
+
!widget.controller.browserExtensionConnected;
|
|
109
|
+
|
|
106
110
|
String? get _activeScreenshotPath {
|
|
107
111
|
if (_isBrowser) {
|
|
112
|
+
if (_extensionPreferredButOffline) return null;
|
|
108
113
|
return widget.controller.browserScreenshotPath;
|
|
109
114
|
}
|
|
110
115
|
if (_isDesktop) {
|
|
@@ -121,6 +126,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
121
126
|
Future<void> _ensurePreview() async {
|
|
122
127
|
final controller = widget.controller;
|
|
123
128
|
if (_isBrowser) {
|
|
129
|
+
if (_extensionPreferredButOffline) return;
|
|
124
130
|
if (controller.browserRuntime['launched'] != true) {
|
|
125
131
|
return;
|
|
126
132
|
}
|
|
@@ -161,6 +167,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
161
167
|
return;
|
|
162
168
|
}
|
|
163
169
|
if (_isBrowser) {
|
|
170
|
+
if (_extensionPreferredButOffline) return;
|
|
164
171
|
await widget.controller.refreshBrowserFrameRuntime();
|
|
165
172
|
return;
|
|
166
173
|
}
|
|
@@ -451,6 +458,14 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
451
458
|
browserExtensionActive: usingExtension,
|
|
452
459
|
browserFallbackLabel: browserFallbackLabel,
|
|
453
460
|
),
|
|
461
|
+
if (_isBrowser && prefersExtension) ...<Widget>[
|
|
462
|
+
const SizedBox(height: 14),
|
|
463
|
+
_ExtensionStatusBar(
|
|
464
|
+
connected: extensionConnected,
|
|
465
|
+
onDownload: controller.downloadBrowserExtension,
|
|
466
|
+
onRefresh: controller.refreshBrowserExtensionStatus,
|
|
467
|
+
),
|
|
468
|
+
],
|
|
454
469
|
if (_isDesktop) ...<Widget>[
|
|
455
470
|
const SizedBox(height: 14),
|
|
456
471
|
DropdownButtonFormField<String>(
|
|
@@ -558,7 +573,9 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
558
573
|
busy: _isCurrentSurfaceBusy,
|
|
559
574
|
wakingUp: !_isBrowser && !_isDesktop && _androidStarting,
|
|
560
575
|
enabled: _isBrowser || _isDesktop || _androidOnline,
|
|
561
|
-
connectRequired:
|
|
576
|
+
connectRequired: _isBrowser
|
|
577
|
+
? _extensionPreferredButOffline
|
|
578
|
+
: _desktopRequiresSelection,
|
|
562
579
|
onTapPoint: _handleTap,
|
|
563
580
|
onSwipe: _handleSwipe,
|
|
564
581
|
onWakeRequested: _openPrimary,
|
|
@@ -1701,6 +1718,58 @@ class _RuntimePreview extends StatelessWidget {
|
|
|
1701
1718
|
}
|
|
1702
1719
|
}
|
|
1703
1720
|
|
|
1721
|
+
class _ExtensionStatusBar extends StatelessWidget {
|
|
1722
|
+
const _ExtensionStatusBar({
|
|
1723
|
+
required this.connected,
|
|
1724
|
+
required this.onDownload,
|
|
1725
|
+
required this.onRefresh,
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
final bool connected;
|
|
1729
|
+
final Future<void> Function() onDownload;
|
|
1730
|
+
final Future<void> Function() onRefresh;
|
|
1731
|
+
|
|
1732
|
+
@override
|
|
1733
|
+
Widget build(BuildContext context) {
|
|
1734
|
+
return Container(
|
|
1735
|
+
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
1736
|
+
decoration: BoxDecoration(
|
|
1737
|
+
color: _bgSecondary,
|
|
1738
|
+
borderRadius: BorderRadius.circular(16),
|
|
1739
|
+
border: Border.all(color: _borderLight),
|
|
1740
|
+
),
|
|
1741
|
+
child: Row(
|
|
1742
|
+
children: <Widget>[
|
|
1743
|
+
_DotStatus(
|
|
1744
|
+
label: connected ? 'Extension connected' : 'Extension not connected',
|
|
1745
|
+
color: connected ? _success : _warning,
|
|
1746
|
+
),
|
|
1747
|
+
const Spacer(),
|
|
1748
|
+
OutlinedButton.icon(
|
|
1749
|
+
onPressed: onDownload,
|
|
1750
|
+
icon: const Icon(Icons.download_outlined, size: 18),
|
|
1751
|
+
label: const Text('Download'),
|
|
1752
|
+
style: OutlinedButton.styleFrom(
|
|
1753
|
+
visualDensity: VisualDensity.compact,
|
|
1754
|
+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
1755
|
+
),
|
|
1756
|
+
),
|
|
1757
|
+
const SizedBox(width: 8),
|
|
1758
|
+
OutlinedButton.icon(
|
|
1759
|
+
onPressed: onRefresh,
|
|
1760
|
+
icon: const Icon(Icons.sync, size: 18),
|
|
1761
|
+
label: const Text('Refresh'),
|
|
1762
|
+
style: OutlinedButton.styleFrom(
|
|
1763
|
+
visualDensity: VisualDensity.compact,
|
|
1764
|
+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
1765
|
+
),
|
|
1766
|
+
),
|
|
1767
|
+
],
|
|
1768
|
+
),
|
|
1769
|
+
);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1704
1773
|
class _ResultBlock extends StatelessWidget {
|
|
1705
1774
|
const _ResultBlock({required this.label, required this.value});
|
|
1706
1775
|
|
|
@@ -187,6 +187,14 @@ class _SettingsPanelState extends State<SettingsPanel> {
|
|
|
187
187
|
<String, TextEditingController>{};
|
|
188
188
|
final Set<String> _expandedProviderIds = <String>{};
|
|
189
189
|
|
|
190
|
+
// Inline runtime test state — ephemeral, not stored in controller.
|
|
191
|
+
bool _cliTestRunning = false;
|
|
192
|
+
Map<String, dynamic>? _cliTestResult;
|
|
193
|
+
bool _extensionTestRunning = false;
|
|
194
|
+
Map<String, dynamic>? _extensionTestResult;
|
|
195
|
+
bool _desktopTestRunning = false;
|
|
196
|
+
Map<String, dynamic>? _desktopTestResult;
|
|
197
|
+
|
|
190
198
|
@override
|
|
191
199
|
void initState() {
|
|
192
200
|
super.initState();
|
|
@@ -636,31 +644,46 @@ class _SettingsPanelState extends State<SettingsPanel> {
|
|
|
636
644
|
},
|
|
637
645
|
),
|
|
638
646
|
const SizedBox(height: 10),
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
647
|
+
if (_browserBackend == 'extension') ...<Widget>[
|
|
648
|
+
_buildInlineTestRow(
|
|
649
|
+
label: 'Chrome extension',
|
|
650
|
+
running: _extensionTestRunning,
|
|
651
|
+
result: _extensionTestResult,
|
|
652
|
+
note: controller.browserExtensionConnected
|
|
653
|
+
? 'Connected — tap Test to verify the live link.'
|
|
654
|
+
: 'Not connected — download the extension, load it in Chrome, then pair after login.',
|
|
655
|
+
onTest: () async {
|
|
656
|
+
setState(() { _extensionTestRunning = true; _extensionTestResult = null; });
|
|
657
|
+
try {
|
|
658
|
+
final r = await controller.testBrowserExtension();
|
|
659
|
+
setState(() => _extensionTestResult = r);
|
|
660
|
+
} catch (e) {
|
|
661
|
+
setState(() => _extensionTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
|
|
662
|
+
} finally {
|
|
663
|
+
setState(() => _extensionTestRunning = false);
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
),
|
|
667
|
+
const SizedBox(height: 10),
|
|
668
|
+
Wrap(
|
|
669
|
+
spacing: 10,
|
|
670
|
+
runSpacing: 10,
|
|
671
|
+
children: <Widget>[
|
|
672
|
+
OutlinedButton.icon(
|
|
673
|
+
onPressed: controller.downloadBrowserExtension,
|
|
674
|
+
icon: Icon(Icons.download_outlined),
|
|
675
|
+
label: Text('Download extension'),
|
|
676
|
+
),
|
|
677
|
+
OutlinedButton.icon(
|
|
678
|
+
onPressed: controller.refreshBrowserExtensionStatus,
|
|
679
|
+
icon: Icon(Icons.sync),
|
|
680
|
+
label: Text('Refresh'),
|
|
681
|
+
),
|
|
682
|
+
],
|
|
683
|
+
),
|
|
684
|
+
] else ...<Widget>[
|
|
685
|
+
Text('Cloud browser runtime is active.', style: TextStyle(color: _textSecondary, height: 1.4)),
|
|
686
|
+
],
|
|
664
687
|
const Divider(height: 32),
|
|
665
688
|
Text(
|
|
666
689
|
'CLI Runtime',
|
|
@@ -692,13 +715,26 @@ class _SettingsPanelState extends State<SettingsPanel> {
|
|
|
692
715
|
},
|
|
693
716
|
),
|
|
694
717
|
const SizedBox(height: 10),
|
|
695
|
-
|
|
696
|
-
|
|
718
|
+
_buildInlineTestRow(
|
|
719
|
+
label: 'CLI',
|
|
720
|
+
running: _cliTestRunning,
|
|
721
|
+
result: _cliTestResult,
|
|
722
|
+
note: _cliBackend == 'desktop'
|
|
697
723
|
? (controller.desktopCompanionConnected
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
: 'Cloud
|
|
701
|
-
|
|
724
|
+
? 'Desktop app connected — commands currently route through the cloud VM (desktop routing coming soon).'
|
|
725
|
+
: 'Desktop app selected but not connected. Commands fall back to cloud VM until the companion is online.')
|
|
726
|
+
: 'Cloud VM — commands run in an isolated container.',
|
|
727
|
+
onTest: () async {
|
|
728
|
+
setState(() { _cliTestRunning = true; _cliTestResult = null; });
|
|
729
|
+
try {
|
|
730
|
+
final r = await controller.testCliRuntime();
|
|
731
|
+
setState(() => _cliTestResult = r);
|
|
732
|
+
} catch (e) {
|
|
733
|
+
setState(() => _cliTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
|
|
734
|
+
} finally {
|
|
735
|
+
setState(() => _cliTestRunning = false);
|
|
736
|
+
}
|
|
737
|
+
},
|
|
702
738
|
),
|
|
703
739
|
const Divider(height: 32),
|
|
704
740
|
Text(
|
|
@@ -1344,6 +1380,47 @@ class _SettingsPanelState extends State<SettingsPanel> {
|
|
|
1344
1380
|
const SizedBox(height: 12),
|
|
1345
1381
|
_InlineError(message: message),
|
|
1346
1382
|
],
|
|
1383
|
+
const SizedBox(height: 12),
|
|
1384
|
+
_buildInlineTestRow(
|
|
1385
|
+
label: 'Desktop companion',
|
|
1386
|
+
running: _desktopTestRunning,
|
|
1387
|
+
result: _desktopTestResult != null
|
|
1388
|
+
? <String, dynamic>{
|
|
1389
|
+
'passed': _desktopTestResult!['passed'] == true,
|
|
1390
|
+
'detail': _desktopTestResult!['detail']?.toString() ?? '',
|
|
1391
|
+
}
|
|
1392
|
+
: null,
|
|
1393
|
+
note: controller.desktopCompanionConnected
|
|
1394
|
+
? 'Connected — tap Test to fetch live device status from the server.'
|
|
1395
|
+
: 'Not connected. Make sure the desktop app is running on the target machine.',
|
|
1396
|
+
onTest: () async {
|
|
1397
|
+
setState(() { _desktopTestRunning = true; _desktopTestResult = null; });
|
|
1398
|
+
try {
|
|
1399
|
+
final r = await controller.testDesktopCompanion();
|
|
1400
|
+
final active = r['activeDevice'];
|
|
1401
|
+
final multi = r['multipleOnline'] == true;
|
|
1402
|
+
String detail = r['detail']?.toString() ?? '';
|
|
1403
|
+
if (r['passed'] == true && active != null) {
|
|
1404
|
+
final label = active['label']?.toString() ?? 'Device';
|
|
1405
|
+
final plat = active['platform']?.toString() ?? '';
|
|
1406
|
+
final sc = active['permissions']?['screenCapture'] == true;
|
|
1407
|
+
final ic = active['permissions']?['inputControl'] == true;
|
|
1408
|
+
detail = '$label${plat.isNotEmpty ? " ($plat)" : ""}'
|
|
1409
|
+
' — screen: ${sc ? "✓" : "✗"}, input: ${ic ? "✓" : "✗"}';
|
|
1410
|
+
} else if (multi) {
|
|
1411
|
+
detail = '${r['onlineCount']} devices online — select one in Desktop › Devices';
|
|
1412
|
+
}
|
|
1413
|
+
setState(() => _desktopTestResult = <String, dynamic>{
|
|
1414
|
+
...r,
|
|
1415
|
+
'detail': detail,
|
|
1416
|
+
});
|
|
1417
|
+
} catch (e) {
|
|
1418
|
+
setState(() => _desktopTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
|
|
1419
|
+
} finally {
|
|
1420
|
+
setState(() => _desktopTestRunning = false);
|
|
1421
|
+
}
|
|
1422
|
+
},
|
|
1423
|
+
),
|
|
1347
1424
|
const SizedBox(height: 14),
|
|
1348
1425
|
Builder(
|
|
1349
1426
|
builder: (context) {
|
|
@@ -1999,6 +2076,70 @@ class _SettingsPanelState extends State<SettingsPanel> {
|
|
|
1999
2076
|
)
|
|
2000
2077
|
.toList();
|
|
2001
2078
|
}
|
|
2079
|
+
|
|
2080
|
+
// Shared helper: small "Test" button + inline result row.
|
|
2081
|
+
Widget _buildInlineTestRow({
|
|
2082
|
+
required String label,
|
|
2083
|
+
required bool running,
|
|
2084
|
+
required Map<String, dynamic>? result,
|
|
2085
|
+
required VoidCallback onTest,
|
|
2086
|
+
String? note,
|
|
2087
|
+
}) {
|
|
2088
|
+
final passed = result?['passed'] == true;
|
|
2089
|
+
final detail = result?['detail']?.toString() ?? '';
|
|
2090
|
+
return Row(
|
|
2091
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
2092
|
+
children: <Widget>[
|
|
2093
|
+
Expanded(
|
|
2094
|
+
child: Column(
|
|
2095
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
2096
|
+
children: <Widget>[
|
|
2097
|
+
if (result != null)
|
|
2098
|
+
Row(
|
|
2099
|
+
children: <Widget>[
|
|
2100
|
+
Icon(
|
|
2101
|
+
passed ? Icons.check_circle_rounded : Icons.cancel_rounded,
|
|
2102
|
+
size: 15,
|
|
2103
|
+
color: passed ? const Color(0xFF22C55E) : const Color(0xFFEF4444),
|
|
2104
|
+
),
|
|
2105
|
+
const SizedBox(width: 6),
|
|
2106
|
+
Expanded(
|
|
2107
|
+
child: Text(
|
|
2108
|
+
passed ? (detail.isNotEmpty ? detail : '$label: OK') : detail,
|
|
2109
|
+
style: TextStyle(
|
|
2110
|
+
fontSize: 13,
|
|
2111
|
+
color: passed ? null : const Color(0xFFEF4444),
|
|
2112
|
+
),
|
|
2113
|
+
),
|
|
2114
|
+
),
|
|
2115
|
+
],
|
|
2116
|
+
)
|
|
2117
|
+
else if (note != null)
|
|
2118
|
+
Text(note, style: TextStyle(fontSize: 13, color: _textSecondary, height: 1.4)),
|
|
2119
|
+
],
|
|
2120
|
+
),
|
|
2121
|
+
),
|
|
2122
|
+
const SizedBox(width: 12),
|
|
2123
|
+
SizedBox(
|
|
2124
|
+
width: 80,
|
|
2125
|
+
child: OutlinedButton(
|
|
2126
|
+
onPressed: running ? null : onTest,
|
|
2127
|
+
style: OutlinedButton.styleFrom(
|
|
2128
|
+
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
2129
|
+
textStyle: const TextStyle(fontSize: 12),
|
|
2130
|
+
),
|
|
2131
|
+
child: running
|
|
2132
|
+
? const SizedBox(
|
|
2133
|
+
width: 13,
|
|
2134
|
+
height: 13,
|
|
2135
|
+
child: CircularProgressIndicator(strokeWidth: 2),
|
|
2136
|
+
)
|
|
2137
|
+
: const Text('Test'),
|
|
2138
|
+
),
|
|
2139
|
+
),
|
|
2140
|
+
],
|
|
2141
|
+
);
|
|
2142
|
+
}
|
|
2002
2143
|
}
|
|
2003
2144
|
|
|
2004
2145
|
class _AiProviderCard extends StatelessWidget {
|
|
@@ -476,6 +476,18 @@ class BackendClient {
|
|
|
476
476
|
return getMap(baseUrl, '/api/runtime/config', allowUnauthorized: true);
|
|
477
477
|
}
|
|
478
478
|
|
|
479
|
+
Future<Map<String, dynamic>> testCli(String baseUrl) async {
|
|
480
|
+
return getMap(baseUrl, '/api/system/test/cli');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
Future<Map<String, dynamic>> testExtension(String baseUrl) async {
|
|
484
|
+
return getMap(baseUrl, '/api/system/test/extension');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
Future<Map<String, dynamic>> testDesktop(String baseUrl) async {
|
|
488
|
+
return getMap(baseUrl, '/api/system/test/desktop');
|
|
489
|
+
}
|
|
490
|
+
|
|
479
491
|
Future<Map<String, dynamic>> fetchBrowserStatus(String baseUrl) async {
|
|
480
492
|
return getMap(baseUrl, '/api/browser/status');
|
|
481
493
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import 'dart:async';
|
|
1
2
|
import 'dart:convert';
|
|
2
3
|
import 'dart:io';
|
|
3
4
|
|
|
@@ -396,6 +397,77 @@ class DesktopCompanionActions {
|
|
|
396
397
|
};
|
|
397
398
|
}
|
|
398
399
|
|
|
400
|
+
Future<Map<String, Object?>> executeShellCommand({
|
|
401
|
+
required String command,
|
|
402
|
+
String? cwd,
|
|
403
|
+
int? timeoutMs,
|
|
404
|
+
String? stdinInput,
|
|
405
|
+
}) async {
|
|
406
|
+
final shell = Platform.isWindows ? 'cmd.exe' : (Platform.environment['SHELL'] ?? '/bin/sh');
|
|
407
|
+
final args = Platform.isWindows ? <String>['/c', command] : <String>['-lc', command];
|
|
408
|
+
final workingDir = cwd?.trim().isNotEmpty == true ? cwd : Platform.environment['HOME'];
|
|
409
|
+
final startedAt = DateTime.now();
|
|
410
|
+
|
|
411
|
+
final process = await Process.start(
|
|
412
|
+
shell,
|
|
413
|
+
args,
|
|
414
|
+
workingDirectory: workingDir,
|
|
415
|
+
runInShell: false,
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
if (stdinInput != null && stdinInput.isNotEmpty) {
|
|
419
|
+
process.stdin.write(stdinInput);
|
|
420
|
+
await process.stdin.close();
|
|
421
|
+
} else {
|
|
422
|
+
unawaited(process.stdin.close());
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const maxChars = 50000;
|
|
426
|
+
final stdoutBuf = StringBuffer();
|
|
427
|
+
final stderrBuf = StringBuffer();
|
|
428
|
+
|
|
429
|
+
final stdoutSub = process.stdout.transform(utf8.decoder).listen((data) {
|
|
430
|
+
stdoutBuf.write(data);
|
|
431
|
+
});
|
|
432
|
+
final stderrSub = process.stderr.transform(utf8.decoder).listen((data) {
|
|
433
|
+
stderrBuf.write(data);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
final effectiveTimeout = Duration(
|
|
437
|
+
milliseconds: (timeoutMs != null && timeoutMs > 0) ? timeoutMs : 15 * 60 * 1000,
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
bool timedOut = false;
|
|
441
|
+
int? exitCode;
|
|
442
|
+
try {
|
|
443
|
+
exitCode = await process.exitCode.timeout(effectiveTimeout);
|
|
444
|
+
} on TimeoutException {
|
|
445
|
+
timedOut = true;
|
|
446
|
+
process.kill(ProcessSignal.sigterm);
|
|
447
|
+
exitCode = null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
await stdoutSub.cancel();
|
|
451
|
+
await stderrSub.cancel();
|
|
452
|
+
|
|
453
|
+
String _trim(StringBuffer buf) {
|
|
454
|
+
final s = buf.toString().trim();
|
|
455
|
+
return s.length > maxChars ? '${s.substring(0, maxChars)}\n...[truncated, ${s.length} total chars]' : s;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return <String, Object?>{
|
|
459
|
+
'exitCode': exitCode,
|
|
460
|
+
'stdout': _trim(stdoutBuf),
|
|
461
|
+
'stderr': _trim(stderrBuf),
|
|
462
|
+
'timedOut': timedOut,
|
|
463
|
+
'killed': timedOut,
|
|
464
|
+
'durationMs': DateTime.now().difference(startedAt).inMilliseconds,
|
|
465
|
+
'command': command,
|
|
466
|
+
'cwd': workingDir,
|
|
467
|
+
'backend': 'desktop-companion',
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
399
471
|
Future<Map<String, Object?>> _capabilities({
|
|
400
472
|
Map<String, Object?>? platformStatus,
|
|
401
473
|
}) async {
|
|
@@ -52,7 +52,8 @@ class DesktopCompanionManager extends ChangeNotifier {
|
|
|
52
52
|
|
|
53
53
|
Future<void> bootstrap(SharedPreferences prefs) async {
|
|
54
54
|
_enabled = prefs.getBool(desktopCompanionEnabledPrefsKey) ?? false;
|
|
55
|
-
|
|
55
|
+
// Always start unpaused — paused state must not carry over across restarts.
|
|
56
|
+
_paused = false;
|
|
56
57
|
_label =
|
|
57
58
|
prefs.getString(desktopCompanionLabelPrefsKey)?.trim() ??
|
|
58
59
|
_defaultLabel();
|
|
@@ -116,7 +117,6 @@ class DesktopCompanionManager extends ChangeNotifier {
|
|
|
116
117
|
|
|
117
118
|
Future<void> setPaused(bool value, SharedPreferences prefs) async {
|
|
118
119
|
_paused = value;
|
|
119
|
-
await prefs.setBool(desktopCompanionPausedPrefsKey, value);
|
|
120
120
|
notifyListeners();
|
|
121
121
|
if (_connected) {
|
|
122
122
|
await _sendEvent('statusChanged', <String, Object?>{'paused': value});
|
|
@@ -387,10 +387,15 @@ class DesktopCompanionManager extends ChangeNotifier {
|
|
|
387
387
|
case 'pauseControl':
|
|
388
388
|
final paused = payload['paused'] != false;
|
|
389
389
|
_paused = paused;
|
|
390
|
-
final prefs = await SharedPreferences.getInstance();
|
|
391
|
-
await prefs.setBool(desktopCompanionPausedPrefsKey, paused);
|
|
392
390
|
notifyListeners();
|
|
393
391
|
return <String, Object?>{'success': true, 'paused': _paused};
|
|
392
|
+
case 'executeCommand':
|
|
393
|
+
return _actions.executeShellCommand(
|
|
394
|
+
command: payload['command']?.toString() ?? '',
|
|
395
|
+
cwd: payload['cwd']?.toString(),
|
|
396
|
+
timeoutMs: (payload['timeout'] as num?)?.toInt(),
|
|
397
|
+
stdinInput: payload['stdin_input']?.toString(),
|
|
398
|
+
);
|
|
394
399
|
case 'ping':
|
|
395
400
|
return <String, Object?>{'pong': true};
|
|
396
401
|
default:
|
|
@@ -316,7 +316,18 @@ final class DesktopCompanionNativePlugin: NSObject {
|
|
|
316
316
|
}
|
|
317
317
|
|
|
318
318
|
private func isAccessibilityTrusted() -> Bool {
|
|
319
|
-
AXIsProcessTrusted()
|
|
319
|
+
if AXIsProcessTrusted() { return true }
|
|
320
|
+
// AXIsProcessTrusted() may cache false on macOS 14+ after a System Settings grant.
|
|
321
|
+
// Probe with a live AX read: .apiDisabled is the error returned when the process
|
|
322
|
+
// lacks accessibility permission (AXError has no .notTrusted case in the macOS SDK).
|
|
323
|
+
let sysElement = AXUIElementCreateSystemWide()
|
|
324
|
+
var value: CFTypeRef?
|
|
325
|
+
let status = AXUIElementCopyAttributeValue(
|
|
326
|
+
sysElement,
|
|
327
|
+
kAXFocusedApplicationAttribute as CFString,
|
|
328
|
+
&value
|
|
329
|
+
)
|
|
330
|
+
return status != .apiDisabled
|
|
320
331
|
}
|
|
321
332
|
|
|
322
333
|
private func resolveDisplayId(_ raw: String?) -> CGDirectDisplayID {
|
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) {
|