neoagent 2.4.1-beta.9 → 2.4.1
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/.env.example +33 -3
- package/LICENSE +111 -56
- package/README.md +8 -3
- package/docs/configuration.md +8 -0
- package/docs/getting-started.md +9 -3
- package/docs/index.md +4 -0
- package/extensions/chrome-browser/background.mjs +36 -4
- 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 +39 -8
- package/extensions/chrome-browser/manifest.json +3 -3
- package/extensions/chrome-browser/popup.html +5 -1
- package/extensions/chrome-browser/popup.js +13 -0
- package/flutter_app/android/app/src/main/AndroidManifest.xml +2 -1
- package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/flutter_app/assets/branding/app_icon_1024.png +0 -0
- package/flutter_app/assets/branding/app_icon_128.png +0 -0
- package/flutter_app/assets/branding/app_icon_192.png +0 -0
- package/flutter_app/assets/branding/app_icon_256.png +0 -0
- package/flutter_app/assets/branding/app_icon_32.png +0 -0
- package/flutter_app/assets/branding/app_icon_512.png +0 -0
- package/flutter_app/assets/branding/app_icon_64.png +0 -0
- package/flutter_app/assets/branding/app_icon_light_1024.png +0 -0
- package/flutter_app/assets/branding/app_icon_light_128.png +0 -0
- package/flutter_app/assets/branding/app_icon_light_192.png +0 -0
- package/flutter_app/assets/branding/app_icon_light_256.png +0 -0
- package/flutter_app/assets/branding/app_icon_light_32.png +0 -0
- package/flutter_app/assets/branding/app_icon_light_512.png +0 -0
- package/flutter_app/assets/branding/app_icon_light_64.png +0 -0
- package/flutter_app/assets/branding/onboarding_intro.mp4 +0 -0
- package/flutter_app/assets/branding/tray_icon_light_template.png +0 -0
- package/flutter_app/assets/branding/tray_icon_template.png +0 -0
- package/flutter_app/lib/features/location/location_service.dart +3 -0
- package/flutter_app/lib/features/onboarding/onboarding_chrome.dart +391 -382
- package/flutter_app/lib/features/onboarding/onboarding_companion_step.dart +743 -0
- package/flutter_app/lib/features/onboarding/onboarding_messaging_step.dart +18 -16
- package/flutter_app/lib/features/onboarding/onboarding_model_step.dart +19 -18
- package/flutter_app/lib/features/onboarding/onboarding_shell.dart +8 -1
- package/flutter_app/lib/features/onboarding/onboarding_video_step.dart +16 -13
- package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +17 -13
- package/flutter_app/lib/main.dart +2 -0
- package/flutter_app/lib/main_account_settings.dart +10 -34
- package/flutter_app/lib/main_admin.dart +14 -1
- package/flutter_app/lib/main_app_shell.dart +377 -340
- package/flutter_app/lib/main_chat.dart +707 -227
- package/flutter_app/lib/main_controller.dart +197 -42
- package/flutter_app/lib/main_devices.dart +436 -4
- package/flutter_app/lib/main_integrations.dart +255 -6
- package/flutter_app/lib/main_launcher.dart +1 -1
- package/flutter_app/lib/main_model_picker.dart +685 -0
- package/flutter_app/lib/main_models.dart +212 -0
- package/flutter_app/lib/main_navigation.dart +1 -9
- package/flutter_app/lib/main_operations.dart +1417 -614
- package/flutter_app/lib/main_settings.dart +673 -871
- package/flutter_app/lib/main_shared.dart +971 -443
- package/flutter_app/lib/main_spacing.dart +4 -4
- package/flutter_app/lib/main_theme.dart +22 -14
- package/flutter_app/lib/main_unified.dart +5 -72
- package/flutter_app/lib/src/android_apk_drop_zone.dart +24 -0
- package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +13 -0
- package/flutter_app/lib/src/android_apk_drop_zone_web.dart +219 -0
- package/flutter_app/lib/src/backend_client.dart +46 -0
- package/flutter_app/lib/src/desktop_companion_actions.dart +30 -7
- package/flutter_app/lib/src/desktop_companion_io.dart +71 -1
- package/flutter_app/lib/src/security/password_strength.dart +84 -0
- package/flutter_app/lib/src/stream_renderer.dart +42 -3
- package/flutter_app/lib/src/theme/palette.dart +76 -34
- package/flutter_app/linux/runner/resources/app_icon.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
- package/flutter_app/pubspec.lock +2 -2
- package/flutter_app/pubspec.yaml +7 -1
- package/flutter_app/tool/branding_source/neoagent-icon-1024.png +0 -0
- package/flutter_app/tool/branding_source/neoagent-icon-128.png +0 -0
- package/flutter_app/tool/branding_source/neoagent-icon-16.png +0 -0
- package/flutter_app/tool/branding_source/neoagent-icon-180.png +0 -0
- package/flutter_app/tool/branding_source/neoagent-icon-192.png +0 -0
- package/flutter_app/tool/branding_source/neoagent-icon-256.png +0 -0
- package/flutter_app/tool/branding_source/neoagent-icon-32.png +0 -0
- package/flutter_app/tool/branding_source/neoagent-icon-48.png +0 -0
- package/flutter_app/tool/branding_source/neoagent-icon-512.png +0 -0
- package/flutter_app/tool/branding_source/neoagent-icon-64.png +0 -0
- package/flutter_app/tool/branding_source/neoagent-icon.svg +43 -0
- package/flutter_app/tool/generate_desktop_branding.py +154 -152
- package/flutter_app/web/favicon.png +0 -0
- package/flutter_app/web/favicon.svg +40 -9
- package/flutter_app/web/favicon_light.svg +43 -0
- package/flutter_app/web/icons/Icon-192-light.png +0 -0
- package/flutter_app/web/icons/Icon-192.png +0 -0
- package/flutter_app/web/icons/Icon-512-light.png +0 -0
- package/flutter_app/web/icons/Icon-512.png +0 -0
- package/flutter_app/web/icons/Icon-maskable-192-light.png +0 -0
- package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
- package/flutter_app/web/icons/Icon-maskable-512-light.png +0 -0
- package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
- package/flutter_app/windows/runner/main.cpp +7 -1
- package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
- package/lib/manager.js +445 -83
- package/package.json +17 -3
- package/runtime/paths.js +3 -1
- package/server/admin/access.js +198 -0
- package/server/admin/admin.css +268 -0
- package/server/admin/admin.js +348 -0
- package/server/admin/analytics.js +128 -0
- package/server/admin/index.html +1015 -0
- package/server/admin/login.html +290 -0
- package/server/admin/logo.svg +43 -0
- package/server/admin/sql.js +134 -0
- package/server/admin/users.js +147 -0
- package/server/config/origins.js +3 -1
- package/server/db/database.js +92 -0
- package/server/db/ftsQuery.js +27 -0
- package/server/http/routes.js +1 -0
- package/server/http/static.js +23 -6
- package/server/index.js +1 -1
- package/server/middleware/adminAuth.js +48 -0
- package/server/middleware/auth.js +1 -40
- package/server/public/.last_build_id +1 -1
- package/server/public/app_icon.png +0 -0
- package/server/public/assets/AssetManifest.bin +1 -1
- package/server/public/assets/AssetManifest.bin.json +1 -1
- package/server/public/assets/assets/branding/app_icon_1024.png +0 -0
- package/server/public/assets/assets/branding/app_icon_256.png +0 -0
- package/server/public/assets/assets/branding/app_icon_512.png +0 -0
- package/server/public/assets/assets/branding/app_icon_light_1024.png +0 -0
- package/server/public/assets/assets/branding/app_icon_light_256.png +0 -0
- package/server/public/assets/assets/branding/app_icon_light_512.png +0 -0
- package/server/public/assets/assets/branding/onboarding_intro.mp4 +0 -0
- package/server/public/assets/assets/branding/tray_icon_light_template.png +0 -0
- package/server/public/assets/assets/branding/tray_icon_template.png +0 -0
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/assets/web/icons/Icon-192.png +0 -0
- package/server/public/favicon.png +0 -0
- package/server/public/favicon.svg +40 -9
- package/server/public/favicon_light.svg +43 -0
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/icons/Icon-192-light.png +0 -0
- package/server/public/icons/Icon-192.png +0 -0
- package/server/public/icons/Icon-512-light.png +0 -0
- package/server/public/icons/Icon-512.png +0 -0
- package/server/public/icons/Icon-maskable-192-light.png +0 -0
- package/server/public/icons/Icon-maskable-192.png +0 -0
- package/server/public/icons/Icon-maskable-512-light.png +0 -0
- package/server/public/icons/Icon-maskable-512.png +0 -0
- package/server/public/main.dart.js +88537 -85936
- package/server/routes/admin.js +632 -0
- package/server/routes/agent_profiles.js +3 -0
- package/server/routes/agents.js +1 -1
- package/server/routes/auth.js +3 -1
- package/server/routes/mcp.js +29 -13
- package/server/routes/memory.js +23 -1
- package/server/routes/messaging.js +2 -0
- package/server/routes/screenHistory.js +14 -8
- package/server/routes/settings.js +10 -2
- package/server/routes/stream.js +1 -1
- package/server/routes/triggers.js +4 -4
- package/server/routes/voice_assistant.js +36 -1
- package/server/routes/workspace.js +86 -0
- package/server/services/account/admin_two_factor.js +132 -0
- package/server/services/account/password_policy.js +6 -1
- package/server/services/ai/completion.js +44 -0
- package/server/services/ai/engine.js +321 -500
- package/server/services/ai/imageAnalysis.js +9 -5
- package/server/services/ai/logFormat.js +46 -0
- package/server/services/ai/loopPolicy.js +11 -0
- package/server/services/ai/messagingFallback.js +228 -0
- package/server/services/ai/models.js +194 -55
- package/server/services/ai/providerRetry.js +169 -0
- package/server/services/ai/providers/anthropic.js +15 -4
- package/server/services/ai/providers/google.js +38 -11
- package/server/services/ai/providers/grok.js +19 -68
- package/server/services/ai/providers/grokOauth.js +141 -0
- package/server/services/ai/providers/nvidia.js +154 -0
- package/server/services/ai/providers/ollama.js +76 -39
- package/server/services/ai/providers/openai.js +20 -31
- package/server/services/ai/providers/openaiCodex.js +6 -2
- package/server/services/ai/providers/openaiCompatible.js +70 -0
- package/server/services/ai/providers/openrouter.js +162 -0
- package/server/services/ai/settings.js +30 -0
- package/server/services/ai/systemPrompt.js +51 -29
- package/server/services/ai/taskAnalysis.js +60 -1
- package/server/services/ai/toolEvidence.js +207 -0
- package/server/services/ai/toolSelector.js +8 -1
- package/server/services/ai/tools.js +78 -13
- package/server/services/browser/extension/registry.js +94 -9
- package/server/services/desktop/registry.js +104 -1
- package/server/services/desktop/screenRecorder.js +208 -98
- package/server/services/desktop/screen_recorder_support.js +46 -0
- package/server/services/integrations/google/provider.js +13 -0
- package/server/services/integrations/home_assistant/constants.js +88 -0
- package/server/services/integrations/home_assistant/network.js +207 -0
- package/server/services/integrations/home_assistant/provider.js +249 -0
- package/server/services/integrations/home_assistant/snapshot.js +98 -0
- package/server/services/integrations/home_assistant/tools.js +101 -0
- package/server/services/integrations/manager.js +5 -0
- package/server/services/integrations/registry.js +2 -0
- package/server/services/integrations/trello/provider.js +8 -2
- package/server/services/manager.js +51 -53
- package/server/services/mcp/client.js +208 -268
- package/server/services/mcp/client_support.js +172 -0
- package/server/services/mcp/recovery.js +116 -0
- package/server/services/mcp/tool_operations.js +123 -0
- package/server/services/memory/ingestion.js +185 -370
- package/server/services/memory/ingestion_coverage.js +129 -0
- package/server/services/memory/ingestion_documents.js +123 -0
- package/server/services/memory/ingestion_support.js +171 -0
- package/server/services/memory/intelligence.js +181 -0
- package/server/services/memory/manager.js +476 -32
- package/server/services/messaging/automation.js +71 -134
- package/server/services/messaging/formatting_guides.js +26 -1
- package/server/services/messaging/inbound_queue.js +111 -0
- package/server/services/messaging/manager.js +25 -8
- package/server/services/messaging/typing_keepalive.js +110 -0
- package/server/services/streaming/android-stream.js +293 -40
- package/server/services/tasks/integration_runtime.js +4 -1
- package/server/services/tasks/runtime.js +415 -56
- package/server/services/tasks/task_repository.js +48 -6
- package/server/services/websocket.js +8 -2
- package/server/services/widgets/service.js +17 -1
- package/server/services/workspace/manager.js +116 -0
- package/server/utils/logger.js +44 -6
- package/server/utils/security.js +3 -0
- package/server/utils/version.js +29 -19
- package/server/services/memory/openhuman_uplift.test.js +0 -98
|
@@ -1,60 +1,200 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { spawn } = require('child_process');
|
|
5
|
+
const { EventEmitter } = require('events');
|
|
3
6
|
const sharp = require('sharp');
|
|
4
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
|
+
|
|
20
|
+
// Derive the full path to the `adb` binary the same way the Android controller does.
|
|
21
|
+
function resolveAdbBin(sdkDir) {
|
|
22
|
+
if (sdkDir) {
|
|
23
|
+
return path.join(sdkDir, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
|
|
24
|
+
}
|
|
25
|
+
return process.platform === 'win32' ? 'adb.exe' : 'adb'; // fall back to PATH
|
|
26
|
+
}
|
|
27
|
+
|
|
5
28
|
function clampInt(value, fallback, min, max) {
|
|
6
29
|
const parsed = Number(value);
|
|
7
30
|
if (!Number.isFinite(parsed)) return fallback;
|
|
8
31
|
return Math.min(max, Math.max(min, Math.floor(parsed)));
|
|
9
32
|
}
|
|
10
33
|
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// JpegFrameParser — extracts complete JPEG images from a raw byte stream.
|
|
36
|
+
// ffmpeg writes MJPEG to stdout as a continuous stream of JPEG frames, each
|
|
37
|
+
// delimited by SOI (FF D8) and EOI (FF D9) markers. We buffer incoming
|
|
38
|
+
// chunks and emit a 'frame' event for every complete JPEG found.
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
class JpegFrameParser extends EventEmitter {
|
|
41
|
+
constructor() {
|
|
42
|
+
super();
|
|
43
|
+
this._buf = null; // Buffer | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
push(chunk) {
|
|
47
|
+
if (!chunk?.length) return;
|
|
48
|
+
this._buf = this._buf ? Buffer.concat([this._buf, chunk]) : Buffer.from(chunk);
|
|
49
|
+
let start = 0;
|
|
50
|
+
|
|
51
|
+
while (start < this._buf.length - 1) {
|
|
52
|
+
// Locate SOI marker (0xFF 0xD8).
|
|
53
|
+
let soiIdx = -1;
|
|
54
|
+
for (let i = start; i < this._buf.length - 1; i++) {
|
|
55
|
+
if (this._buf[i] === 0xff && this._buf[i + 1] === 0xd8) {
|
|
56
|
+
soiIdx = i;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (soiIdx === -1) {
|
|
61
|
+
// No SOI found – discard everything.
|
|
62
|
+
this._buf = null;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Locate EOI marker (0xFF 0xD9) that comes after SOI.
|
|
67
|
+
let eoiIdx = -1;
|
|
68
|
+
for (let i = soiIdx + 2; i < this._buf.length - 1; i++) {
|
|
69
|
+
if (this._buf[i] === 0xff && this._buf[i + 1] === 0xd9) {
|
|
70
|
+
eoiIdx = i;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (eoiIdx === -1) {
|
|
75
|
+
// SOI found but no matching EOI yet – keep buffered from SOI onward.
|
|
76
|
+
this._buf = soiIdx > 0 ? this._buf.slice(soiIdx) : this._buf;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Emit the complete JPEG (inclusive of both markers).
|
|
81
|
+
this.emit('frame', this._buf.slice(soiIdx, eoiIdx + 2));
|
|
82
|
+
start = eoiIdx + 2;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Retain any leftover bytes that haven't formed a complete frame yet.
|
|
86
|
+
this._buf = start < this._buf.length ? this._buf.slice(start) : null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// AndroidStream
|
|
92
|
+
//
|
|
93
|
+
// Streams the Android emulator screen by:
|
|
94
|
+
// 1. Running `adb exec-out screenrecord --output-format=h264 -` to get a
|
|
95
|
+
// continuous H.264 bitstream at up to 30 fps with no per-frame process
|
|
96
|
+
// overhead (replacing the previous one-shot screencap-per-frame approach).
|
|
97
|
+
// 2. Piping the bitstream through `ffmpeg` which decodes H.264 and re-encodes
|
|
98
|
+
// each frame as MJPEG at the requested fps / quality.
|
|
99
|
+
// 3. Parsing complete JPEG frames from ffmpeg stdout via JpegFrameParser and
|
|
100
|
+
// forwarding them to StreamHub.
|
|
101
|
+
// 4. Auto-restarting when Android's built-in 3-minute screenrecord limit
|
|
102
|
+
// expires (~170 s to give a small safety margin).
|
|
103
|
+
// 5. Falling back to screencap polling when H.264 streaming cannot start
|
|
104
|
+
// (e.g. older device without screenrecord stdout support).
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
11
106
|
class AndroidStream {
|
|
12
|
-
constructor({ userId, deviceId, controller, streamHub, fps =
|
|
107
|
+
constructor({ userId, deviceId, controller, streamHub, fps = 12, quality = 75 }) {
|
|
13
108
|
this.userId = String(userId || '');
|
|
14
109
|
this.deviceId = String(deviceId || '');
|
|
15
110
|
this.controller = controller;
|
|
16
111
|
this.streamHub = streamHub;
|
|
17
|
-
this.fps = clampInt(fps,
|
|
112
|
+
this.fps = clampInt(fps, 12, 1, 30);
|
|
18
113
|
this.quality = clampInt(quality, 75, 30, 95);
|
|
19
|
-
|
|
20
|
-
this.
|
|
114
|
+
|
|
115
|
+
this._stopped = true;
|
|
116
|
+
this._adbProc = null;
|
|
117
|
+
this._ffmpegProc = null;
|
|
118
|
+
this._restartTimer = null;
|
|
119
|
+
this._h264StartupTimer = null;
|
|
21
120
|
this._seq = 0;
|
|
22
121
|
this._lastErrorLogAt = 0;
|
|
23
|
-
this.
|
|
122
|
+
this._usePollingFallback = false; // set true when H.264 fails to start
|
|
24
123
|
}
|
|
25
124
|
|
|
26
125
|
start() {
|
|
27
|
-
if (this.
|
|
126
|
+
if (!this._stopped) return;
|
|
28
127
|
this._stopped = false;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
void this._captureOnce();
|
|
128
|
+
if (this._usePollingFallback) {
|
|
129
|
+
this._startPollingFallback();
|
|
130
|
+
} else {
|
|
131
|
+
this._launchH264();
|
|
132
|
+
}
|
|
35
133
|
}
|
|
36
134
|
|
|
37
135
|
stop() {
|
|
38
|
-
if (this._timer) {
|
|
39
|
-
clearInterval(this._timer);
|
|
40
|
-
this._timer = null;
|
|
41
|
-
}
|
|
42
136
|
this._stopped = true;
|
|
137
|
+
this._killProcesses();
|
|
43
138
|
}
|
|
44
139
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
140
|
+
// ── H.264 streaming (primary path) ─────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
_launchH264() {
|
|
143
|
+
if (this._stopped) return;
|
|
144
|
+
|
|
145
|
+
const adb = resolveAdbBin(this.controller?.sdkDir);
|
|
146
|
+
// deviceId is the ADB serial (emulator-5554, etc.)
|
|
147
|
+
const serial = this.deviceId;
|
|
148
|
+
const adbArgs = [
|
|
149
|
+
'-s', serial,
|
|
150
|
+
'exec-out',
|
|
151
|
+
'screenrecord',
|
|
152
|
+
'--output-format=h264',
|
|
153
|
+
'--bit-rate=2000000',
|
|
154
|
+
'--size=1280x720',
|
|
155
|
+
'-',
|
|
156
|
+
];
|
|
157
|
+
|
|
48
158
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
159
|
+
this._adbProc = spawn(adb, adbArgs, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
160
|
+
} catch (err) {
|
|
161
|
+
this._logError('Failed to spawn adb for H.264 streaming, falling back to screencap', err);
|
|
162
|
+
this._fallback();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Map our 30–95 quality range to ffmpeg q:v 2–20 (lower = better quality).
|
|
167
|
+
const ffmpegQ = Math.round(2 + ((95 - this.quality) / (95 - 30)) * 18);
|
|
168
|
+
const ffmpegArgs = [
|
|
169
|
+
'-loglevel', 'error',
|
|
170
|
+
'-f', 'h264',
|
|
171
|
+
'-i', 'pipe:0',
|
|
172
|
+
'-f', 'image2pipe',
|
|
173
|
+
'-vcodec', 'mjpeg',
|
|
174
|
+
'-q:v', String(ffmpegQ),
|
|
175
|
+
'-vf', `fps=${this.fps}`,
|
|
176
|
+
'pipe:1',
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
this._ffmpegProc = spawn('ffmpeg', ffmpegArgs, { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
181
|
+
} catch (err) {
|
|
182
|
+
this._logError('ffmpeg not found, falling back to screencap', err);
|
|
183
|
+
try { this._adbProc?.kill('SIGTERM'); } catch {}
|
|
184
|
+
this._adbProc = null;
|
|
185
|
+
this._fallback();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Wire adb stdout → ffmpeg stdin.
|
|
190
|
+
this._adbProc.stdout.pipe(this._ffmpegProc.stdin);
|
|
191
|
+
|
|
192
|
+
// Parse JPEG frames from ffmpeg stdout and forward to StreamHub.
|
|
193
|
+
const parser = new JpegFrameParser();
|
|
194
|
+
this._ffmpegProc.stdout.on('data', (chunk) => parser.push(chunk));
|
|
195
|
+
parser.on('frame', (jpeg) => {
|
|
57
196
|
if (this._stopped) return;
|
|
197
|
+
this._clearH264StartupTimer();
|
|
58
198
|
this.streamHub.handleFrame(this.userId, this.deviceId, {
|
|
59
199
|
jpeg,
|
|
60
200
|
platform: 'android',
|
|
@@ -62,22 +202,135 @@ class AndroidStream {
|
|
|
62
202
|
ts: Date.now() >>> 0,
|
|
63
203
|
flags: 1,
|
|
64
204
|
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
this._adbProc.on('error', (err) => {
|
|
208
|
+
this._logError('adb process error', err);
|
|
209
|
+
});
|
|
210
|
+
this._ffmpegProc.on('error', (err) => {
|
|
211
|
+
this._logError('ffmpeg process error', err);
|
|
212
|
+
});
|
|
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
|
+
|
|
223
|
+
// Android screenrecord's hard limit is 180 s — restart at 170 s so there
|
|
224
|
+
// is no gap in the stream.
|
|
225
|
+
this._restartTimer = setTimeout(() => {
|
|
226
|
+
if (!this._stopped) {
|
|
227
|
+
this._killProcesses();
|
|
228
|
+
this._launchH264();
|
|
229
|
+
}
|
|
230
|
+
}, 170_000);
|
|
231
|
+
this._restartTimer.unref?.();
|
|
232
|
+
|
|
233
|
+
// If adb exits early (e.g. emulator restart), attempt recovery.
|
|
234
|
+
this._adbProc.on('close', (code) => {
|
|
235
|
+
if (this._stopped || this._usePollingFallback) return;
|
|
236
|
+
// If it exited immediately with a bad code, the device likely does not
|
|
237
|
+
// support stdout screenrecord — fall back to screencap polling.
|
|
238
|
+
if (code !== 0 && this._seq === 0) {
|
|
239
|
+
this._logError(`screenrecord exited (code ${code}) before producing any frames — using screencap fallback`);
|
|
240
|
+
this._killProcesses();
|
|
241
|
+
this._fallback();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
this._logError(`adb screenrecord exited (code ${code}), restarting in 2 s`);
|
|
245
|
+
this._killProcesses();
|
|
246
|
+
this._scheduleRestart(2000, () => this._launchH264());
|
|
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
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
_fallback() {
|
|
259
|
+
this._killProcesses();
|
|
260
|
+
this._usePollingFallback = true;
|
|
261
|
+
if (!this._stopped) this._startPollingFallback();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Screencap polling fallback ──────────────────────────────────────────
|
|
265
|
+
// Used when H.264 streaming is unavailable. Runs a tight continuous loop
|
|
266
|
+
// (no fixed interval) so there is never idle time waiting between captures.
|
|
267
|
+
|
|
268
|
+
_startPollingFallback() {
|
|
269
|
+
// Fire and forget — the loop runs until this._stopped is set.
|
|
270
|
+
void this._pollLoop();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async _pollLoop() {
|
|
274
|
+
while (!this._stopped) {
|
|
275
|
+
try {
|
|
276
|
+
if (!this.controller || typeof this.controller.capturePng !== 'function') {
|
|
277
|
+
throw new Error('Android streaming requires a controller with capturePng().');
|
|
278
|
+
}
|
|
279
|
+
const png = await this.controller.capturePng({ deviceId: this.deviceId });
|
|
280
|
+
if (this._stopped || !png?.length) continue;
|
|
281
|
+
const jpeg = await sharp(png)
|
|
282
|
+
.jpeg({ quality: this.quality, mozjpeg: false })
|
|
283
|
+
.toBuffer();
|
|
284
|
+
if (this._stopped) return;
|
|
285
|
+
this.streamHub.handleFrame(this.userId, this.deviceId, {
|
|
286
|
+
jpeg,
|
|
287
|
+
platform: 'android',
|
|
288
|
+
seq: this._seq++ >>> 0,
|
|
289
|
+
ts: Date.now() >>> 0,
|
|
290
|
+
flags: 1,
|
|
73
291
|
});
|
|
292
|
+
} catch (error) {
|
|
293
|
+
this._logError('screencap poll failed', error);
|
|
294
|
+
// Brief pause before retrying to avoid a tight spin on persistent errors.
|
|
295
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
74
296
|
}
|
|
75
|
-
}
|
|
76
|
-
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
_scheduleRestart(delayMs, fn) {
|
|
303
|
+
this._restartTimer = setTimeout(fn, delayMs);
|
|
304
|
+
this._restartTimer.unref?.();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
_killProcesses() {
|
|
308
|
+
clearTimeout(this._restartTimer);
|
|
309
|
+
this._restartTimer = null;
|
|
310
|
+
this._clearH264StartupTimer();
|
|
311
|
+
// Kill ffmpeg first so it stops reading; then kill adb.
|
|
312
|
+
try { this._ffmpegProc?.kill('SIGTERM'); } catch {}
|
|
313
|
+
try { this._adbProc?.kill('SIGTERM'); } catch {}
|
|
314
|
+
this._ffmpegProc = null;
|
|
315
|
+
this._adbProc = null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
_clearH264StartupTimer() {
|
|
319
|
+
clearTimeout(this._h264StartupTimer);
|
|
320
|
+
this._h264StartupTimer = null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
_logError(msg, err) {
|
|
324
|
+
const now = Date.now();
|
|
325
|
+
if (now - this._lastErrorLogAt > 10_000) {
|
|
326
|
+
this._lastErrorLogAt = now;
|
|
327
|
+
console.warn(`[AndroidStream] ${msg}`, {
|
|
328
|
+
userId: this.userId,
|
|
329
|
+
deviceId: this.deviceId,
|
|
330
|
+
error: err ? String(err?.message || err) : undefined,
|
|
331
|
+
});
|
|
77
332
|
}
|
|
78
333
|
}
|
|
79
334
|
}
|
|
80
335
|
|
|
81
|
-
module.exports = {
|
|
82
|
-
AndroidStream,
|
|
83
|
-
};
|
|
336
|
+
module.exports = { AndroidStream };
|
|
@@ -275,7 +275,10 @@ async function pollIntegrationTask(runtime, task) {
|
|
|
275
275
|
const startIndex = rows.findIndex((row) => row.fingerprint === existingFingerprint);
|
|
276
276
|
const pending = startIndex >= 0 ? rows.slice(startIndex + 1) : rows.slice(-1);
|
|
277
277
|
for (const row of pending) {
|
|
278
|
-
await runtime.fireTaskFromTrigger(task.id, task.user_id, row);
|
|
278
|
+
const result = await runtime.fireTaskFromTrigger(task.id, task.user_id, row);
|
|
279
|
+
if (result?.error || (result?.skipped && result.reason !== 'duplicate_trigger')) {
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
279
282
|
}
|
|
280
283
|
}
|
|
281
284
|
|