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
@@ -1,7 +1,7 @@
1
1
  import { createBrowserProtocol } from './protocol.mjs';
2
2
  import { DEFAULT_SERVER_URL } from './config.mjs';
3
3
 
4
- const STORAGE_KEYS = ['serverUrl', 'configuredServerUrl', 'token', 'pairingId', 'pairingSecret', 'approvalUrl', 'status'];
4
+ const STORAGE_KEYS = ['serverUrl', 'configuredServerUrl', 'token', 'pairingId', 'pairingSecret', 'approvalUrl', 'status', 'extensionName'];
5
5
  const protocol = createBrowserProtocol(chrome);
6
6
  let socket = null;
7
7
  let reconnectTimer = null;
@@ -188,10 +188,12 @@ async function handleSocketMessage(raw) {
188
188
  async function startPairing(serverUrl) {
189
189
  const normalized = await resolveServerUrl(serverUrl);
190
190
  if (!normalized) throw new Error('NeoAgent server URL required.');
191
+ const { extensionName } = await getStorage(['extensionName']);
192
+ const nameToUse = String(extensionName || 'Chrome Extension').trim() || 'Chrome Extension';
191
193
  const response = await fetchWithTimeout(`${normalized}/api/browser-extension/pairing/request`, {
192
194
  method: 'POST',
193
195
  headers: { 'content-type': 'application/json' },
194
- body: JSON.stringify({ extensionName: 'NeoAgent Browser' }),
196
+ body: JSON.stringify({ extensionName: nameToUse }),
195
197
  });
196
198
  const payload = await response.json().catch(() => ({}));
197
199
  if (!response.ok) throw new Error(payload.error || `Pairing failed: ${response.status}`);
@@ -212,14 +214,15 @@ async function startPairing(serverUrl) {
212
214
  }
213
215
 
214
216
  async function claimPairing() {
215
- const { serverUrl, pairingId, pairingSecret } = await getStorage(['serverUrl', 'pairingId', 'pairingSecret']);
217
+ const { serverUrl, pairingId, pairingSecret, extensionName } = await getStorage(['serverUrl', 'pairingId', 'pairingSecret', 'extensionName']);
216
218
  if (!serverUrl || !pairingId || !pairingSecret) {
217
219
  throw new Error('No pending pairing request.');
218
220
  }
221
+ const nameToUse = String(extensionName || 'Chrome Extension').trim() || 'Chrome Extension';
219
222
  const response = await fetchWithTimeout(`${serverUrl}/api/browser-extension/pairing/${encodeURIComponent(pairingId)}/claim`, {
220
223
  method: 'POST',
221
224
  headers: { 'content-type': 'application/json' },
222
- body: JSON.stringify({ pairingSecret, extensionName: 'NeoAgent Browser' }),
225
+ body: JSON.stringify({ pairingSecret, extensionName: nameToUse }),
223
226
  });
224
227
  const payload = await response.json().catch(() => ({}));
225
228
  if (!response.ok) throw new Error(payload.error || `Claim failed: ${response.status}`);
@@ -250,12 +253,18 @@ async function checkForUpdates(preferredServerUrl) {
250
253
  const response = await fetchWithTimeout(`${serverUrl}/api/browser-extension/latest`);
251
254
  const latest = await response.json().catch(() => ({}));
252
255
  if (!response.ok) throw new Error(latest.error || `Update check failed: ${response.status}`);
253
- const currentVersion = chrome.runtime.getManifest().version;
256
+ const manifest = chrome.runtime.getManifest();
257
+ const currentVersion = manifest.version;
258
+ const currentVersionName = manifest.version_name || currentVersion;
259
+ const latestVersion = latest.version || currentVersion;
260
+ const latestVersionName = latest.versionName || latestVersion;
254
261
  return {
255
262
  currentVersion,
256
- latestVersion: latest.version || currentVersion,
263
+ currentVersionName,
264
+ latestVersion,
265
+ latestVersionName,
257
266
  downloadUrl: latest.downloadUrl || `${serverUrl}/api/browser-extension/download`,
258
- updateAvailable: compareVersions(latest.version, currentVersion) > 0,
267
+ updateAvailable: compareVersions(latestVersion, currentVersion) > 0,
259
268
  };
260
269
  }
261
270
 
@@ -279,6 +288,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
279
288
  return disconnect();
280
289
  case 'checkForUpdates':
281
290
  return checkForUpdates(message.serverUrl);
291
+ case 'saveExtensionName':
292
+ await setStorage({ extensionName: message.extensionName });
293
+ return { success: true };
282
294
  case 'openDownload':
283
295
  return openDownload(message.serverUrl);
284
296
  case 'getState':
@@ -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>
@@ -1,14 +1,25 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "NeoAgent Browser",
4
- "version": "1.0.0",
4
+ "version": "2.4.1.7",
5
+ "version_name": "2.4.1-beta.7",
5
6
  "description": "Connect this Chrome browser to NeoAgent for browser automation.",
6
7
  "minimum_chrome_version": "118",
7
8
  "permissions": ["debugger", "storage", "tabs"],
8
9
  "host_permissions": ["http://*/*", "https://*/*"],
10
+ "icons": {
11
+ "16": "icons/icon16.png",
12
+ "48": "icons/icon48.png",
13
+ "128": "icons/icon128.png"
14
+ },
9
15
  "action": {
10
16
  "default_title": "NeoAgent Browser",
11
- "default_popup": "popup.html"
17
+ "default_popup": "popup.html",
18
+ "default_icon": {
19
+ "16": "icons/icon16.png",
20
+ "48": "icons/icon48.png",
21
+ "128": "icons/icon128.png"
22
+ }
12
23
  },
13
24
  "background": {
14
25
  "service_worker": "background.mjs",
@@ -46,6 +46,11 @@ header {
46
46
  gap: 6px;
47
47
  }
48
48
 
49
+ .logo {
50
+ width: 40px;
51
+ height: 40px;
52
+ }
53
+
49
54
  h1,
50
55
  h2,
51
56
  p {
@@ -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,15 +32,17 @@
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 &amp; updates</summary>
38
37
  <label>
39
38
  Server URL
40
39
  <input id="serverUrl" type="url" placeholder="https://neoagent.example.com">
41
40
  </label>
42
- <div class="settings-actions">
41
+ <label style="margin-top: 8px;">
42
+ Extension Name
43
+ <input id="extensionName" type="text" placeholder="e.g. Work Laptop, Personal Mac" value="Chrome Extension">
44
+ </label>
45
+ <div class="settings-actions" style="margin-top: 12px;">
43
46
  <button id="checkUpdate" type="button" class="secondary">Check for update</button>
44
47
  <button id="download" type="button" class="secondary">Download latest ZIP</button>
45
48
  <button id="disconnect" type="button" class="secondary danger">Disconnect</button>
@@ -47,8 +50,7 @@
47
50
  <p class="hint">The server URL is usually filled in by the ZIP downloaded from NeoAgent.</p>
48
51
  </details>
49
52
 
50
- <p class="hint">Pairing opens NeoAgent in a tab. This extension never stores your NeoAgent password.</p>
51
- <p id="message" class="message"></p>
53
+ <p id="message" class="message"></p>
52
54
  </main>
53
55
  <script src="popup.js" type="module"></script>
54
56
  </body>
@@ -1,6 +1,7 @@
1
1
  const statusEl = document.querySelector('#status');
2
2
  const statusDotEl = document.querySelector('#statusDot');
3
3
  const serverUrlEl = document.querySelector('#serverUrl');
4
+ const extensionNameEl = document.querySelector('#extensionName');
4
5
  const serverLabelEl = document.querySelector('#serverLabel');
5
6
  const messageEl = document.querySelector('#message');
6
7
  const settingsEl = document.querySelector('#settings');
@@ -9,7 +10,6 @@ const flowTitleEl = document.querySelector('#flowTitle');
9
10
  const flowDescriptionEl = document.querySelector('#flowDescription');
10
11
  const primaryActionEl = document.querySelector('#primaryAction');
11
12
  const secondaryActionEl = document.querySelector('#secondaryAction');
12
- const openAppEl = document.querySelector('#openApp');
13
13
  const disconnectEl = document.querySelector('#disconnect');
14
14
  const checkUpdateEl = document.querySelector('#checkUpdate');
15
15
  const downloadEl = document.querySelector('#download');
@@ -59,7 +59,7 @@ function setBusy(isBusy, label = 'Working...') {
59
59
  }
60
60
  const busy = pendingActions > 0;
61
61
 
62
- [primaryActionEl, secondaryActionEl, openAppEl, disconnectEl, checkUpdateEl, downloadEl].forEach((button) => {
62
+ [primaryActionEl, secondaryActionEl, disconnectEl, checkUpdateEl, downloadEl].forEach((button) => {
63
63
  if (!button || button.hidden) return;
64
64
  if (busy) {
65
65
  if (!Object.prototype.hasOwnProperty.call(button.dataset, 'wasDisabled')) {
@@ -99,8 +99,6 @@ function updateFlow() {
99
99
  const hasToken = Boolean(currentState.token || currentState.tokenId);
100
100
  const approvalUrl = currentState.approvalUrl || '';
101
101
 
102
- openAppEl.disabled = !hasServerUrl;
103
-
104
102
  if (!hasServerUrl) {
105
103
  stepLabelEl.textContent = 'Step 1 of 3';
106
104
  flowTitleEl.textContent = 'Add your NeoAgent server';
@@ -217,6 +215,10 @@ async function refresh() {
217
215
  if (serverUrl && document.activeElement !== serverUrlEl) {
218
216
  serverUrlEl.value = serverUrl;
219
217
  }
218
+ const extensionName = currentState.extensionName || 'Chrome Extension';
219
+ if (document.activeElement !== extensionNameEl) {
220
+ extensionNameEl.value = extensionName;
221
+ }
220
222
  if (!serverUrl) {
221
223
  settingsEl.open = true;
222
224
  }
@@ -240,10 +242,17 @@ function bindAsyncClick(element, handler) {
240
242
  }
241
243
 
242
244
  serverUrlEl.addEventListener('input', updateFlow);
245
+ extensionNameEl.addEventListener('input', async () => {
246
+ const name = String(extensionNameEl.value || '').trim();
247
+ try {
248
+ await send('saveExtensionName', { extensionName: name });
249
+ } catch (err) {
250
+ console.error('Failed to save extension name', err);
251
+ }
252
+ });
243
253
 
244
254
  bindAsyncClick(primaryActionEl, () => runAction(primaryActionEl.dataset.action));
245
255
  bindAsyncClick(secondaryActionEl, () => runAction(secondaryActionEl.dataset.action));
246
- bindAsyncClick(openAppEl, () => runAction('openApp'));
247
256
  bindAsyncClick(disconnectEl, async () => {
248
257
  await send('disconnect');
249
258
  await refresh();
@@ -253,8 +262,8 @@ bindAsyncClick(checkUpdateEl, async () => {
253
262
  const result = await send('checkForUpdates', { serverUrl: effectiveServerUrl() });
254
263
  setMessage(
255
264
  result.updateAvailable
256
- ? `Update available: ${result.currentVersion} -> ${result.latestVersion}.`
257
- : `Current version ${result.currentVersion} is up to date.`,
265
+ ? `Update available: ${result.currentVersionName || result.currentVersion} -> ${result.latestVersionName || result.latestVersion}.`
266
+ : `Current version ${result.currentVersionName || result.currentVersion} is up to date.`,
258
267
  result.updateAvailable ? '' : 'success',
259
268
  );
260
269
  });
@@ -0,0 +1,391 @@
1
+ import 'package:flutter/material.dart';
2
+ import 'package:flutter_animate/flutter_animate.dart';
3
+ import 'package:url_launcher/url_launcher.dart';
4
+
5
+ import '../../main.dart';
6
+ import 'onboarding_chrome.dart';
7
+
8
+ class OnboardingCompanionStep extends StatefulWidget {
9
+ const OnboardingCompanionStep({
10
+ super.key,
11
+ required this.onNext,
12
+ required this.controller,
13
+ });
14
+
15
+ final VoidCallback onNext;
16
+ final NeoAgentController controller;
17
+
18
+ @override
19
+ State<OnboardingCompanionStep> createState() => _OnboardingCompanionStepState();
20
+ }
21
+
22
+ class _OnboardingCompanionStepState extends State<OnboardingCompanionStep> {
23
+ final Set<String> _clickedDownloads = <String>{};
24
+
25
+ Future<void> _launchUrl(String urlString) async {
26
+ final url = Uri.parse(urlString);
27
+ if (await canLaunchUrl(url)) {
28
+ await launchUrl(url, mode: LaunchMode.externalApplication);
29
+ }
30
+ }
31
+
32
+ @override
33
+ Widget build(BuildContext context) {
34
+ final width = MediaQuery.sizeOf(context).width;
35
+ final useGrid = width >= 700;
36
+ final columns = width >= 1050 ? 3 : (useGrid ? 2 : 1);
37
+
38
+ return AnimatedBuilder(
39
+ animation: widget.controller,
40
+ builder: (context, _) {
41
+ final extConnected = widget.controller.browserExtensionConnected;
42
+ final desktopConnected = widget.controller.desktopCompanionConnected;
43
+
44
+ final items = <_CompanionItemData>[
45
+ _CompanionItemData(
46
+ id: 'extension',
47
+ title: 'Chrome Extension',
48
+ subtitle: 'Automate browser tasks and capture web page context directly.',
49
+ icon: Icons.extension_rounded,
50
+ accentColor: extConnected ? const Color(0xFF10A37F) : const Color(0xFF4285F4),
51
+ connected: extConnected,
52
+ buttonText: extConnected ? 'Extension Connected' : 'Download Extension',
53
+ onTap: () async {
54
+ setState(() => _clickedDownloads.add('extension'));
55
+ await widget.controller.downloadBrowserExtension();
56
+ },
57
+ ),
58
+ _CompanionItemData(
59
+ id: 'desktop',
60
+ title: 'Desktop App',
61
+ subtitle: 'Enable native command run, system capture, and global controls.',
62
+ icon: Icons.laptop_mac_rounded,
63
+ accentColor: desktopConnected ? const Color(0xFF10A37F) : const Color(0xFF8A5CF5),
64
+ connected: desktopConnected,
65
+ buttonText: desktopConnected ? 'Desktop Connected' : 'Get Desktop App',
66
+ onTap: () async {
67
+ setState(() => _clickedDownloads.add('desktop'));
68
+ await _launchUrl('https://github.com/NeoLabs-Systems/NeoAgent/releases');
69
+ },
70
+ ),
71
+ _CompanionItemData(
72
+ id: 'mobile',
73
+ title: 'Mobile Companion',
74
+ subtitle: 'Sync notifications, phone calls, and health connect metrics.',
75
+ icon: Icons.phone_iphone_rounded,
76
+ accentColor: const Color(0xFFF5A623),
77
+ connected: false,
78
+ buttonText: 'Download Android APK',
79
+ onTap: () async {
80
+ setState(() => _clickedDownloads.add('mobile'));
81
+ await _launchUrl('https://github.com/NeoLabs-Systems/NeoAgent/releases');
82
+ },
83
+ ),
84
+ ];
85
+
86
+ return OnboardingScaffold(
87
+ step: 1,
88
+ totalSteps: 4,
89
+ eyebrow: 'INTEGRATION',
90
+ title: 'Connect your\ndevices & apps.',
91
+ description: 'NeoOS works best when integrated with your desktop, browser, and mobile devices.',
92
+ footer: Row(
93
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
94
+ children: <Widget>[
95
+ OnboardingGhostButton(
96
+ label: 'Skip integration',
97
+ onPressed: widget.onNext,
98
+ ),
99
+ OnboardingPrimaryButton(
100
+ label: 'Continue',
101
+ icon: Icons.arrow_forward_rounded,
102
+ onPressed: widget.onNext,
103
+ ),
104
+ ],
105
+ ),
106
+ child: useGrid
107
+ ? GridView.builder(
108
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
109
+ crossAxisCount: columns,
110
+ crossAxisSpacing: 14,
111
+ mainAxisSpacing: 14,
112
+ childAspectRatio: width >= 1050 ? 1.42 : 1.25,
113
+ ),
114
+ itemCount: items.length,
115
+ itemBuilder: (context, index) {
116
+ final item = items[index];
117
+ return _CompanionCard(
118
+ item: item,
119
+ compact: true,
120
+ isClicked: _clickedDownloads.contains(item.id),
121
+ )
122
+ .animate()
123
+ .fadeIn(duration: 420.ms, delay: (180 + (index * 80)).ms)
124
+ .slideY(begin: 0.16, end: 0);
125
+ },
126
+ )
127
+ : ListView.separated(
128
+ itemCount: items.length,
129
+ separatorBuilder: (_, __) => const SizedBox(height: 14),
130
+ itemBuilder: (context, index) {
131
+ final item = items[index];
132
+ return _CompanionCard(
133
+ item: item,
134
+ compact: false,
135
+ isClicked: _clickedDownloads.contains(item.id),
136
+ )
137
+ .animate()
138
+ .fadeIn(duration: 420.ms, delay: (180 + (index * 80)).ms)
139
+ .slideY(begin: 0.16, end: 0);
140
+ },
141
+ ),
142
+ );
143
+ },
144
+ );
145
+ }
146
+ }
147
+
148
+ class _CompanionItemData {
149
+ const _CompanionItemData({
150
+ required this.id,
151
+ required this.title,
152
+ required this.subtitle,
153
+ required this.icon,
154
+ required this.accentColor,
155
+ required this.connected,
156
+ required this.buttonText,
157
+ required this.onTap,
158
+ });
159
+
160
+ final String id;
161
+ final String title;
162
+ final String subtitle;
163
+ final IconData icon;
164
+ final Color accentColor;
165
+ final bool connected;
166
+ final String buttonText;
167
+ final VoidCallback onTap;
168
+ }
169
+
170
+ class _CompanionCard extends StatelessWidget {
171
+ const _CompanionCard({
172
+ required this.item,
173
+ required this.compact,
174
+ required this.isClicked,
175
+ });
176
+
177
+ final _CompanionItemData item;
178
+ final bool compact;
179
+ final bool isClicked;
180
+
181
+ @override
182
+ Widget build(BuildContext context) {
183
+ final shellSize = compact ? 48.0 : 58.0;
184
+ final iconSize = compact ? 24.0 : 30.0;
185
+
186
+ final cardContent = compact
187
+ ? Column(
188
+ crossAxisAlignment: CrossAxisAlignment.start,
189
+ children: <Widget>[
190
+ Row(
191
+ children: <Widget>[
192
+ Container(
193
+ width: shellSize,
194
+ height: shellSize,
195
+ decoration: BoxDecoration(
196
+ color: item.accentColor.withValues(alpha: 0.18),
197
+ borderRadius: BorderRadius.circular(16),
198
+ ),
199
+ child: Icon(
200
+ item.icon,
201
+ color: item.accentColor,
202
+ size: iconSize,
203
+ ),
204
+ ),
205
+ const Spacer(),
206
+ _StatusIndicator(connected: item.connected, color: item.accentColor),
207
+ ],
208
+ ),
209
+ const SizedBox(height: 14),
210
+ Text(
211
+ item.title,
212
+ maxLines: 1,
213
+ overflow: TextOverflow.ellipsis,
214
+ style: const TextStyle(
215
+ color: Colors.white,
216
+ fontSize: 18,
217
+ fontWeight: FontWeight.w800,
218
+ ),
219
+ ),
220
+ const SizedBox(height: 6),
221
+ Expanded(
222
+ child: Text(
223
+ item.subtitle,
224
+ maxLines: 3,
225
+ overflow: TextOverflow.ellipsis,
226
+ style: TextStyle(
227
+ color: Colors.white.withValues(alpha: 0.68),
228
+ fontSize: 13,
229
+ height: 1.35,
230
+ ),
231
+ ),
232
+ ),
233
+ const SizedBox(height: 12),
234
+ _DownloadButton(item: item, isClicked: isClicked),
235
+ ],
236
+ )
237
+ : Row(
238
+ crossAxisAlignment: CrossAxisAlignment.start,
239
+ children: <Widget>[
240
+ Container(
241
+ width: shellSize,
242
+ height: shellSize,
243
+ decoration: BoxDecoration(
244
+ color: item.accentColor.withValues(alpha: 0.18),
245
+ borderRadius: BorderRadius.circular(18),
246
+ ),
247
+ child: Icon(
248
+ item.icon,
249
+ color: item.accentColor,
250
+ size: iconSize,
251
+ ),
252
+ ),
253
+ const SizedBox(width: 18),
254
+ Expanded(
255
+ child: Column(
256
+ crossAxisAlignment: CrossAxisAlignment.start,
257
+ mainAxisSize: MainAxisSize.min,
258
+ children: <Widget>[
259
+ Text(
260
+ item.title,
261
+ style: const TextStyle(
262
+ color: Colors.white,
263
+ fontSize: 20,
264
+ fontWeight: FontWeight.w800,
265
+ ),
266
+ ),
267
+ const SizedBox(height: 5),
268
+ Text(
269
+ item.subtitle,
270
+ style: TextStyle(
271
+ color: Colors.white.withValues(alpha: 0.68),
272
+ fontSize: 14,
273
+ height: 1.45,
274
+ ),
275
+ ),
276
+ const SizedBox(height: 14),
277
+ _DownloadButton(item: item, isClicked: isClicked),
278
+ ],
279
+ ),
280
+ ),
281
+ const SizedBox(width: 12),
282
+ _StatusIndicator(connected: item.connected, color: item.accentColor),
283
+ ],
284
+ );
285
+
286
+ return OnboardingOptionCard(
287
+ selected: item.connected,
288
+ accent: item.accentColor,
289
+ compact: compact,
290
+ onTap: item.onTap,
291
+ child: cardContent,
292
+ );
293
+ }
294
+ }
295
+
296
+ class _StatusIndicator extends StatelessWidget {
297
+ const _StatusIndicator({required this.connected, required this.color});
298
+
299
+ final bool connected;
300
+ final Color color;
301
+
302
+ @override
303
+ Widget build(BuildContext context) {
304
+ return AnimatedSwitcher(
305
+ duration: const Duration(milliseconds: 220),
306
+ child: connected
307
+ ? Icon(
308
+ Icons.check_circle_rounded,
309
+ key: const ValueKey<String>('connected'),
310
+ color: color,
311
+ size: 28,
312
+ )
313
+ : Icon(
314
+ Icons.arrow_circle_down_rounded,
315
+ key: const ValueKey<String>('downloadable'),
316
+ color: Colors.white.withValues(alpha: 0.26),
317
+ size: 28,
318
+ ),
319
+ );
320
+ }
321
+ }
322
+
323
+ class _DownloadButton extends StatelessWidget {
324
+ const _DownloadButton({required this.item, required this.isClicked});
325
+
326
+ final _CompanionItemData item;
327
+ final bool isClicked;
328
+
329
+ @override
330
+ Widget build(BuildContext context) {
331
+ if (item.connected) {
332
+ return Container(
333
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
334
+ decoration: BoxDecoration(
335
+ color: item.accentColor.withValues(alpha: 0.16),
336
+ borderRadius: BorderRadius.circular(12),
337
+ border: Border.all(color: item.accentColor.withValues(alpha: 0.3)),
338
+ ),
339
+ child: Row(
340
+ mainAxisSize: MainAxisSize.min,
341
+ children: <Widget>[
342
+ Icon(Icons.done_all_rounded, size: 14, color: item.accentColor),
343
+ const SizedBox(width: 6),
344
+ Text(
345
+ 'Connected',
346
+ style: TextStyle(
347
+ color: item.accentColor,
348
+ fontSize: 12,
349
+ fontWeight: FontWeight.w700,
350
+ ),
351
+ ),
352
+ ],
353
+ ),
354
+ );
355
+ }
356
+
357
+ return Container(
358
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
359
+ decoration: BoxDecoration(
360
+ color: isClicked
361
+ ? Colors.white.withValues(alpha: 0.08)
362
+ : item.accentColor.withValues(alpha: 0.12),
363
+ borderRadius: BorderRadius.circular(12),
364
+ border: Border.all(
365
+ color: isClicked
366
+ ? Colors.white.withValues(alpha: 0.2)
367
+ : item.accentColor.withValues(alpha: 0.25),
368
+ ),
369
+ ),
370
+ child: Row(
371
+ mainAxisSize: MainAxisSize.min,
372
+ children: <Widget>[
373
+ Icon(
374
+ isClicked ? Icons.hourglass_empty_rounded : Icons.open_in_new_rounded,
375
+ size: 14,
376
+ color: isClicked ? Colors.white70 : item.accentColor,
377
+ ),
378
+ const SizedBox(width: 6),
379
+ Text(
380
+ isClicked ? 'Waiting for pairing...' : item.buttonText,
381
+ style: TextStyle(
382
+ color: isClicked ? Colors.white70 : Colors.white,
383
+ fontSize: 12,
384
+ fontWeight: FontWeight.w700,
385
+ ),
386
+ ),
387
+ ],
388
+ ),
389
+ );
390
+ }
391
+ }
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
2
2
  import '../../main.dart';
3
3
  import 'onboarding_video_step.dart';
4
4
  import 'onboarding_welcome_step.dart';
5
+ import 'onboarding_companion_step.dart';
5
6
  import 'onboarding_messaging_step.dart';
6
7
  import 'onboarding_model_step.dart';
7
8
 
@@ -45,6 +46,10 @@ class _OnboardingShellState extends State<OnboardingShell> {
45
46
  children: <Widget>[
46
47
  OnboardingVideoStep(onComplete: _nextStep),
47
48
  OnboardingWelcomeStep(onNext: _nextStep),
49
+ OnboardingCompanionStep(
50
+ onNext: _nextStep,
51
+ controller: widget.controller,
52
+ ),
48
53
  OnboardingMessagingStep(
49
54
  onNext: _nextStep,
50
55
  controller: widget.controller,
@@ -55,3 +60,4 @@ class _OnboardingShellState extends State<OnboardingShell> {
55
60
  );
56
61
  }
57
62
  }
63
+
@@ -11,7 +11,7 @@ class OnboardingWelcomeStep extends StatelessWidget {
11
11
  @override
12
12
  Widget build(BuildContext context) {
13
13
  return OnboardingScaffold(
14
- step: 1,
14
+ step: 0,
15
15
  totalSteps: 4,
16
16
  eyebrow: 'WELCOME',
17
17
  title: 'Welcome to\nNeoOS',
@@ -39,6 +39,7 @@ import 'src/messaging_access_summary.dart';
39
39
  import 'src/oauth_launcher.dart';
40
40
  import 'src/recording_bridge.dart';
41
41
  import 'src/recording_payloads.dart';
42
+ import 'src/stream_renderer.dart';
42
43
  import 'src/theme/palette.dart';
43
44
  import 'src/web_app_update_monitor.dart';
44
45
  import 'src/widget_bridge.dart';