neoagent 2.4.1-beta.13 → 2.4.1-beta.15
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/extensions/chrome-browser/background.mjs +26 -0
- package/extensions/chrome-browser/manifest.json +3 -3
- package/flutter_app/android/app/src/main/AndroidManifest.xml +2 -1
- package/flutter_app/lib/main_chat.dart +276 -83
- package/flutter_app/lib/main_controller.dart +12 -0
- package/flutter_app/lib/main_devices.dart +39 -0
- package/flutter_app/lib/main_integrations.dart +3 -1
- package/flutter_app/lib/main_unified.dart +29 -1
- package/flutter_app/lib/src/backend_client.dart +11 -0
- package/flutter_app/lib/src/desktop_companion_io.dart +23 -0
- package/flutter_app/lib/src/stream_renderer.dart +42 -3
- package/package.json +1 -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 +56334 -56015
- package/server/routes/voice_assistant.js +36 -1
- package/server/services/browser/extension/registry.js +46 -5
- package/server/services/desktop/registry.js +104 -1
- package/server/services/streaming/android-stream.js +39 -1
- package/server/utils/version.js +29 -19
- package/server/utils/version.test.js +39 -0
|
@@ -4,7 +4,8 @@ const { requireAuth } = require('../middleware/auth');
|
|
|
4
4
|
const { sanitizeError } = require('../utils/security');
|
|
5
5
|
const { getAgentIdFromRequest, resolveAgentId } = require('../services/agents/manager');
|
|
6
6
|
const { TurnCoordinator } = require('../services/voice/turnCoordinator');
|
|
7
|
-
const { normalizeVoiceSynthesisOptions } = require('../services/voice/providers');
|
|
7
|
+
const { normalizeVoiceSynthesisOptions, transcribeVoiceInput } = require('../services/voice/providers');
|
|
8
|
+
const { writeTempAudioFile, removeTempFile } = require('../services/voice/liveAudio');
|
|
8
9
|
const { runVoiceTranscriptTurn } = require('../services/voice/turnRunner');
|
|
9
10
|
|
|
10
11
|
const router = express.Router();
|
|
@@ -135,4 +136,38 @@ router.post('/respond', async (req, res) => {
|
|
|
135
136
|
}
|
|
136
137
|
});
|
|
137
138
|
|
|
139
|
+
router.post('/transcribe', async (req, res) => {
|
|
140
|
+
try {
|
|
141
|
+
const audioBase64 = String(req.body?.audioBase64 || '').trim();
|
|
142
|
+
const mimeType = String(req.body?.mimeType || 'audio/pcm;rate=16000;channels=1').trim();
|
|
143
|
+
|
|
144
|
+
if (!audioBase64) {
|
|
145
|
+
return res.status(400).json({ error: 'audioBase64 is required.' });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const approxBytes = (audioBase64.length * 3) / 4;
|
|
149
|
+
if (approxBytes > 25 * 1024 * 1024) {
|
|
150
|
+
return res.status(400).json({ error: 'Audio exceeds maximum size of 25MB.' });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const audioBytes = Buffer.from(audioBase64, 'base64');
|
|
154
|
+
const { filePath, mimeType: fileMimeType } = await writeTempAudioFile(audioBytes, mimeType);
|
|
155
|
+
let transcript = '';
|
|
156
|
+
try {
|
|
157
|
+
transcript = await transcribeVoiceInput(filePath, {
|
|
158
|
+
mimeType: fileMimeType,
|
|
159
|
+
userId: req.session.userId,
|
|
160
|
+
timeoutMs: 30000,
|
|
161
|
+
});
|
|
162
|
+
} finally {
|
|
163
|
+
await removeTempFile(filePath);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return res.json({ transcript: String(transcript || '').trim() });
|
|
167
|
+
} catch (err) {
|
|
168
|
+
const message = sanitizeError(err);
|
|
169
|
+
return res.status(500).json({ error: message });
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
138
173
|
module.exports = router;
|
|
@@ -10,6 +10,7 @@ const DEFAULT_PAIRING_TTL_MS = 10 * 60 * 1000;
|
|
|
10
10
|
const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 1000;
|
|
11
11
|
const DEFAULT_HEARTBEAT_INTERVAL_MS = 25 * 1000;
|
|
12
12
|
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 75 * 1000;
|
|
13
|
+
const DEFAULT_PRESENCE_TOUCH_INTERVAL_MS = 15 * 1000;
|
|
13
14
|
|
|
14
15
|
function sha256(value) {
|
|
15
16
|
return crypto.createHash('sha256').update(String(value || '')).digest('hex');
|
|
@@ -46,6 +47,7 @@ class BrowserExtensionRegistry {
|
|
|
46
47
|
this.pairingTtlMs = Number(options.pairingTtlMs || process.env.NEOAGENT_BROWSER_EXTENSION_PAIRING_TTL_MS || DEFAULT_PAIRING_TTL_MS);
|
|
47
48
|
this.heartbeatIntervalMs = Number(options.heartbeatIntervalMs || process.env.NEOAGENT_BROWSER_EXTENSION_HEARTBEAT_INTERVAL_MS || DEFAULT_HEARTBEAT_INTERVAL_MS);
|
|
48
49
|
this.heartbeatTimeoutMs = Number(options.heartbeatTimeoutMs || process.env.NEOAGENT_BROWSER_EXTENSION_HEARTBEAT_TIMEOUT_MS || DEFAULT_HEARTBEAT_TIMEOUT_MS);
|
|
50
|
+
this.presenceTouchIntervalMs = Number(options.presenceTouchIntervalMs || process.env.NEOAGENT_BROWSER_EXTENSION_PRESENCE_TOUCH_INTERVAL_MS || DEFAULT_PRESENCE_TOUCH_INTERVAL_MS);
|
|
49
51
|
this.connectionsByUser = new Map();
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -218,6 +220,7 @@ class BrowserExtensionRegistry {
|
|
|
218
220
|
timeoutMs: this.commandTimeoutMs,
|
|
219
221
|
heartbeatIntervalMs: this.heartbeatIntervalMs,
|
|
220
222
|
heartbeatTimeoutMs: this.heartbeatTimeoutMs,
|
|
223
|
+
presenceTouchIntervalMs: this.presenceTouchIntervalMs,
|
|
221
224
|
});
|
|
222
225
|
userMap.set(tokenRow.id, connection);
|
|
223
226
|
this.db.prepare(
|
|
@@ -315,6 +318,15 @@ class BrowserExtensionRegistry {
|
|
|
315
318
|
return Boolean(this.getConnection(userId, tokenId)?.isOpen());
|
|
316
319
|
}
|
|
317
320
|
|
|
321
|
+
touchPresence(userId, tokenId) {
|
|
322
|
+
const userMap = this.#getUserConnections(userId);
|
|
323
|
+
const connection = userMap?.get(String(tokenId));
|
|
324
|
+
if (!connection?.isOpen()) return;
|
|
325
|
+
this.db.prepare(
|
|
326
|
+
`UPDATE browser_extension_tokens SET last_seen_at = datetime('now') WHERE id = ?`
|
|
327
|
+
).run(tokenId);
|
|
328
|
+
}
|
|
329
|
+
|
|
318
330
|
async dispatch(userId, command, payload = {}, options = {}) {
|
|
319
331
|
const connection = this.getConnection(userId, options.tokenId || payload.tokenId || null);
|
|
320
332
|
if (!connection || !connection.isOpen()) {
|
|
@@ -329,10 +341,16 @@ class BrowserExtensionRegistry {
|
|
|
329
341
|
|
|
330
342
|
getStatus(userId) {
|
|
331
343
|
const userMap = this.#getUserConnections(userId);
|
|
332
|
-
|
|
333
|
-
|
|
344
|
+
let selectedTokenId = this.getSelectedTokenId(userId);
|
|
345
|
+
let connected = selectedTokenId
|
|
334
346
|
? userMap?.get(selectedTokenId)
|
|
335
|
-
:
|
|
347
|
+
: null;
|
|
348
|
+
if (!connected?.isOpen()) {
|
|
349
|
+
connected = this.getConnection(userId);
|
|
350
|
+
if (connected?.isOpen()) {
|
|
351
|
+
selectedTokenId = connected.tokenId;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
336
354
|
const effectiveSelectedTokenId = selectedTokenId || connected?.tokenId || null;
|
|
337
355
|
const tokens = this.db.prepare(
|
|
338
356
|
`SELECT id, name, status, last_connected_at, last_seen_at, revoked_at, created_at, metadata_json
|
|
@@ -400,7 +418,7 @@ class BrowserExtensionRegistry {
|
|
|
400
418
|
}
|
|
401
419
|
|
|
402
420
|
class ExtensionBrowserConnection {
|
|
403
|
-
constructor({ registry, ws, userId, tokenId, meta, timeoutMs, heartbeatIntervalMs, heartbeatTimeoutMs }) {
|
|
421
|
+
constructor({ registry, ws, userId, tokenId, meta, timeoutMs, heartbeatIntervalMs, heartbeatTimeoutMs, presenceTouchIntervalMs }) {
|
|
404
422
|
this.registry = registry;
|
|
405
423
|
this.ws = ws;
|
|
406
424
|
this.userId = userId;
|
|
@@ -409,13 +427,18 @@ class ExtensionBrowserConnection {
|
|
|
409
427
|
this.timeoutMs = timeoutMs;
|
|
410
428
|
this.heartbeatIntervalMs = heartbeatIntervalMs;
|
|
411
429
|
this.heartbeatTimeoutMs = heartbeatTimeoutMs;
|
|
430
|
+
this.presenceTouchIntervalMs = presenceTouchIntervalMs;
|
|
412
431
|
this.pending = new Map();
|
|
413
432
|
this.connectedAt = new Date().toISOString();
|
|
414
433
|
this.lastPongAt = Date.now();
|
|
434
|
+
this.lastPresenceTouchAt = 0;
|
|
415
435
|
this.heartbeatTimer = null;
|
|
416
436
|
|
|
417
437
|
ws.on('message', (data) => this.#handleMessage(data));
|
|
418
|
-
ws.on('pong', () => {
|
|
438
|
+
ws.on('pong', () => {
|
|
439
|
+
this.lastPongAt = Date.now();
|
|
440
|
+
this.touchPresence();
|
|
441
|
+
});
|
|
419
442
|
ws.on('close', () => this.#closePending(new ExtensionBrowserUnavailableError('Extension browser disconnected.')));
|
|
420
443
|
ws.on('error', (error) => this.#closePending(error));
|
|
421
444
|
this.#startHeartbeat();
|
|
@@ -435,6 +458,21 @@ class ExtensionBrowserConnection {
|
|
|
435
458
|
this.#closePending(new ExtensionBrowserUnavailableError('Extension browser disconnected.'));
|
|
436
459
|
}
|
|
437
460
|
|
|
461
|
+
touchPresence({ force = false } = {}) {
|
|
462
|
+
const now = Date.now();
|
|
463
|
+
const intervalMs = Number(this.presenceTouchIntervalMs);
|
|
464
|
+
if (
|
|
465
|
+
!force
|
|
466
|
+
&& Number.isFinite(intervalMs)
|
|
467
|
+
&& intervalMs > 0
|
|
468
|
+
&& now - this.lastPresenceTouchAt < intervalMs
|
|
469
|
+
) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
this.lastPresenceTouchAt = now;
|
|
473
|
+
this.registry.touchPresence(this.userId, this.tokenId);
|
|
474
|
+
}
|
|
475
|
+
|
|
438
476
|
sendCommand(command, payload = {}, options = {}) {
|
|
439
477
|
if (!this.isOpen()) {
|
|
440
478
|
return Promise.reject(new ExtensionBrowserUnavailableError());
|
|
@@ -460,6 +498,7 @@ class ExtensionBrowserConnection {
|
|
|
460
498
|
|
|
461
499
|
#handleMessage(data) {
|
|
462
500
|
this.lastPongAt = Date.now();
|
|
501
|
+
this.touchPresence();
|
|
463
502
|
let message;
|
|
464
503
|
try {
|
|
465
504
|
message = parseExtensionMessage(data);
|
|
@@ -497,6 +536,7 @@ class ExtensionBrowserConnection {
|
|
|
497
536
|
const intervalMs = Number(this.heartbeatIntervalMs);
|
|
498
537
|
const timeoutMs = Number(this.heartbeatTimeoutMs);
|
|
499
538
|
if (!Number.isFinite(intervalMs) || intervalMs <= 0) return;
|
|
539
|
+
this.touchPresence({ force: true });
|
|
500
540
|
this.heartbeatTimer = setInterval(() => {
|
|
501
541
|
if (!this.isOpen()) {
|
|
502
542
|
this.#closePending(new ExtensionBrowserUnavailableError('Extension browser disconnected.'));
|
|
@@ -507,6 +547,7 @@ class ExtensionBrowserConnection {
|
|
|
507
547
|
this.#closePending(new ExtensionBrowserUnavailableError('Extension browser heartbeat timed out.'));
|
|
508
548
|
return;
|
|
509
549
|
}
|
|
550
|
+
this.touchPresence();
|
|
510
551
|
try {
|
|
511
552
|
this.ws.ping();
|
|
512
553
|
} catch (error) {
|
|
@@ -10,6 +10,9 @@ const {
|
|
|
10
10
|
} = require('./protocol');
|
|
11
11
|
|
|
12
12
|
const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 1000;
|
|
13
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 25 * 1000;
|
|
14
|
+
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 75 * 1000;
|
|
15
|
+
const DEFAULT_PRESENCE_TOUCH_INTERVAL_MS = 15 * 1000;
|
|
13
16
|
|
|
14
17
|
function safeJson(value) {
|
|
15
18
|
try {
|
|
@@ -47,6 +50,21 @@ class DesktopCompanionRegistry {
|
|
|
47
50
|
|| process.env.NEOAGENT_DESKTOP_COMMAND_TIMEOUT_MS
|
|
48
51
|
|| DEFAULT_COMMAND_TIMEOUT_MS,
|
|
49
52
|
);
|
|
53
|
+
this.heartbeatIntervalMs = Number(
|
|
54
|
+
options.heartbeatIntervalMs
|
|
55
|
+
|| process.env.NEOAGENT_DESKTOP_HEARTBEAT_INTERVAL_MS
|
|
56
|
+
|| DEFAULT_HEARTBEAT_INTERVAL_MS,
|
|
57
|
+
);
|
|
58
|
+
this.heartbeatTimeoutMs = Number(
|
|
59
|
+
options.heartbeatTimeoutMs
|
|
60
|
+
|| process.env.NEOAGENT_DESKTOP_HEARTBEAT_TIMEOUT_MS
|
|
61
|
+
|| DEFAULT_HEARTBEAT_TIMEOUT_MS,
|
|
62
|
+
);
|
|
63
|
+
this.presenceTouchIntervalMs = Number(
|
|
64
|
+
options.presenceTouchIntervalMs
|
|
65
|
+
|| process.env.NEOAGENT_DESKTOP_PRESENCE_TOUCH_INTERVAL_MS
|
|
66
|
+
|| DEFAULT_PRESENCE_TOUCH_INTERVAL_MS,
|
|
67
|
+
);
|
|
50
68
|
this.connectionsByUser = new Map();
|
|
51
69
|
}
|
|
52
70
|
|
|
@@ -200,6 +218,9 @@ class DesktopCompanionRegistry {
|
|
|
200
218
|
platform: record.platform,
|
|
201
219
|
},
|
|
202
220
|
timeoutMs: this.commandTimeoutMs,
|
|
221
|
+
heartbeatIntervalMs: this.heartbeatIntervalMs,
|
|
222
|
+
heartbeatTimeoutMs: this.heartbeatTimeoutMs,
|
|
223
|
+
presenceTouchIntervalMs: this.presenceTouchIntervalMs,
|
|
203
224
|
});
|
|
204
225
|
// Install the new connection in the map BEFORE closing the old one.
|
|
205
226
|
// This ensures that when the old socket's async 'close' event fires and
|
|
@@ -275,6 +296,19 @@ class DesktopCompanionRegistry {
|
|
|
275
296
|
return this.getDeviceRecordByDeviceId(userId, deviceId);
|
|
276
297
|
}
|
|
277
298
|
|
|
299
|
+
touchPresence(userId, deviceId) {
|
|
300
|
+
const userMap = this._getUserMap(userId);
|
|
301
|
+
const connection = userMap?.get(String(deviceId));
|
|
302
|
+
if (!connection?.isOpen()) return;
|
|
303
|
+
this.db.prepare(
|
|
304
|
+
`UPDATE desktop_companion_devices
|
|
305
|
+
SET status = 'online',
|
|
306
|
+
last_seen_at = datetime('now'),
|
|
307
|
+
updated_at = datetime('now')
|
|
308
|
+
WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL`
|
|
309
|
+
).run(userId, deviceId);
|
|
310
|
+
}
|
|
311
|
+
|
|
278
312
|
isConnected(userId) {
|
|
279
313
|
const userMap = this._getUserMap(userId);
|
|
280
314
|
return userMap != null && userMap.size > 0;
|
|
@@ -493,7 +527,19 @@ class DesktopCompanionRegistry {
|
|
|
493
527
|
}
|
|
494
528
|
|
|
495
529
|
class DesktopCompanionConnection {
|
|
496
|
-
constructor({
|
|
530
|
+
constructor({
|
|
531
|
+
registry,
|
|
532
|
+
ws,
|
|
533
|
+
userId,
|
|
534
|
+
sessionId,
|
|
535
|
+
deviceId,
|
|
536
|
+
recordId,
|
|
537
|
+
meta,
|
|
538
|
+
timeoutMs,
|
|
539
|
+
heartbeatIntervalMs,
|
|
540
|
+
heartbeatTimeoutMs,
|
|
541
|
+
presenceTouchIntervalMs,
|
|
542
|
+
}) {
|
|
497
543
|
this.registry = registry;
|
|
498
544
|
this.ws = ws;
|
|
499
545
|
this.userId = userId;
|
|
@@ -502,11 +548,22 @@ class DesktopCompanionConnection {
|
|
|
502
548
|
this.recordId = recordId;
|
|
503
549
|
this.meta = meta || {};
|
|
504
550
|
this.timeoutMs = timeoutMs;
|
|
551
|
+
this.heartbeatIntervalMs = heartbeatIntervalMs;
|
|
552
|
+
this.heartbeatTimeoutMs = heartbeatTimeoutMs;
|
|
553
|
+
this.presenceTouchIntervalMs = presenceTouchIntervalMs;
|
|
505
554
|
this.pending = new Map();
|
|
555
|
+
this.lastPongAt = Date.now();
|
|
556
|
+
this.lastPresenceTouchAt = 0;
|
|
557
|
+
this.heartbeatTimer = null;
|
|
506
558
|
|
|
507
559
|
ws.on('message', (data) => this._handleMessage(data));
|
|
560
|
+
ws.on('pong', () => {
|
|
561
|
+
this.lastPongAt = Date.now();
|
|
562
|
+
this.touchPresence();
|
|
563
|
+
});
|
|
508
564
|
ws.on('close', () => this._closePending(new DesktopCompanionUnavailableError('Desktop companion disconnected.')));
|
|
509
565
|
ws.on('error', (error) => this._closePending(error));
|
|
566
|
+
this._startHeartbeat();
|
|
510
567
|
}
|
|
511
568
|
|
|
512
569
|
isOpen() {
|
|
@@ -525,6 +582,21 @@ class DesktopCompanionConnection {
|
|
|
525
582
|
this._closePending(new DesktopCompanionUnavailableError('Desktop companion disconnected.'));
|
|
526
583
|
}
|
|
527
584
|
|
|
585
|
+
touchPresence({ force = false } = {}) {
|
|
586
|
+
const now = Date.now();
|
|
587
|
+
const intervalMs = Number(this.presenceTouchIntervalMs);
|
|
588
|
+
if (
|
|
589
|
+
!force
|
|
590
|
+
&& Number.isFinite(intervalMs)
|
|
591
|
+
&& intervalMs > 0
|
|
592
|
+
&& now - this.lastPresenceTouchAt < intervalMs
|
|
593
|
+
) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
this.lastPresenceTouchAt = now;
|
|
597
|
+
this.registry.touchPresence(this.userId, this.deviceId);
|
|
598
|
+
}
|
|
599
|
+
|
|
528
600
|
sendCommand(command, payload = {}, options = {}) {
|
|
529
601
|
if (!this.isOpen()) {
|
|
530
602
|
return Promise.reject(new DesktopCompanionUnavailableError());
|
|
@@ -549,6 +621,8 @@ class DesktopCompanionConnection {
|
|
|
549
621
|
}
|
|
550
622
|
|
|
551
623
|
_handleMessage(data) {
|
|
624
|
+
this.lastPongAt = Date.now();
|
|
625
|
+
this.touchPresence();
|
|
552
626
|
if (Buffer.isBuffer(data) && data.length > 0 && data[0] === FRAME_TYPE_VIDEO) {
|
|
553
627
|
return;
|
|
554
628
|
}
|
|
@@ -585,6 +659,10 @@ class DesktopCompanionConnection {
|
|
|
585
659
|
}
|
|
586
660
|
|
|
587
661
|
_closePending(error) {
|
|
662
|
+
if (this.heartbeatTimer) {
|
|
663
|
+
clearInterval(this.heartbeatTimer);
|
|
664
|
+
this.heartbeatTimer = null;
|
|
665
|
+
}
|
|
588
666
|
for (const pending of this.pending.values()) {
|
|
589
667
|
clearTimeout(pending.timer);
|
|
590
668
|
pending.reject(error);
|
|
@@ -596,6 +674,31 @@ class DesktopCompanionConnection {
|
|
|
596
674
|
// mark the device offline.
|
|
597
675
|
this.registry.unregisterConnection(this);
|
|
598
676
|
}
|
|
677
|
+
|
|
678
|
+
_startHeartbeat() {
|
|
679
|
+
const intervalMs = Number(this.heartbeatIntervalMs);
|
|
680
|
+
const timeoutMs = Number(this.heartbeatTimeoutMs);
|
|
681
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) return;
|
|
682
|
+
this.touchPresence({ force: true });
|
|
683
|
+
this.heartbeatTimer = setInterval(() => {
|
|
684
|
+
if (!this.isOpen()) {
|
|
685
|
+
this._closePending(new DesktopCompanionUnavailableError('Desktop companion disconnected.'));
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
if (Number.isFinite(timeoutMs) && timeoutMs > 0 && Date.now() - this.lastPongAt > timeoutMs) {
|
|
689
|
+
try { this.ws.terminate(); } catch {}
|
|
690
|
+
this._closePending(new DesktopCompanionUnavailableError('Desktop companion heartbeat timed out.'));
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
this.touchPresence();
|
|
694
|
+
try {
|
|
695
|
+
this.ws.ping();
|
|
696
|
+
} catch (error) {
|
|
697
|
+
this._closePending(error);
|
|
698
|
+
}
|
|
699
|
+
}, intervalMs);
|
|
700
|
+
this.heartbeatTimer.unref?.();
|
|
701
|
+
}
|
|
599
702
|
}
|
|
600
703
|
|
|
601
704
|
module.exports = {
|
|
@@ -5,6 +5,18 @@ const { spawn } = require('child_process');
|
|
|
5
5
|
const { EventEmitter } = require('events');
|
|
6
6
|
const sharp = require('sharp');
|
|
7
7
|
|
|
8
|
+
let H264_FIRST_FRAME_TIMEOUT_MS = 6_000;
|
|
9
|
+
const envTimeout = process.env.NEOAGENT_ANDROID_STREAM_FIRST_FRAME_TIMEOUT_MS;
|
|
10
|
+
|
|
11
|
+
if (envTimeout) {
|
|
12
|
+
const parsed = parseInt(envTimeout, 10);
|
|
13
|
+
if (!isNaN(parsed) && parsed > 0) {
|
|
14
|
+
H264_FIRST_FRAME_TIMEOUT_MS = parsed;
|
|
15
|
+
} else {
|
|
16
|
+
console.warn('[AndroidStream] Invalid NEOAGENT_ANDROID_STREAM_FIRST_FRAME_TIMEOUT_MS; using default 6000ms');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
// Derive the full path to the `adb` binary the same way the Android controller does.
|
|
9
21
|
function resolveAdbBin(sdkDir) {
|
|
10
22
|
if (sdkDir) {
|
|
@@ -104,6 +116,7 @@ class AndroidStream {
|
|
|
104
116
|
this._adbProc = null;
|
|
105
117
|
this._ffmpegProc = null;
|
|
106
118
|
this._restartTimer = null;
|
|
119
|
+
this._h264StartupTimer = null;
|
|
107
120
|
this._seq = 0;
|
|
108
121
|
this._lastErrorLogAt = 0;
|
|
109
122
|
this._usePollingFallback = false; // set true when H.264 fails to start
|
|
@@ -181,6 +194,7 @@ class AndroidStream {
|
|
|
181
194
|
this._ffmpegProc.stdout.on('data', (chunk) => parser.push(chunk));
|
|
182
195
|
parser.on('frame', (jpeg) => {
|
|
183
196
|
if (this._stopped) return;
|
|
197
|
+
this._clearH264StartupTimer();
|
|
184
198
|
this.streamHub.handleFrame(this.userId, this.deviceId, {
|
|
185
199
|
jpeg,
|
|
186
200
|
platform: 'android',
|
|
@@ -197,6 +211,15 @@ class AndroidStream {
|
|
|
197
211
|
this._logError('ffmpeg process error', err);
|
|
198
212
|
});
|
|
199
213
|
|
|
214
|
+
if (H264_FIRST_FRAME_TIMEOUT_MS > 0) {
|
|
215
|
+
this._h264StartupTimer = setTimeout(() => {
|
|
216
|
+
if (this._stopped || this._usePollingFallback || this._seq > 0) return;
|
|
217
|
+
this._logError('H.264 stream produced no frames before timeout — using screencap fallback');
|
|
218
|
+
this._fallback();
|
|
219
|
+
}, H264_FIRST_FRAME_TIMEOUT_MS);
|
|
220
|
+
this._h264StartupTimer.unref?.();
|
|
221
|
+
}
|
|
222
|
+
|
|
200
223
|
// Android screenrecord's hard limit is 180 s — restart at 170 s so there
|
|
201
224
|
// is no gap in the stream.
|
|
202
225
|
this._restartTimer = setTimeout(() => {
|
|
@@ -209,7 +232,7 @@ class AndroidStream {
|
|
|
209
232
|
|
|
210
233
|
// If adb exits early (e.g. emulator restart), attempt recovery.
|
|
211
234
|
this._adbProc.on('close', (code) => {
|
|
212
|
-
if (this._stopped) return;
|
|
235
|
+
if (this._stopped || this._usePollingFallback) return;
|
|
213
236
|
// If it exited immediately with a bad code, the device likely does not
|
|
214
237
|
// support stdout screenrecord — fall back to screencap polling.
|
|
215
238
|
if (code !== 0 && this._seq === 0) {
|
|
@@ -222,9 +245,18 @@ class AndroidStream {
|
|
|
222
245
|
this._killProcesses();
|
|
223
246
|
this._scheduleRestart(2000, () => this._launchH264());
|
|
224
247
|
});
|
|
248
|
+
|
|
249
|
+
this._ffmpegProc.on('close', (code) => {
|
|
250
|
+
if (this._stopped || this._usePollingFallback) return;
|
|
251
|
+
if (this._seq === 0) {
|
|
252
|
+
this._logError(`ffmpeg exited (code ${code}) before producing any frames — using screencap fallback`);
|
|
253
|
+
this._fallback();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
225
256
|
}
|
|
226
257
|
|
|
227
258
|
_fallback() {
|
|
259
|
+
this._killProcesses();
|
|
228
260
|
this._usePollingFallback = true;
|
|
229
261
|
if (!this._stopped) this._startPollingFallback();
|
|
230
262
|
}
|
|
@@ -275,6 +307,7 @@ class AndroidStream {
|
|
|
275
307
|
_killProcesses() {
|
|
276
308
|
clearTimeout(this._restartTimer);
|
|
277
309
|
this._restartTimer = null;
|
|
310
|
+
this._clearH264StartupTimer();
|
|
278
311
|
// Kill ffmpeg first so it stops reading; then kill adb.
|
|
279
312
|
try { this._ffmpegProc?.kill('SIGTERM'); } catch {}
|
|
280
313
|
try { this._adbProc?.kill('SIGTERM'); } catch {}
|
|
@@ -282,6 +315,11 @@ class AndroidStream {
|
|
|
282
315
|
this._adbProc = null;
|
|
283
316
|
}
|
|
284
317
|
|
|
318
|
+
_clearH264StartupTimer() {
|
|
319
|
+
clearTimeout(this._h264StartupTimer);
|
|
320
|
+
this._h264StartupTimer = null;
|
|
321
|
+
}
|
|
322
|
+
|
|
285
323
|
_logError(msg, err) {
|
|
286
324
|
const now = Date.now();
|
|
287
325
|
if (now - this._lastErrorLogAt > 10_000) {
|
package/server/utils/version.js
CHANGED
|
@@ -12,6 +12,11 @@ const {
|
|
|
12
12
|
const { getDeploymentInfo } = require('./deployment');
|
|
13
13
|
|
|
14
14
|
const PACKAGE_JSON_PATH = path.join(APP_DIR, 'package.json');
|
|
15
|
+
const GIT_COMMAND_TIMEOUT_MS = 750;
|
|
16
|
+
const VERSION_CACHE_TTL_MS = 30 * 1000;
|
|
17
|
+
|
|
18
|
+
let cachedVersionInfo = null;
|
|
19
|
+
let cachedVersionInfoAt = 0;
|
|
15
20
|
|
|
16
21
|
function readPackageVersion() {
|
|
17
22
|
try {
|
|
@@ -22,7 +27,16 @@ function readPackageVersion() {
|
|
|
22
27
|
}
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
function
|
|
30
|
+
function readGitValue(command) {
|
|
31
|
+
return execSync(command, {
|
|
32
|
+
cwd: APP_DIR,
|
|
33
|
+
encoding: 'utf8',
|
|
34
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
35
|
+
timeout: GIT_COMMAND_TIMEOUT_MS,
|
|
36
|
+
}).trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function buildVersionInfo() {
|
|
26
40
|
const packageVersion = readPackageVersion() || '0.0.0';
|
|
27
41
|
const releaseChannel = readConfiguredReleaseChannel();
|
|
28
42
|
const deployment = getDeploymentInfo();
|
|
@@ -32,24 +46,9 @@ function getVersionInfo() {
|
|
|
32
46
|
let gitBranch = null;
|
|
33
47
|
|
|
34
48
|
try {
|
|
35
|
-
gitVersion =
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
encoding: 'utf8',
|
|
39
|
-
stdio: ['ignore', 'pipe', 'ignore']
|
|
40
|
-
})
|
|
41
|
-
.trim()
|
|
42
|
-
.replace(/^v/, '') || null;
|
|
43
|
-
gitSha = execSync('git rev-parse --short HEAD', {
|
|
44
|
-
cwd: APP_DIR,
|
|
45
|
-
encoding: 'utf8',
|
|
46
|
-
stdio: ['ignore', 'pipe', 'ignore']
|
|
47
|
-
}).trim();
|
|
48
|
-
gitBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
49
|
-
cwd: APP_DIR,
|
|
50
|
-
encoding: 'utf8',
|
|
51
|
-
stdio: ['ignore', 'pipe', 'ignore']
|
|
52
|
-
}).trim();
|
|
49
|
+
gitVersion = readGitValue('git describe --tags --always --dirty').replace(/^v/, '') || null;
|
|
50
|
+
gitSha = readGitValue('git rev-parse --short HEAD') || null;
|
|
51
|
+
gitBranch = readGitValue('git rev-parse --abbrev-ref HEAD') || null;
|
|
53
52
|
} catch {
|
|
54
53
|
gitSha = process.env.GIT_SHA || null;
|
|
55
54
|
}
|
|
@@ -80,4 +79,15 @@ function getVersionInfo() {
|
|
|
80
79
|
};
|
|
81
80
|
}
|
|
82
81
|
|
|
82
|
+
function getVersionInfo() {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
if (cachedVersionInfo && now - cachedVersionInfoAt < VERSION_CACHE_TTL_MS) {
|
|
85
|
+
return { ...cachedVersionInfo };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
cachedVersionInfo = buildVersionInfo();
|
|
89
|
+
cachedVersionInfoAt = now;
|
|
90
|
+
return { ...cachedVersionInfo };
|
|
91
|
+
}
|
|
92
|
+
|
|
83
93
|
module.exports = { getVersionInfo };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const { test } = require('node:test');
|
|
5
|
+
const childProcess = require('child_process');
|
|
6
|
+
|
|
7
|
+
const VERSION_MODULE_PATH = require.resolve('./version');
|
|
8
|
+
|
|
9
|
+
function loadVersionModuleWithExecSync(execSync) {
|
|
10
|
+
const originalExecSync = childProcess.execSync;
|
|
11
|
+
delete require.cache[VERSION_MODULE_PATH];
|
|
12
|
+
childProcess.execSync = execSync;
|
|
13
|
+
try {
|
|
14
|
+
return require('./version');
|
|
15
|
+
} finally {
|
|
16
|
+
childProcess.execSync = originalExecSync;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('getVersionInfo caches git metadata reads', () => {
|
|
21
|
+
let callCount = 0;
|
|
22
|
+
const { getVersionInfo } = loadVersionModuleWithExecSync((command) => {
|
|
23
|
+
callCount += 1;
|
|
24
|
+
if (command.includes('describe')) return 'v1.2.3\n';
|
|
25
|
+
if (command.includes('rev-parse --short')) return 'abc123\n';
|
|
26
|
+
if (command.includes('abbrev-ref')) return 'main\n';
|
|
27
|
+
throw new Error(`unexpected command: ${command}`);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const first = getVersionInfo();
|
|
31
|
+
const second = getVersionInfo();
|
|
32
|
+
|
|
33
|
+
assert.equal(first.gitVersion, '1.2.3');
|
|
34
|
+
assert.equal(first.gitSha, 'abc123');
|
|
35
|
+
assert.equal(first.gitBranch, 'main');
|
|
36
|
+
assert.equal(second.gitVersion, '1.2.3');
|
|
37
|
+
assert.equal(callCount, 3);
|
|
38
|
+
});
|
|
39
|
+
|