neoagent 3.2.1-beta.2 → 3.2.1-beta.3
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 +318 -88
- package/extensions/chrome-browser/http.mjs +136 -0
- package/extensions/chrome-browser/protocol.mjs +511 -89
- package/flutter_app/lib/main_chat.dart +118 -739
- package/flutter_app/lib/main_controller.dart +29 -20
- package/flutter_app/lib/main_models.dart +3 -0
- package/flutter_app/lib/main_operations.dart +334 -321
- package/flutter_app/lib/main_settings.dart +4 -3
- package/flutter_app/lib/main_shared.dart +14 -11
- package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
- package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
- package/flutter_app/windows/runner/flutter_window.cpp +143 -32
- package/lib/manager.js +106 -89
- package/lib/schema_migrations.js +67 -13
- package/package.json +20 -13
- package/runtime/paths.js +49 -5
- package/server/guest_agent.js +52 -30
- package/server/http/middleware.js +24 -0
- package/server/http/routes.js +4 -6
- 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 +30803 -30805
- package/server/routes/admin.js +1 -1
- package/server/routes/android.js +30 -34
- package/server/routes/browser.js +23 -15
- package/server/routes/desktop.js +18 -1
- package/server/routes/integrations.js +5 -1
- package/server/routes/memory.js +1 -0
- package/server/routes/settings.js +16 -5
- package/server/routes/social_reach.js +12 -3
- package/server/routes/social_video.js +4 -0
- package/server/services/ai/compaction.js +7 -2
- package/server/services/ai/history.js +44 -5
- package/server/services/ai/integrated_tools/http_request.js +8 -0
- package/server/services/ai/loop/agent_engine_core.js +347 -162
- package/server/services/ai/loop/callbacks.js +1 -0
- package/server/services/ai/loop/completion_judge.js +12 -0
- package/server/services/ai/loop/conversation_loop.js +348 -242
- package/server/services/ai/loop/messaging_delivery.js +129 -57
- package/server/services/ai/loop/model_call_guard.js +91 -0
- package/server/services/ai/loop/model_io.js +8 -41
- package/server/services/ai/loop/tool_dispatch.js +19 -8
- package/server/services/ai/loopPolicy.js +24 -19
- package/server/services/ai/model_discovery.js +227 -0
- package/server/services/ai/model_identity.js +71 -0
- package/server/services/ai/models.js +67 -162
- package/server/services/ai/providerRetry.js +17 -59
- package/server/services/ai/provider_selector.js +111 -0
- package/server/services/ai/providers/anthropic.js +2 -2
- package/server/services/ai/providers/claudeCode.js +15 -28
- package/server/services/ai/providers/githubCopilot.js +36 -16
- package/server/services/ai/providers/google.js +23 -5
- package/server/services/ai/providers/grok.js +4 -3
- package/server/services/ai/providers/grokOauth.js +13 -22
- package/server/services/ai/providers/nvidia.js +10 -5
- package/server/services/ai/providers/ollama.js +102 -82
- package/server/services/ai/providers/ollama_stream.js +142 -0
- package/server/services/ai/providers/openai.js +39 -5
- package/server/services/ai/providers/openaiCodex.js +11 -4
- package/server/services/ai/providers/openrouter.js +29 -7
- package/server/services/ai/providers/provider_error.js +36 -0
- package/server/services/ai/settings.js +26 -2
- package/server/services/ai/taskAnalysis.js +5 -1
- package/server/services/ai/terminal_reply.js +45 -0
- package/server/services/ai/toolEvidence.js +58 -29
- package/server/services/ai/tools.js +124 -111
- package/server/services/android/controller.js +770 -237
- package/server/services/android/process.js +140 -0
- package/server/services/android/sdk_download.js +143 -0
- package/server/services/android/uia.js +6 -5
- package/server/services/artifacts/store.js +24 -0
- package/server/services/browser/controller.js +736 -385
- package/server/services/browser/extension/gateway.js +40 -16
- package/server/services/browser/extension/protocol.js +12 -1
- package/server/services/browser/extension/provider.js +59 -47
- package/server/services/browser/extension/registry.js +155 -34
- package/server/services/cli/executor.js +62 -9
- package/server/services/desktop/gateway.js +41 -4
- package/server/services/desktop/protocol.js +3 -0
- package/server/services/desktop/provider.js +39 -42
- package/server/services/desktop/registry.js +137 -52
- package/server/services/integrations/figma/provider.js +78 -12
- package/server/services/integrations/github/common.js +11 -6
- package/server/services/integrations/github/provider.js +52 -53
- package/server/services/integrations/google/provider.js +55 -19
- package/server/services/integrations/home_assistant/network.js +17 -20
- package/server/services/integrations/home_assistant/provider.js +7 -5
- package/server/services/integrations/home_assistant/tools.js +17 -5
- package/server/services/integrations/http.js +51 -0
- package/server/services/integrations/manager.js +158 -53
- package/server/services/integrations/microsoft/provider.js +80 -13
- package/server/services/integrations/neoarchive/provider.js +55 -29
- package/server/services/integrations/neorecall/client.js +17 -10
- package/server/services/integrations/neorecall/provider.js +20 -11
- package/server/services/integrations/notion/provider.js +16 -13
- package/server/services/integrations/oauth_provider.js +115 -51
- package/server/services/integrations/slack/provider.js +98 -9
- package/server/services/integrations/spotify/provider.js +67 -71
- package/server/services/integrations/trello/provider.js +21 -7
- package/server/services/integrations/weather/provider.js +18 -12
- package/server/services/integrations/whatsapp/provider.js +76 -16
- package/server/services/manager.js +87 -1
- package/server/services/memory/embedding_index.js +20 -8
- package/server/services/memory/embeddings.js +151 -90
- package/server/services/memory/ingestion.js +50 -9
- package/server/services/memory/ingestion_documents.js +13 -3
- package/server/services/memory/manager.js +52 -19
- package/server/services/messaging/automation.js +84 -9
- package/server/services/messaging/http_platforms.js +33 -13
- package/server/services/messaging/inbound_queue.js +78 -24
- package/server/services/messaging/inbound_store.js +224 -0
- package/server/services/messaging/manager.js +326 -51
- package/server/services/messaging/typing_keepalive.js +5 -2
- package/server/services/network/http.js +210 -0
- package/server/services/network/safe_request.js +307 -0
- package/server/services/runtime/backends/local-vm.js +214 -66
- package/server/services/runtime/manager.js +17 -12
- package/server/services/social_reach/channels/github.js +10 -4
- package/server/services/social_reach/channels/reddit.js +4 -4
- package/server/services/social_reach/channels/rss.js +2 -2
- package/server/services/social_reach/channels/social_video.js +12 -7
- package/server/services/social_reach/channels/v2ex.js +21 -8
- package/server/services/social_reach/channels/x.js +2 -2
- package/server/services/social_reach/channels/xueqiu.js +5 -5
- package/server/services/social_reach/service.js +9 -6
- package/server/services/social_reach/utils.js +65 -14
- package/server/services/social_video/service.js +160 -50
- package/server/services/tasks/integration_runtime.js +18 -8
- package/server/services/tasks/runtime.js +39 -4
- package/server/services/voice/agentBridge.js +17 -4
- package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
- package/server/services/voice/liveSession.js +31 -0
- package/server/services/voice/openaiSpeech.js +33 -8
- package/server/services/voice/providers.js +233 -151
- package/server/services/voice/runtimeManager.js +118 -20
- package/server/services/voice/turnRunner.js +6 -0
- package/server/services/wearable/firmware_manifest.js +51 -13
- package/server/services/wearable/service.js +1 -0
- package/server/utils/abort.js +96 -0
- package/server/utils/cloud-security.js +110 -3
- package/server/utils/files.js +31 -0
- package/server/utils/image_payload.js +95 -0
- package/server/utils/retry.js +107 -0
|
@@ -1,29 +1,34 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { spawn
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
-
const https = require('https');
|
|
6
5
|
const net = require('net');
|
|
7
6
|
const os = require('os');
|
|
8
7
|
const path = require('path');
|
|
9
|
-
const { DATA_DIR } = require('../../../runtime/paths');
|
|
8
|
+
const { DATA_DIR, RUNTIME_HOME } = require('../../../runtime/paths');
|
|
9
|
+
const { validateAndroidIntentUrl } = require('../../utils/cloud-security');
|
|
10
|
+
const { validateImageBuffer } = require('../../utils/image_payload');
|
|
11
|
+
const { downloadFile, resolveCommandLineToolsRelease } = require('./sdk_download');
|
|
12
|
+
const { findBestNode, parseUiDump, summarizeNode } = require('./uia');
|
|
13
|
+
const { clampNumber, runProcess } = require('./process');
|
|
10
14
|
|
|
11
15
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
12
16
|
|
|
13
|
-
const DEFAULT_SDK_DIR = path.join(
|
|
17
|
+
const DEFAULT_SDK_DIR = path.join(RUNTIME_HOME, 'android-sdk');
|
|
18
|
+
const DEFAULT_AVD_DIR = path.join(RUNTIME_HOME, 'android', 'avd');
|
|
14
19
|
const STATE_DIR = path.join(DATA_DIR, 'android', 'state');
|
|
15
20
|
const LOGO_PATH = path.join(__dirname, '..', '..', '..', 'flutter_app', 'assets', 'branding', 'app_icon_512.png');
|
|
16
21
|
|
|
17
|
-
// Even ports in
|
|
22
|
+
// Even console ports in the documented emulator range. Each emulator also owns
|
|
23
|
+
// the adjacent ADB port, so 64 pairs fit without crossing the supported range.
|
|
18
24
|
const ADB_PORT_BASE = 5554;
|
|
19
|
-
const ADB_PORT_SLOTS =
|
|
25
|
+
const ADB_PORT_SLOTS = 64;
|
|
26
|
+
const RESERVED_ADB_PORTS = new Set();
|
|
27
|
+
const SDK_PROVISIONING = new Map();
|
|
20
28
|
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
linux: `https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip`,
|
|
25
|
-
win32: `https://dl.google.com/android/repository/commandlinetools-win-${CMDLINE_TOOLS_VERSION}_latest.zip`,
|
|
26
|
-
};
|
|
29
|
+
const MAX_ANDROID_TEXT_CHARS = 8000;
|
|
30
|
+
const MAX_ANDROID_INTENT_EXTRAS = 100;
|
|
31
|
+
const MAX_ANDROID_PACKAGE_BYTES = 1024 * 1024 * 1024;
|
|
27
32
|
|
|
28
33
|
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
29
34
|
|
|
@@ -38,7 +43,14 @@ function readState(userId) {
|
|
|
38
43
|
|
|
39
44
|
function writeState(userId, patch) {
|
|
40
45
|
const current = readState(userId);
|
|
41
|
-
|
|
46
|
+
const destination = stateFile(userId);
|
|
47
|
+
const temporary = `${destination}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
48
|
+
try {
|
|
49
|
+
fs.writeFileSync(temporary, JSON.stringify({ ...current, ...patch }, null, 2), { mode: 0o600 });
|
|
50
|
+
fs.renameSync(temporary, destination);
|
|
51
|
+
} finally {
|
|
52
|
+
try { fs.unlinkSync(temporary); } catch {}
|
|
53
|
+
}
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
// ─── SDK resolution ──────────────────────────────────────────────────────────
|
|
@@ -111,86 +123,197 @@ function pickSystemImage(sdkDir) {
|
|
|
111
123
|
|
|
112
124
|
function defaultSystemImage() {
|
|
113
125
|
const abi = (os.arch() === 'arm64' || os.arch() === 'arm') ? 'arm64-v8a' : 'x86_64';
|
|
114
|
-
return `system-images;android-
|
|
126
|
+
return `system-images;android-36;google_apis;${abi}`;
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
// ─── SDK setup ───────────────────────────────────────────────────────────────
|
|
118
130
|
|
|
119
|
-
function
|
|
131
|
+
function abortError(signal, message = 'Android operation was aborted.') {
|
|
132
|
+
if (signal?.reason instanceof Error) return signal.reason;
|
|
133
|
+
const error = new Error(String(signal?.reason || message));
|
|
134
|
+
error.name = 'AbortError';
|
|
135
|
+
error.code = 'ABORT_ERR';
|
|
136
|
+
return error;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function delay(ms, signal = null) {
|
|
140
|
+
if (signal?.aborted) return Promise.reject(abortError(signal));
|
|
120
141
|
return new Promise((resolve, reject) => {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
142
|
+
const timer = setTimeout(() => {
|
|
143
|
+
signal?.removeEventListener('abort', onAbort);
|
|
144
|
+
resolve();
|
|
145
|
+
}, ms);
|
|
146
|
+
const onAbort = () => {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
reject(abortError(signal));
|
|
149
|
+
};
|
|
150
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
151
|
+
if (signal?.aborted) onAbort();
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function waitForAbortable(promise, signal = null) {
|
|
156
|
+
if (signal?.aborted) return Promise.reject(abortError(signal));
|
|
157
|
+
if (!signal) return promise;
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
let settled = false;
|
|
160
|
+
const finish = (callback, value) => {
|
|
161
|
+
if (settled) return;
|
|
162
|
+
settled = true;
|
|
163
|
+
signal.removeEventListener('abort', onAbort);
|
|
164
|
+
callback(value);
|
|
165
|
+
};
|
|
166
|
+
const onAbort = () => finish(reject, abortError(signal));
|
|
167
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
168
|
+
if (signal.aborted) onAbort();
|
|
169
|
+
Promise.resolve(promise).then(
|
|
170
|
+
(value) => finish(resolve, value),
|
|
171
|
+
(error) => finish(reject, error),
|
|
172
|
+
);
|
|
131
173
|
});
|
|
132
174
|
}
|
|
133
175
|
|
|
134
|
-
async function ensureSdk(sdkDir, onProgress) {
|
|
176
|
+
async function ensureSdk(sdkDir, onProgress, options = {}) {
|
|
135
177
|
if (fs.existsSync(sdkManagerBin(sdkDir))) return;
|
|
136
|
-
const
|
|
137
|
-
if (!url) throw new Error(`No cmdline-tools download for platform: ${process.platform}`);
|
|
178
|
+
const release = resolveCommandLineToolsRelease();
|
|
138
179
|
|
|
139
180
|
fs.mkdirSync(sdkDir, { recursive: true });
|
|
140
|
-
onProgress('Downloading Android SDK command-line tools (~
|
|
141
|
-
const
|
|
142
|
-
|
|
181
|
+
onProgress('Downloading Android SDK command-line tools (~182 MB)…');
|
|
182
|
+
const downloadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'neoagent-android-sdk-'));
|
|
183
|
+
const zip = path.join(downloadDir, 'cmdline-tools.zip');
|
|
184
|
+
try {
|
|
185
|
+
await downloadFile(release.url, zip, {
|
|
186
|
+
expectedSha256: release.sha256,
|
|
187
|
+
signal: options.signal,
|
|
188
|
+
});
|
|
189
|
+
await runProcess('unzip', ['-tq', zip], {
|
|
190
|
+
timeoutMs: 120_000,
|
|
191
|
+
maxOutputBytes: 1024 * 1024,
|
|
192
|
+
signal: options.signal,
|
|
193
|
+
});
|
|
143
194
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
195
|
+
onProgress('Extracting…');
|
|
196
|
+
const toolsDir = path.join(sdkDir, 'cmdline-tools');
|
|
197
|
+
fs.mkdirSync(toolsDir, { recursive: true });
|
|
198
|
+
await runProcess('unzip', ['-qo', zip, '-d', toolsDir], {
|
|
199
|
+
timeoutMs: 120_000,
|
|
200
|
+
maxOutputBytes: 1024 * 1024,
|
|
201
|
+
signal: options.signal,
|
|
202
|
+
});
|
|
203
|
+
} finally {
|
|
204
|
+
fs.rmSync(downloadDir, { recursive: true, force: true });
|
|
205
|
+
}
|
|
150
206
|
|
|
207
|
+
const toolsDir = path.join(sdkDir, 'cmdline-tools');
|
|
151
208
|
const extracted = path.join(toolsDir, 'cmdline-tools');
|
|
152
209
|
const latest = path.join(toolsDir, 'latest');
|
|
153
210
|
if (fs.existsSync(extracted) && !fs.existsSync(latest)) fs.renameSync(extracted, latest);
|
|
154
211
|
if (!fs.existsSync(sdkManagerBin(sdkDir))) throw new Error('sdkmanager not found after extraction');
|
|
155
212
|
}
|
|
156
213
|
|
|
157
|
-
async function ensurePackages(sdkDir, onProgress) {
|
|
214
|
+
async function ensurePackages(sdkDir, onProgress, options = {}) {
|
|
158
215
|
const env = { ...process.env, ANDROID_SDK_ROOT: sdkDir, ANDROID_HOME: sdkDir };
|
|
159
216
|
const sdkman = sdkManagerBin(sdkDir);
|
|
160
217
|
|
|
161
218
|
onProgress('Accepting Android SDK licenses…');
|
|
162
|
-
|
|
219
|
+
await runProcess(sdkman, ['--licenses', `--sdk_root=${sdkDir}`], {
|
|
220
|
+
input: 'y\n'.repeat(20),
|
|
221
|
+
env,
|
|
222
|
+
timeoutMs: 5 * 60 * 1000,
|
|
223
|
+
maxOutputBytes: 8 * 1024 * 1024,
|
|
224
|
+
signal: options.signal,
|
|
225
|
+
});
|
|
163
226
|
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
227
|
+
const existingImage = pickSystemImage(sdkDir);
|
|
228
|
+
const packages = ['platform-tools', 'emulator'];
|
|
229
|
+
if (!existingImage) packages.push(defaultSystemImage());
|
|
230
|
+
onProgress(`Installing ${packages.join(', ')} (first run only)…`);
|
|
231
|
+
await runProcess(sdkman, [...packages, `--sdk_root=${sdkDir}`], {
|
|
232
|
+
env,
|
|
233
|
+
timeoutMs: 20 * 60 * 1000,
|
|
234
|
+
maxOutputBytes: 32 * 1024 * 1024,
|
|
235
|
+
signal: options.signal,
|
|
168
236
|
});
|
|
169
|
-
if (r.status !== 0) throw new Error(`sdkmanager failed: ${(r.stderr || r.stdout || '').slice(0, 500)}`);
|
|
170
237
|
}
|
|
171
238
|
|
|
172
|
-
function
|
|
239
|
+
async function ensureSdkProvisioned(sdkDir, onProgress, options = {}) {
|
|
240
|
+
const key = path.resolve(sdkDir);
|
|
241
|
+
let entry = SDK_PROVISIONING.get(key);
|
|
242
|
+
if (entry?.controller.signal.aborted && !entry.settled) {
|
|
243
|
+
await entry.promise.catch(() => {});
|
|
244
|
+
entry = null;
|
|
245
|
+
}
|
|
246
|
+
if (!entry) {
|
|
247
|
+
const controller = new AbortController();
|
|
248
|
+
entry = {
|
|
249
|
+
controller,
|
|
250
|
+
promise: null,
|
|
251
|
+
settled: false,
|
|
252
|
+
waiters: 0,
|
|
253
|
+
};
|
|
254
|
+
entry.promise = (async () => {
|
|
255
|
+
await ensureSdk(sdkDir, onProgress, { signal: controller.signal });
|
|
256
|
+
const hasAdb = fs.existsSync(adbBin(sdkDir));
|
|
257
|
+
const hasEmulator = fs.existsSync(emulatorBin(sdkDir));
|
|
258
|
+
const hasImage = Boolean(pickSystemImage(sdkDir));
|
|
259
|
+
if (!hasAdb || !hasEmulator || !hasImage) {
|
|
260
|
+
await ensurePackages(sdkDir, onProgress, { signal: controller.signal });
|
|
261
|
+
}
|
|
262
|
+
})();
|
|
263
|
+
SDK_PROVISIONING.set(key, entry);
|
|
264
|
+
const settle = () => {
|
|
265
|
+
entry.settled = true;
|
|
266
|
+
if (SDK_PROVISIONING.get(key) === entry) SDK_PROVISIONING.delete(key);
|
|
267
|
+
};
|
|
268
|
+
entry.promise.then(settle, settle);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
entry.waiters += 1;
|
|
272
|
+
try {
|
|
273
|
+
await waitForAbortable(entry.promise, options.signal);
|
|
274
|
+
} finally {
|
|
275
|
+
entry.waiters -= 1;
|
|
276
|
+
if (entry.waiters === 0 && !entry.settled && !entry.controller.signal.aborted) {
|
|
277
|
+
entry.controller.abort(new Error('Android SDK provisioning no longer has an active startup request.'));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function ensureEmulatorRegistered(sdkDir, options = {}) {
|
|
173
283
|
const packageXml = path.join(sdkDir, 'emulator', 'package.xml');
|
|
174
284
|
if (fs.existsSync(packageXml) || !fs.existsSync(emulatorBin(sdkDir))) return;
|
|
175
285
|
const env = { ...process.env, ANDROID_SDK_ROOT: sdkDir, ANDROID_HOME: sdkDir };
|
|
176
|
-
|
|
177
|
-
|
|
286
|
+
await runProcess(sdkManagerBin(sdkDir), ['emulator', `--sdk_root=${sdkDir}`], {
|
|
287
|
+
env,
|
|
288
|
+
input: 'y\n'.repeat(5),
|
|
289
|
+
timeoutMs: 5 * 60 * 1000,
|
|
290
|
+
maxOutputBytes: 16 * 1024 * 1024,
|
|
291
|
+
signal: options.signal,
|
|
178
292
|
});
|
|
179
293
|
}
|
|
180
294
|
|
|
181
|
-
function ensureAvd(sdkDir, avdName, onProgress) {
|
|
182
|
-
const avdDir = path.join(
|
|
295
|
+
async function ensureAvd(sdkDir, avdName, avdHome, onProgress, options = {}) {
|
|
296
|
+
const avdDir = path.join(avdHome, `${avdName}.avd`);
|
|
183
297
|
if (fs.existsSync(avdDir)) return;
|
|
184
298
|
|
|
185
|
-
ensureEmulatorRegistered(sdkDir);
|
|
299
|
+
await ensureEmulatorRegistered(sdkDir, options);
|
|
186
300
|
const img = pickSystemImage(sdkDir) || defaultSystemImage();
|
|
187
301
|
onProgress(`Creating AVD "${avdName}" using ${img}…`);
|
|
188
302
|
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
303
|
+
fs.mkdirSync(avdHome, { recursive: true });
|
|
304
|
+
const env = {
|
|
305
|
+
...process.env,
|
|
306
|
+
ANDROID_AVD_HOME: avdHome,
|
|
307
|
+
ANDROID_SDK_ROOT: sdkDir,
|
|
308
|
+
ANDROID_HOME: sdkDir,
|
|
309
|
+
};
|
|
310
|
+
await runProcess(avdManagerBin(sdkDir), ['create', 'avd', '-n', avdName, '-k', img, '--device', 'pixel', '--force'], {
|
|
311
|
+
env,
|
|
312
|
+
input: '\n',
|
|
313
|
+
timeoutMs: 120_000,
|
|
314
|
+
maxOutputBytes: 4 * 1024 * 1024,
|
|
315
|
+
signal: options.signal,
|
|
192
316
|
});
|
|
193
|
-
if (r.status !== 0) throw new Error(`avdmanager failed: ${(r.stderr || r.stdout || '').slice(0, 500)}`);
|
|
194
317
|
|
|
195
318
|
// Patch config: sparse QCOW2 (no pre-allocation), smaller cache partition.
|
|
196
319
|
const cfgPath = path.join(avdDir, 'config.ini');
|
|
@@ -222,25 +345,62 @@ function isSafeIdentifier(str) {
|
|
|
222
345
|
return /^[\w.]+$/.test(String(str || ''));
|
|
223
346
|
}
|
|
224
347
|
|
|
348
|
+
function isSafeActivity(str) {
|
|
349
|
+
return /^[\w.$]+$/.test(String(str || ''));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function isSafeComponent(str) {
|
|
353
|
+
return /^[\w.$]+\/[\w.$]+$/.test(String(str || ''));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function isSafeMimeType(str) {
|
|
357
|
+
return /^[\w.+-]+\/[\w.+*-]+$/.test(String(str || ''));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function normalizeUserId(value) {
|
|
361
|
+
const userId = String(value || 'default').trim();
|
|
362
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(userId)) {
|
|
363
|
+
throw new Error('Android user ID contains unsupported characters.');
|
|
364
|
+
}
|
|
365
|
+
return userId;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function hasUiSelector(value = {}) {
|
|
369
|
+
return ['text', 'resourceId', 'description', 'className', 'packageName']
|
|
370
|
+
.some((key) => String(value[key] || '').trim())
|
|
371
|
+
|| value.clickable === true;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function requireCoordinate(value, label) {
|
|
375
|
+
const number = Number(value);
|
|
376
|
+
if (!Number.isFinite(number) || number < 0) {
|
|
377
|
+
throw new Error(`${label} must be a non-negative number.`);
|
|
378
|
+
}
|
|
379
|
+
return Math.round(number);
|
|
380
|
+
}
|
|
381
|
+
|
|
225
382
|
// ─── AndroidController ───────────────────────────────────────────────────────
|
|
226
383
|
|
|
227
384
|
class AndroidController {
|
|
228
385
|
constructor(options = {}) {
|
|
229
|
-
this.userId =
|
|
386
|
+
this.userId = normalizeUserId(options.userId);
|
|
230
387
|
this.avdName = `neoagent_${this.userId}`;
|
|
231
388
|
// Deterministic ADB console port per user, within documented range 5554–5682 (even only).
|
|
232
389
|
this.adbPort = ADB_PORT_BASE + ((hashCode(this.userId) >>> 0) % ADB_PORT_SLOTS) * 2;
|
|
233
390
|
this.adbSerial = `emulator-${this.adbPort}`;
|
|
234
391
|
this.sdkDir = options.sdkDir || findExistingSdk() || DEFAULT_SDK_DIR;
|
|
392
|
+
this.avdHome = options.avdHome || DEFAULT_AVD_DIR;
|
|
235
393
|
this.artifactStore = options.artifactStore || null;
|
|
236
394
|
this.startPromise = null;
|
|
395
|
+
this.startAbortController = null;
|
|
396
|
+
this.emulatorProcess = null;
|
|
237
397
|
}
|
|
238
398
|
|
|
239
399
|
// ── Status ────────────────────────────────────────────────────────────────
|
|
240
400
|
|
|
241
401
|
getStatusSync() { return readState(this.userId); }
|
|
242
402
|
|
|
243
|
-
async getStatus() {
|
|
403
|
+
async getStatus(options = {}) {
|
|
244
404
|
const state = readState(this.userId);
|
|
245
405
|
const base = {
|
|
246
406
|
bootstrapped: state.bootstrapped || false,
|
|
@@ -255,204 +415,476 @@ class AndroidController {
|
|
|
255
415
|
if (!this.#isPidAlive(state.pid)) return { ...base, bootstrapped: false };
|
|
256
416
|
|
|
257
417
|
try {
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
418
|
+
const result = await runProcess(
|
|
419
|
+
adbBin(this.sdkDir),
|
|
420
|
+
['-s', state.adbSerial, 'shell', 'getprop', 'sys.boot_completed'],
|
|
421
|
+
{ timeoutMs: 5000, maxOutputBytes: 64 * 1024, signal: options.signal },
|
|
422
|
+
);
|
|
423
|
+
const booted = result.stdout.trim() === '1';
|
|
261
424
|
return {
|
|
262
425
|
...base,
|
|
263
426
|
bootstrapped: booted,
|
|
264
427
|
devices: booted ? [{ serial: state.adbSerial, status: 'device', emulator: true }] : [],
|
|
265
428
|
};
|
|
266
|
-
} catch {
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (options.signal?.aborted) throw error;
|
|
267
431
|
return base;
|
|
268
432
|
}
|
|
269
433
|
}
|
|
270
434
|
|
|
271
435
|
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
272
436
|
|
|
273
|
-
async requestStartEmulator() {
|
|
437
|
+
async requestStartEmulator(options = {}) {
|
|
274
438
|
console.log(`[Android] requestStartEmulator for user ${this.userId}`);
|
|
275
439
|
const state = readState(this.userId);
|
|
276
440
|
if (state.adbSerial && this.#isPidAlive(state.pid)) {
|
|
277
441
|
console.log(`[Android] Emulator already running (pid=${state.pid})`);
|
|
278
|
-
|
|
442
|
+
const status = await this.getStatus(options);
|
|
443
|
+
return {
|
|
444
|
+
success: true,
|
|
445
|
+
pending: !status.bootstrapped,
|
|
446
|
+
bootstrapped: status.bootstrapped,
|
|
447
|
+
adbSerial: state.adbSerial,
|
|
448
|
+
};
|
|
279
449
|
}
|
|
280
450
|
if (!this.startPromise) {
|
|
281
451
|
writeState(this.userId, { starting: true, startupPhase: 'Initializing', lastStartError: null });
|
|
282
|
-
|
|
283
|
-
this.
|
|
452
|
+
const startAbortController = new AbortController();
|
|
453
|
+
this.startAbortController = startAbortController;
|
|
454
|
+
const externalSignal = options.signal || null;
|
|
455
|
+
const forwardAbort = () => startAbortController.abort(externalSignal.reason);
|
|
456
|
+
if (externalSignal?.aborted) forwardAbort();
|
|
457
|
+
else externalSignal?.addEventListener('abort', forwardAbort, { once: true });
|
|
458
|
+
let trackedStart;
|
|
459
|
+
trackedStart = this.#setup({
|
|
460
|
+
...options,
|
|
461
|
+
signal: startAbortController.signal,
|
|
462
|
+
}).finally(() => {
|
|
463
|
+
externalSignal?.removeEventListener('abort', forwardAbort);
|
|
464
|
+
if (this.startAbortController === startAbortController) {
|
|
465
|
+
this.startAbortController = null;
|
|
466
|
+
}
|
|
467
|
+
if (this.startPromise === trackedStart) this.startPromise = null;
|
|
468
|
+
});
|
|
469
|
+
this.startPromise = trackedStart;
|
|
470
|
+
trackedStart.catch(() => {});
|
|
284
471
|
}
|
|
285
472
|
const s = readState(this.userId);
|
|
286
473
|
return { success: true, pending: true, bootstrapped: false, starting: true, startupPhase: s.startupPhase };
|
|
287
474
|
}
|
|
288
475
|
|
|
289
|
-
async
|
|
476
|
+
async startEmulator(options = {}) {
|
|
477
|
+
const start = await this.requestStartEmulator(options);
|
|
478
|
+
if (start.bootstrapped) return start;
|
|
479
|
+
const timeoutMs = clampNumber(options.timeoutMs, 240_000, 1000, 10 * 60 * 1000);
|
|
480
|
+
const adbSerial = await this.waitForDevice({ timeoutMs, signal: options.signal });
|
|
481
|
+
return { success: true, pending: false, bootstrapped: true, adbSerial };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async stopEmulator(options = {}) {
|
|
485
|
+
this.startAbortController?.abort(new Error('Android emulator startup was stopped.'));
|
|
486
|
+
const starting = this.startPromise;
|
|
487
|
+
if (starting) await starting.catch(() => {});
|
|
290
488
|
const state = readState(this.userId);
|
|
291
|
-
|
|
292
|
-
|
|
489
|
+
let adbStopError = null;
|
|
490
|
+
if (state.adbSerial) {
|
|
491
|
+
try {
|
|
492
|
+
await runProcess(
|
|
493
|
+
adbBin(this.sdkDir),
|
|
494
|
+
['-s', state.adbSerial, 'emu', 'kill'],
|
|
495
|
+
{ timeoutMs: 5000, maxOutputBytes: 64 * 1024, signal: options.signal },
|
|
496
|
+
);
|
|
497
|
+
} catch (error) {
|
|
498
|
+
adbStopError = error;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
await this.#terminateOwnedEmulatorProcess();
|
|
502
|
+
if (state.pid) await this.#waitForPidExit(state.pid, 5000);
|
|
503
|
+
if (state.pid && this.#isPidAlive(state.pid)) {
|
|
504
|
+
if (options.signal?.aborted) throw abortError(options.signal);
|
|
505
|
+
if (adbStopError) throw adbStopError;
|
|
506
|
+
throw new Error('Android emulator did not exit after the stop request.');
|
|
507
|
+
}
|
|
508
|
+
RESERVED_ADB_PORTS.delete(Number(String(state.adbSerial || '').replace(/^emulator-/, '')));
|
|
509
|
+
RESERVED_ADB_PORTS.delete(this.adbPort);
|
|
510
|
+
writeState(this.userId, {
|
|
511
|
+
bootstrapped: false,
|
|
512
|
+
starting: false,
|
|
513
|
+
pid: null,
|
|
514
|
+
adbSerial: null,
|
|
515
|
+
startupPhase: null,
|
|
516
|
+
lastStartError: null,
|
|
517
|
+
});
|
|
518
|
+
if (options.signal?.aborted) throw abortError(options.signal);
|
|
293
519
|
console.log('[Android] Emulator stopped');
|
|
520
|
+
return { success: true };
|
|
294
521
|
}
|
|
295
522
|
|
|
296
523
|
async close() { await this.stopEmulator().catch(() => {}); }
|
|
297
524
|
|
|
298
525
|
async waitForDevice(options = {}) {
|
|
299
|
-
const
|
|
526
|
+
const timeoutMs = clampNumber(options.timeoutMs, 600_000, 1000, 10 * 60 * 1000);
|
|
527
|
+
const deadline = Date.now() + timeoutMs;
|
|
300
528
|
while (Date.now() < deadline) {
|
|
301
|
-
|
|
529
|
+
if (options.signal?.aborted) {
|
|
530
|
+
const error = new Error('Waiting for the Android emulator was aborted.');
|
|
531
|
+
error.name = 'AbortError';
|
|
532
|
+
error.code = 'ABORT_ERR';
|
|
533
|
+
throw error;
|
|
534
|
+
}
|
|
535
|
+
const s = await this.getStatus(options);
|
|
302
536
|
if (s.bootstrapped) return this.adbSerial;
|
|
303
|
-
|
|
537
|
+
if (!s.starting && s.lastStartError) throw new Error(s.lastStartError);
|
|
538
|
+
await delay(2000, options.signal);
|
|
304
539
|
}
|
|
305
540
|
throw new Error('Android emulator did not become ready in time');
|
|
306
541
|
}
|
|
307
542
|
|
|
308
|
-
async listDevices() {
|
|
309
|
-
const s = await this.getStatus();
|
|
543
|
+
async listDevices(options = {}) {
|
|
544
|
+
const s = await this.getStatus(options);
|
|
310
545
|
return s.bootstrapped ? [{ serial: this.adbSerial, status: 'device', emulator: true }] : [];
|
|
311
546
|
}
|
|
312
547
|
|
|
313
|
-
async ensureBootstrapped() {
|
|
314
|
-
const
|
|
315
|
-
if (!
|
|
548
|
+
async ensureBootstrapped(options = {}) {
|
|
549
|
+
const status = await this.getStatus(options);
|
|
550
|
+
if (!status.bootstrapped) await this.startEmulator(options);
|
|
551
|
+
return this.adbSerial;
|
|
316
552
|
}
|
|
317
553
|
|
|
318
554
|
// ── Shell / ADB ───────────────────────────────────────────────────────────
|
|
319
555
|
|
|
320
556
|
async shell(commandOrObj) {
|
|
321
557
|
const command = typeof commandOrObj === 'string' ? commandOrObj : String(commandOrObj?.command || '');
|
|
558
|
+
if (!command.trim()) throw new Error('Android shell command is required.');
|
|
322
559
|
const serial = this.#requireSerial();
|
|
323
560
|
const adb = adbBin(this.sdkDir);
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
proc.on('close', code => code === 0 ? resolve(out) : reject(new Error(err || out || `exit ${code}`)));
|
|
330
|
-
proc.on('error', reject);
|
|
561
|
+
const options = typeof commandOrObj === 'object' && commandOrObj ? commandOrObj : {};
|
|
562
|
+
const result = await runProcess(adb, ['-s', serial, 'shell', command], {
|
|
563
|
+
timeoutMs: options.timeoutMs,
|
|
564
|
+
maxOutputBytes: 4 * 1024 * 1024,
|
|
565
|
+
signal: options.signal,
|
|
331
566
|
});
|
|
567
|
+
if (options.screenshot === true) {
|
|
568
|
+
return { output: result.stdout, ...await this.screenshot({ signal: options.signal }) };
|
|
569
|
+
}
|
|
570
|
+
return result.stdout;
|
|
332
571
|
}
|
|
333
572
|
|
|
334
573
|
async adb(...args) {
|
|
335
574
|
const state = readState(this.userId);
|
|
336
575
|
const adb = adbBin(this.sdkDir);
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
proc.on('error', reject);
|
|
344
|
-
});
|
|
576
|
+
const result = await runProcess(
|
|
577
|
+
adb,
|
|
578
|
+
['-s', state.adbSerial || this.adbSerial, ...args],
|
|
579
|
+
{ timeoutMs: 60_000, maxOutputBytes: 16 * 1024 * 1024 },
|
|
580
|
+
);
|
|
581
|
+
return result.stdout;
|
|
345
582
|
}
|
|
346
583
|
|
|
347
584
|
// ── Actions ───────────────────────────────────────────────────────────────
|
|
348
585
|
|
|
349
|
-
async screenshot(
|
|
350
|
-
const r = await this.capturePng();
|
|
586
|
+
async screenshot(options = {}) {
|
|
587
|
+
const r = await this.capturePng(options);
|
|
351
588
|
if (!r?.length) throw new Error('screencap returned no data');
|
|
352
|
-
return { screenshotPath: this.#saveArtifact(r) };
|
|
589
|
+
return { screenshotPath: await this.#saveArtifact(r, options) };
|
|
353
590
|
}
|
|
354
591
|
|
|
355
|
-
async capturePng(
|
|
592
|
+
async capturePng(options = {}) {
|
|
356
593
|
const serial = this.#requireSerial();
|
|
357
|
-
return this.#adbCaptureAsync(serial, ['exec-out', 'screencap', '-p']);
|
|
594
|
+
return this.#adbCaptureAsync(serial, ['exec-out', 'screencap', '-p'], options);
|
|
358
595
|
}
|
|
359
596
|
|
|
360
|
-
async observe(
|
|
597
|
+
async observe({ includeNodes = true, signal } = {}) {
|
|
598
|
+
const screenshot = await this.screenshot({ signal });
|
|
599
|
+
const dump = await this.dumpUi({ includeNodes, signal });
|
|
600
|
+
return {
|
|
601
|
+
...screenshot,
|
|
602
|
+
uiDumpPath: dump.devicePath,
|
|
603
|
+
nodeCount: dump.nodeCount,
|
|
604
|
+
...(includeNodes === false ? {} : { nodes: dump.nodes }),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
361
607
|
|
|
362
|
-
async
|
|
363
|
-
|
|
364
|
-
|
|
608
|
+
async findUiNode(selector = {}) {
|
|
609
|
+
if (!hasUiSelector(selector)) {
|
|
610
|
+
throw new Error('Provide x and y coordinates or a UI selector.');
|
|
611
|
+
}
|
|
612
|
+
const dump = await this.dumpUi({ includeNodes: true, signal: selector.signal });
|
|
613
|
+
const node = findBestNode(dump.nodes, selector);
|
|
614
|
+
if (!node) {
|
|
615
|
+
throw new Error(`No Android UI element matched ${JSON.stringify(selector)}.`);
|
|
616
|
+
}
|
|
617
|
+
return node;
|
|
365
618
|
}
|
|
366
619
|
|
|
367
|
-
async
|
|
368
|
-
|
|
369
|
-
|
|
620
|
+
async resolvePoint(options = {}) {
|
|
621
|
+
const hasX = Number.isFinite(Number(options.x));
|
|
622
|
+
const hasY = Number.isFinite(Number(options.y));
|
|
623
|
+
if (hasX || hasY) {
|
|
624
|
+
if (!hasX || !hasY) throw new Error('Both x and y coordinates are required.');
|
|
625
|
+
return {
|
|
626
|
+
x: requireCoordinate(options.x, 'x'),
|
|
627
|
+
y: requireCoordinate(options.y, 'y'),
|
|
628
|
+
node: null,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
const node = await this.findUiNode(options);
|
|
632
|
+
if (node.bounds.width <= 0 || node.bounds.height <= 0) {
|
|
633
|
+
throw new Error('The matched Android UI element has no tappable bounds.');
|
|
634
|
+
}
|
|
635
|
+
return { x: node.bounds.centerX, y: node.bounds.centerY, node };
|
|
370
636
|
}
|
|
371
637
|
|
|
372
|
-
async
|
|
373
|
-
|
|
374
|
-
|
|
638
|
+
async tap(options = {}) {
|
|
639
|
+
const target = await this.resolvePoint(options);
|
|
640
|
+
await this.shell({ command: `input tap ${target.x} ${target.y}`, signal: options.signal });
|
|
641
|
+
return {
|
|
642
|
+
success: true,
|
|
643
|
+
target: target.node ? summarizeNode(target.node) : { x: target.x, y: target.y },
|
|
644
|
+
...await this.screenshot({ signal: options.signal }),
|
|
645
|
+
};
|
|
375
646
|
}
|
|
376
647
|
|
|
377
|
-
async
|
|
378
|
-
|
|
648
|
+
async longPress(options = {}) {
|
|
649
|
+
const target = await this.resolvePoint(options);
|
|
650
|
+
const durationMs = clampNumber(options.durationMs, 650, 100, 30_000);
|
|
651
|
+
await this.shell({
|
|
652
|
+
command: `input swipe ${target.x} ${target.y} ${target.x} ${target.y} ${durationMs}`,
|
|
653
|
+
signal: options.signal,
|
|
654
|
+
});
|
|
655
|
+
return {
|
|
656
|
+
success: true,
|
|
657
|
+
target: target.node ? summarizeNode(target.node) : { x: target.x, y: target.y },
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async swipe({ x1, y1, x2, y2, durationMs = 300, signal } = {}) {
|
|
662
|
+
const points = {
|
|
663
|
+
x1: requireCoordinate(x1, 'x1'),
|
|
664
|
+
y1: requireCoordinate(y1, 'y1'),
|
|
665
|
+
x2: requireCoordinate(x2, 'x2'),
|
|
666
|
+
y2: requireCoordinate(y2, 'y2'),
|
|
667
|
+
};
|
|
668
|
+
const duration = clampNumber(durationMs, 300, 50, 30_000);
|
|
669
|
+
await this.shell({
|
|
670
|
+
command: `input swipe ${points.x1} ${points.y1} ${points.x2} ${points.y2} ${duration}`,
|
|
671
|
+
signal,
|
|
672
|
+
});
|
|
673
|
+
return { success: true, ...await this.screenshot({ signal }) };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async type({ text, textSelector, resourceId, description, className, clear = false, pressEnter, signal } = {}) {
|
|
677
|
+
if (text == null) throw new Error('Text is required.');
|
|
678
|
+
if (String(text).length > MAX_ANDROID_TEXT_CHARS) {
|
|
679
|
+
throw new Error(`Text exceeds the ${MAX_ANDROID_TEXT_CHARS}-character limit.`);
|
|
680
|
+
}
|
|
681
|
+
const selector = {
|
|
682
|
+
text: textSelector,
|
|
683
|
+
resourceId,
|
|
684
|
+
description,
|
|
685
|
+
className,
|
|
686
|
+
signal,
|
|
687
|
+
};
|
|
688
|
+
let target = null;
|
|
689
|
+
if (hasUiSelector(selector)) {
|
|
690
|
+
target = await this.resolvePoint(selector);
|
|
691
|
+
await this.shell({ command: `input tap ${target.x} ${target.y}`, signal });
|
|
692
|
+
}
|
|
693
|
+
if (clear === true) {
|
|
694
|
+
await this.shell({
|
|
695
|
+
command: 'input keyevent KEYCODE_MOVE_END; for i in $(seq 1 200); do input keyevent KEYCODE_DEL; done',
|
|
696
|
+
signal,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
379
699
|
// ADB input text encoding: %% = literal %, %s = space.
|
|
380
700
|
const encoded = String(text).replace(/%/g, '%%').replace(/ /g, '%s');
|
|
381
|
-
await this.shell(`input text '${shellEscape(encoded)}'
|
|
382
|
-
if (pressEnter) await this.shell('input keyevent KEYCODE_ENTER');
|
|
383
|
-
return {
|
|
701
|
+
if (encoded) await this.shell({ command: `input text '${shellEscape(encoded)}'`, signal });
|
|
702
|
+
if (pressEnter) await this.shell({ command: 'input keyevent KEYCODE_ENTER', signal });
|
|
703
|
+
return {
|
|
704
|
+
success: true,
|
|
705
|
+
...(target?.node ? { target: summarizeNode(target.node) } : {}),
|
|
706
|
+
};
|
|
384
707
|
}
|
|
385
708
|
|
|
386
709
|
async pressKey(keyOrObj) {
|
|
387
710
|
const raw = typeof keyOrObj === 'string' ? keyOrObj : (keyOrObj?.key || '');
|
|
711
|
+
const signal = typeof keyOrObj === 'object' ? keyOrObj?.signal : null;
|
|
388
712
|
const KEY_MAP = {
|
|
389
713
|
back: 'KEYCODE_BACK', home: 'KEYCODE_HOME', app_switch: 'KEYCODE_APP_SWITCH',
|
|
390
714
|
enter: 'KEYCODE_ENTER', del: 'KEYCODE_DEL', escape: 'KEYCODE_ESCAPE',
|
|
391
715
|
menu: 'KEYCODE_MENU', power: 'KEYCODE_POWER',
|
|
392
716
|
volume_up: 'KEYCODE_VOLUME_UP', volume_down: 'KEYCODE_VOLUME_DOWN',
|
|
393
717
|
};
|
|
394
|
-
const
|
|
395
|
-
|
|
718
|
+
const normalized = String(raw || '').trim();
|
|
719
|
+
const keycode = KEY_MAP[normalized.toLowerCase()]
|
|
720
|
+
|| (/^\d+$/.test(normalized) ? normalized : normalized.toUpperCase());
|
|
721
|
+
if (!/^\d+$/.test(keycode) && !/^KEYCODE_[A-Z0-9_]+$/.test(keycode)) {
|
|
722
|
+
throw new Error(`Unsupported Android key: ${normalized || '(empty)'}`);
|
|
723
|
+
}
|
|
724
|
+
await this.shell({ command: `input keyevent ${keycode}`, signal });
|
|
396
725
|
return { success: true };
|
|
397
726
|
}
|
|
398
727
|
|
|
399
|
-
async dumpUi(
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
728
|
+
async dumpUi({ includeNodes = true, signal } = {}) {
|
|
729
|
+
this.#requireSerial();
|
|
730
|
+
const devicePath = '/sdcard/window_dump.xml';
|
|
731
|
+
await this.shell({ command: 'uiautomator dump /sdcard/window_dump.xml', signal });
|
|
732
|
+
const xml = await this.shell({ command: `cat '${devicePath}'`, timeoutMs: 10_000, signal });
|
|
733
|
+
const nodes = parseUiDump(xml);
|
|
734
|
+
return {
|
|
735
|
+
xml,
|
|
736
|
+
devicePath,
|
|
737
|
+
nodeCount: nodes.length,
|
|
738
|
+
...(includeNodes === false ? {} : { nodes: nodes.slice(0, 200).map(summarizeNode) }),
|
|
739
|
+
};
|
|
404
740
|
}
|
|
405
741
|
|
|
406
|
-
async listApps({ includeSystem = false } = {}) {
|
|
407
|
-
const out = await this.shell(
|
|
742
|
+
async listApps({ includeSystem = false, signal } = {}) {
|
|
743
|
+
const out = await this.shell({
|
|
744
|
+
command: includeSystem ? 'pm list packages' : 'pm list packages -3',
|
|
745
|
+
signal,
|
|
746
|
+
});
|
|
408
747
|
const packages = out.trim().split('\n').filter(Boolean).map(l => l.replace('package:', '').trim());
|
|
409
748
|
return { packages };
|
|
410
749
|
}
|
|
411
750
|
|
|
412
|
-
async openApp({ packageName } = {}) {
|
|
751
|
+
async openApp({ packageName, activity, signal } = {}) {
|
|
413
752
|
if (!isSafeIdentifier(packageName)) throw new Error('Invalid package name');
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
753
|
+
if (activity) {
|
|
754
|
+
if (!isSafeActivity(activity)) throw new Error('Invalid Android activity name');
|
|
755
|
+
await this.shell({ command: `am start -n '${shellEscape(`${packageName}/${activity}`)}'`, signal });
|
|
756
|
+
} else {
|
|
757
|
+
await this.shell({
|
|
758
|
+
command: `monkey -p '${shellEscape(packageName)}' -c android.intent.category.LAUNCHER 1`,
|
|
759
|
+
signal,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
await delay(1500, signal);
|
|
763
|
+
return this.screenshot({ signal });
|
|
417
764
|
}
|
|
418
765
|
|
|
419
|
-
async openIntent({ action, dataUri, extras = {} } = {}) {
|
|
766
|
+
async openIntent({ action, dataUri, data, url, uri, packageName, component, mimeType, extras = {}, signal } = {}) {
|
|
420
767
|
const safeAction = isSafeIdentifier(action) ? action : 'android.intent.action.VIEW';
|
|
421
768
|
let cmd = `am start -a '${shellEscape(safeAction)}'`;
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
if (
|
|
769
|
+
const resolvedDataUri = dataUri || data || url || uri;
|
|
770
|
+
if (resolvedDataUri) {
|
|
771
|
+
if (String(resolvedDataUri).length > MAX_ANDROID_TEXT_CHARS) {
|
|
772
|
+
throw new Error('Android intent URI is too long.');
|
|
773
|
+
}
|
|
774
|
+
const validation = await validateAndroidIntentUrl(String(resolvedDataUri), { signal });
|
|
775
|
+
if (!validation.allowed) throw new Error('This Android intent URI is not permitted.');
|
|
776
|
+
cmd += ` -d '${shellEscape(resolvedDataUri)}'`;
|
|
777
|
+
}
|
|
778
|
+
if (packageName) {
|
|
779
|
+
if (!isSafeIdentifier(packageName)) throw new Error('Invalid Android package name');
|
|
780
|
+
cmd += ` -p '${shellEscape(packageName)}'`;
|
|
781
|
+
}
|
|
782
|
+
if (component) {
|
|
783
|
+
if (!isSafeComponent(component)) throw new Error('Invalid Android component name');
|
|
784
|
+
cmd += ` -n '${shellEscape(component)}'`;
|
|
785
|
+
}
|
|
786
|
+
if (mimeType) {
|
|
787
|
+
if (!isSafeMimeType(mimeType)) throw new Error('Invalid Android MIME type');
|
|
788
|
+
cmd += ` -t '${shellEscape(mimeType)}'`;
|
|
789
|
+
}
|
|
790
|
+
const extraEntries = Object.entries(extras || {});
|
|
791
|
+
if (extraEntries.length > MAX_ANDROID_INTENT_EXTRAS) {
|
|
792
|
+
throw new Error(`Android intent extras exceed the ${MAX_ANDROID_INTENT_EXTRAS}-item limit.`);
|
|
425
793
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
794
|
+
for (const [k, v] of extraEntries) {
|
|
795
|
+
if (!isSafeIdentifier(k)) throw new Error(`Invalid Android intent extra name: ${k}`);
|
|
796
|
+
if (String(v).length > MAX_ANDROID_TEXT_CHARS) {
|
|
797
|
+
throw new Error(`Android intent extra is too long: ${k}`);
|
|
798
|
+
}
|
|
799
|
+
cmd += ` --es '${shellEscape(k)}' '${shellEscape(v)}'`;
|
|
800
|
+
}
|
|
801
|
+
await this.shell({ command: cmd, signal });
|
|
802
|
+
await delay(2000, signal);
|
|
803
|
+
return this.screenshot({ signal });
|
|
429
804
|
}
|
|
430
805
|
|
|
431
|
-
async waitFor(
|
|
432
|
-
|
|
806
|
+
async waitFor(options = {}) {
|
|
807
|
+
if (!hasUiSelector(options)) {
|
|
808
|
+
throw new Error('android_wait_for requires at least one UI selector.');
|
|
809
|
+
}
|
|
810
|
+
const timeoutMs = clampNumber(options.timeoutMs ?? options.timeout, 20_000, 100, 120_000);
|
|
811
|
+
const intervalMs = clampNumber(options.intervalMs, 1500, 100, 5000);
|
|
812
|
+
const deadline = Date.now() + timeoutMs;
|
|
433
813
|
while (Date.now() < deadline) {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
814
|
+
if (options.signal?.aborted) {
|
|
815
|
+
throw abortError(options.signal, 'Waiting for the Android UI was aborted.');
|
|
816
|
+
}
|
|
817
|
+
const dump = await this.dumpUi({ includeNodes: true, signal: options.signal });
|
|
818
|
+
const node = findBestNode(dump.nodes, options);
|
|
819
|
+
if (node) {
|
|
820
|
+
return {
|
|
821
|
+
found: true,
|
|
822
|
+
ready: true,
|
|
823
|
+
node: summarizeNode(node),
|
|
824
|
+
...(options.screenshot === false ? {} : await this.screenshot({ signal: options.signal })),
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
await delay(intervalMs, options.signal);
|
|
437
828
|
}
|
|
438
|
-
return { ready: false };
|
|
829
|
+
return { found: false, ready: false, timeoutMs };
|
|
439
830
|
}
|
|
440
831
|
|
|
441
|
-
async installApk({ apkPath } = {}) {
|
|
832
|
+
async installApk({ apkPath, signal } = {}) {
|
|
442
833
|
if (!apkPath) throw new Error('apkPath required');
|
|
834
|
+
const resolvedPath = fs.realpathSync(apkPath);
|
|
835
|
+
const packageStat = fs.statSync(resolvedPath);
|
|
836
|
+
if (!packageStat.isFile()) throw new Error('Android package path must be a file.');
|
|
837
|
+
if (packageStat.size > MAX_ANDROID_PACKAGE_BYTES) {
|
|
838
|
+
throw new Error('Android package exceeds the 1GB installation limit.');
|
|
839
|
+
}
|
|
840
|
+
const extension = path.extname(resolvedPath).toLowerCase();
|
|
841
|
+
if (extension !== '.apk' && extension !== '.apks') {
|
|
842
|
+
throw new Error('Android package must be an .apk or universal .apks bundle.');
|
|
843
|
+
}
|
|
443
844
|
const serial = this.#requireSerial();
|
|
444
845
|
const adb = adbBin(this.sdkDir);
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
846
|
+
let installPath = resolvedPath;
|
|
847
|
+
let extractionDir = null;
|
|
848
|
+
try {
|
|
849
|
+
if (extension === '.apks') {
|
|
850
|
+
extractionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'neoagent-apks-'));
|
|
851
|
+
const listing = await runProcess('unzip', ['-l', resolvedPath, 'universal.apk'], {
|
|
852
|
+
timeoutMs: 30_000,
|
|
853
|
+
maxOutputBytes: 1024 * 1024,
|
|
854
|
+
signal,
|
|
855
|
+
});
|
|
856
|
+
const universalSize = String(listing.stdout)
|
|
857
|
+
.split(/\r?\n/)
|
|
858
|
+
.map((line) => line.trim().match(/^(\d+)\s+.*\suniversal\.apk$/i))
|
|
859
|
+
.find(Boolean);
|
|
860
|
+
if (!universalSize) {
|
|
861
|
+
throw new Error('The .apks bundle does not contain universal.apk. Export it with bundletool --mode=universal.');
|
|
862
|
+
}
|
|
863
|
+
if (Number(universalSize[1]) > MAX_ANDROID_PACKAGE_BYTES) {
|
|
864
|
+
throw new Error('The universal APK exceeds the 1GB installation limit.');
|
|
865
|
+
}
|
|
866
|
+
await runProcess('unzip', ['-jo', resolvedPath, 'universal.apk', '-d', extractionDir], {
|
|
867
|
+
timeoutMs: 120_000,
|
|
868
|
+
maxOutputBytes: 1024 * 1024,
|
|
869
|
+
signal,
|
|
870
|
+
});
|
|
871
|
+
installPath = path.join(extractionDir, 'universal.apk');
|
|
872
|
+
if (!fs.existsSync(installPath)) {
|
|
873
|
+
throw new Error('The .apks bundle does not contain universal.apk. Export it with bundletool --mode=universal.');
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
const result = await runProcess(adb, ['-s', serial, 'install', '-r', installPath], {
|
|
877
|
+
timeoutMs: 5 * 60 * 1000,
|
|
878
|
+
maxOutputBytes: 4 * 1024 * 1024,
|
|
879
|
+
signal,
|
|
453
880
|
});
|
|
454
|
-
|
|
455
|
-
|
|
881
|
+
if (!String(result.stdout).includes('Success')) {
|
|
882
|
+
throw new Error(String(result.stderr || result.stdout || 'adb install did not report success'));
|
|
883
|
+
}
|
|
884
|
+
return { success: true, output: result.stdout };
|
|
885
|
+
} finally {
|
|
886
|
+
if (extractionDir) fs.rmSync(extractionDir, { recursive: true, force: true });
|
|
887
|
+
}
|
|
456
888
|
}
|
|
457
889
|
|
|
458
890
|
// ── Private helpers ───────────────────────────────────────────────────────
|
|
@@ -468,52 +900,30 @@ class AndroidController {
|
|
|
468
900
|
try { process.kill(Number(pid), 0); return true; } catch { return false; }
|
|
469
901
|
}
|
|
470
902
|
|
|
471
|
-
#
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
return new Promise((resolve, reject) => {
|
|
478
|
-
const proc = spawn(adbBin(this.sdkDir), ['-s', serial, ...args]);
|
|
479
|
-
const chunks = [];
|
|
480
|
-
const errors = [];
|
|
481
|
-
let settled = false;
|
|
482
|
-
const timer = setTimeout(() => {
|
|
483
|
-
if (settled) return;
|
|
484
|
-
settled = true;
|
|
485
|
-
try { proc.kill('SIGKILL'); } catch {}
|
|
486
|
-
reject(new Error('adb capture timed out'));
|
|
487
|
-
}, 15000);
|
|
488
|
-
timer.unref?.();
|
|
489
|
-
proc.stdout?.on('data', (chunk) => chunks.push(chunk));
|
|
490
|
-
proc.stderr?.on('data', (chunk) => errors.push(chunk));
|
|
491
|
-
proc.on('error', (error) => {
|
|
492
|
-
if (settled) return;
|
|
493
|
-
settled = true;
|
|
494
|
-
clearTimeout(timer);
|
|
495
|
-
reject(error);
|
|
496
|
-
});
|
|
497
|
-
proc.on('close', (code) => {
|
|
498
|
-
if (settled) return;
|
|
499
|
-
settled = true;
|
|
500
|
-
clearTimeout(timer);
|
|
501
|
-
if (code === 0 && chunks.length) {
|
|
502
|
-
resolve(Buffer.concat(chunks));
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
const message = Buffer.concat(errors).toString('utf8').trim();
|
|
506
|
-
reject(new Error(message || `adb capture exit ${code}`));
|
|
507
|
-
});
|
|
903
|
+
async #adbCaptureAsync(serial, args, options = {}) {
|
|
904
|
+
const result = await runProcess(adbBin(this.sdkDir), ['-s', serial, ...args], {
|
|
905
|
+
timeoutMs: 15_000,
|
|
906
|
+
maxOutputBytes: 20 * 1024 * 1024,
|
|
907
|
+
encoding: null,
|
|
908
|
+
signal: options.signal,
|
|
508
909
|
});
|
|
910
|
+
if (!result.stdout.length) throw new Error('ADB capture returned no data.');
|
|
911
|
+
return result.stdout;
|
|
509
912
|
}
|
|
510
913
|
|
|
511
|
-
#saveArtifact(data) {
|
|
914
|
+
async #saveArtifact(data, options = {}) {
|
|
512
915
|
if (!data || !this.artifactStore) return null;
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
916
|
+
const image = validateImageBuffer(data, { allowedTypes: ['image/png'] });
|
|
917
|
+
const artifact = await this.artifactStore.createBufferArtifact(this.userId, {
|
|
918
|
+
kind: 'android-screenshot',
|
|
919
|
+
backend: 'android-emulator',
|
|
920
|
+
extension: image.extension,
|
|
921
|
+
contentType: image.contentType,
|
|
922
|
+
filenameBase: 'android-emulator-screenshot',
|
|
923
|
+
content: image.buffer,
|
|
924
|
+
signal: options.signal,
|
|
925
|
+
});
|
|
926
|
+
return artifact.url;
|
|
517
927
|
}
|
|
518
928
|
|
|
519
929
|
// ── Setup pipeline ────────────────────────────────────────────────────────
|
|
@@ -523,21 +933,59 @@ class AndroidController {
|
|
|
523
933
|
for (let i = 0; i < ADB_PORT_SLOTS; i++) {
|
|
524
934
|
const slot = (base + i) % ADB_PORT_SLOTS;
|
|
525
935
|
const port = ADB_PORT_BASE + slot * 2;
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
srv.listen(port, '127.0.0.1', () => srv.close(() => resolve(true)));
|
|
529
|
-
srv.on('error', () => resolve(false));
|
|
530
|
-
});
|
|
936
|
+
if (RESERVED_ADB_PORTS.has(port)) continue;
|
|
937
|
+
const free = await this.#isPortPairFree(port);
|
|
531
938
|
if (free) {
|
|
939
|
+
RESERVED_ADB_PORTS.add(port);
|
|
532
940
|
this.adbPort = port;
|
|
533
941
|
this.adbSerial = `emulator-${port}`;
|
|
534
942
|
return;
|
|
535
943
|
}
|
|
536
944
|
}
|
|
537
|
-
throw new Error(`No free ADB port in range ${ADB_PORT_BASE}–${ADB_PORT_BASE + ADB_PORT_SLOTS * 2}`);
|
|
945
|
+
throw new Error(`No free ADB port pair in range ${ADB_PORT_BASE}–${ADB_PORT_BASE + (ADB_PORT_SLOTS * 2) - 1}`);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
async #isPortPairFree(consolePort) {
|
|
949
|
+
const servers = [];
|
|
950
|
+
const listen = (port) => new Promise((resolve) => {
|
|
951
|
+
const server = net.createServer();
|
|
952
|
+
server.unref();
|
|
953
|
+
server.once('error', () => resolve(false));
|
|
954
|
+
server.listen(port, '127.0.0.1', () => {
|
|
955
|
+
servers.push(server);
|
|
956
|
+
resolve(true);
|
|
957
|
+
});
|
|
958
|
+
});
|
|
959
|
+
try {
|
|
960
|
+
return await listen(consolePort) && await listen(consolePort + 1);
|
|
961
|
+
} finally {
|
|
962
|
+
await Promise.allSettled(servers.map((server) => new Promise((resolve) => server.close(resolve))));
|
|
963
|
+
}
|
|
538
964
|
}
|
|
539
965
|
|
|
540
|
-
async #
|
|
966
|
+
async #waitForPidExit(pid, timeoutMs) {
|
|
967
|
+
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
|
|
968
|
+
while (this.#isPidAlive(pid) && Date.now() < deadline) {
|
|
969
|
+
await delay(100);
|
|
970
|
+
}
|
|
971
|
+
return !this.#isPidAlive(pid);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
async #terminateOwnedEmulatorProcess() {
|
|
975
|
+
const proc = this.emulatorProcess;
|
|
976
|
+
if (!proc) return;
|
|
977
|
+
if (proc.exitCode == null && proc.signalCode == null) {
|
|
978
|
+
try { proc.kill('SIGTERM'); } catch {}
|
|
979
|
+
await this.#waitForPidExit(proc.pid, 3000);
|
|
980
|
+
}
|
|
981
|
+
if (proc.pid && this.#isPidAlive(proc.pid)) {
|
|
982
|
+
try { proc.kill('SIGKILL'); } catch {}
|
|
983
|
+
await this.#waitForPidExit(proc.pid, 2000);
|
|
984
|
+
}
|
|
985
|
+
if (this.emulatorProcess === proc) this.emulatorProcess = null;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
async #setup(options = {}) {
|
|
541
989
|
const progress = msg => {
|
|
542
990
|
console.log(`[Android] ${msg}`);
|
|
543
991
|
writeState(this.userId, { startupPhase: msg });
|
|
@@ -549,52 +997,113 @@ class AndroidController {
|
|
|
549
997
|
this.sdkDir = existing;
|
|
550
998
|
progress(`Found existing Android SDK at ${existing}`);
|
|
551
999
|
} else {
|
|
552
|
-
progress('
|
|
553
|
-
await ensureSdk(this.sdkDir, progress);
|
|
554
|
-
await ensurePackages(this.sdkDir, progress);
|
|
1000
|
+
progress('Preparing Android SDK…');
|
|
555
1001
|
}
|
|
556
|
-
|
|
557
|
-
await this
|
|
1002
|
+
await ensureSdkProvisioned(this.sdkDir, progress, options);
|
|
1003
|
+
await ensureAvd(this.sdkDir, this.avdName, this.avdHome, progress, options);
|
|
1004
|
+
await this.#startEmulatorProcess(progress, options);
|
|
558
1005
|
} catch (err) {
|
|
559
1006
|
console.error(`[Android] Setup failed: ${err.message}`);
|
|
560
|
-
|
|
1007
|
+
await this.#terminateOwnedEmulatorProcess();
|
|
1008
|
+
RESERVED_ADB_PORTS.delete(this.adbPort);
|
|
1009
|
+
writeState(this.userId, {
|
|
1010
|
+
bootstrapped: false,
|
|
1011
|
+
starting: false,
|
|
1012
|
+
startupPhase: 'Failed',
|
|
1013
|
+
lastStartError: err.message,
|
|
1014
|
+
pid: null,
|
|
1015
|
+
adbSerial: null,
|
|
1016
|
+
});
|
|
1017
|
+
throw err;
|
|
561
1018
|
}
|
|
562
1019
|
}
|
|
563
1020
|
|
|
564
|
-
async #startEmulatorProcess(progress) {
|
|
1021
|
+
async #startEmulatorProcess(progress, options = {}) {
|
|
565
1022
|
progress('Starting Android emulator…');
|
|
566
|
-
const env = {
|
|
567
|
-
|
|
1023
|
+
const env = {
|
|
1024
|
+
...process.env,
|
|
1025
|
+
ANDROID_AVD_HOME: this.avdHome,
|
|
1026
|
+
ANDROID_SDK_ROOT: this.sdkDir,
|
|
1027
|
+
ANDROID_HOME: this.sdkDir,
|
|
1028
|
+
};
|
|
1029
|
+
const emulatorArgs = [
|
|
568
1030
|
'-avd', this.avdName,
|
|
569
|
-
'-no-
|
|
1031
|
+
'-no-audio', '-no-boot-anim',
|
|
570
1032
|
'-port', String(this.adbPort),
|
|
571
|
-
'-gpu', 'swiftshader_indirect',
|
|
572
1033
|
'-partition-size', '800',
|
|
573
|
-
]
|
|
574
|
-
|
|
575
|
-
proc
|
|
576
|
-
|
|
577
|
-
|
|
1034
|
+
];
|
|
1035
|
+
if (options.headless !== false) emulatorArgs.splice(2, 0, '-no-window');
|
|
1036
|
+
const proc = spawn(emulatorBin(this.sdkDir), emulatorArgs, {
|
|
1037
|
+
env,
|
|
1038
|
+
detached: false,
|
|
1039
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1040
|
+
});
|
|
1041
|
+
this.emulatorProcess = proc;
|
|
578
1042
|
|
|
579
|
-
|
|
1043
|
+
let launchOutput = '';
|
|
1044
|
+
const collectLaunchOutput = (chunk) => {
|
|
1045
|
+
if (launchOutput.length >= 64 * 1024) return;
|
|
1046
|
+
launchOutput += chunk.toString().slice(0, (64 * 1024) - launchOutput.length);
|
|
1047
|
+
};
|
|
1048
|
+
proc.stdout.on('data', collectLaunchOutput);
|
|
1049
|
+
proc.stderr.on('data', collectLaunchOutput);
|
|
1050
|
+
writeState(this.userId, { pid: proc.pid || null, adbSerial: proc.pid ? this.adbSerial : null });
|
|
1051
|
+
|
|
1052
|
+
let bootReady = false;
|
|
1053
|
+
let rejectProcessFailure;
|
|
1054
|
+
const processFailure = new Promise((_, reject) => { rejectProcessFailure = reject; });
|
|
1055
|
+
proc.once('error', (error) => rejectProcessFailure(error));
|
|
1056
|
+
proc.on('exit', (code, exitSignal) => {
|
|
580
1057
|
console.log(`[Android] Emulator exited with code ${code}`);
|
|
581
|
-
|
|
1058
|
+
this.emulatorProcess = null;
|
|
1059
|
+
RESERVED_ADB_PORTS.delete(this.adbPort);
|
|
1060
|
+
const current = readState(this.userId);
|
|
1061
|
+
if (Number(current.pid) === Number(proc.pid)) {
|
|
1062
|
+
writeState(this.userId, { bootstrapped: false, starting: false, pid: null, adbSerial: null });
|
|
1063
|
+
}
|
|
1064
|
+
if (!bootReady) {
|
|
1065
|
+
const details = launchOutput.trim().slice(-4000);
|
|
1066
|
+
rejectProcessFailure(new Error(
|
|
1067
|
+
`Android emulator exited before boot completed (${exitSignal || (code ?? 'unknown')}).${details ? ` ${details}` : ''}`,
|
|
1068
|
+
));
|
|
1069
|
+
}
|
|
582
1070
|
});
|
|
583
1071
|
|
|
584
1072
|
progress('Waiting for Android to boot (can take 2–5 min on first run)…');
|
|
585
1073
|
// Abort early if the emulator process dies, instead of polling until timeout.
|
|
586
|
-
|
|
1074
|
+
const cancelEmulator = () => {
|
|
1075
|
+
try { proc.kill('SIGTERM'); } catch {}
|
|
1076
|
+
};
|
|
1077
|
+
options.signal?.addEventListener('abort', cancelEmulator, { once: true });
|
|
1078
|
+
try {
|
|
1079
|
+
await Promise.race([
|
|
1080
|
+
this.#waitForBoot({
|
|
1081
|
+
isAlive: () => this.#isPidAlive(proc.pid),
|
|
1082
|
+
signal: options.signal,
|
|
1083
|
+
}),
|
|
1084
|
+
processFailure,
|
|
1085
|
+
]);
|
|
1086
|
+
bootReady = true;
|
|
1087
|
+
} finally {
|
|
1088
|
+
options.signal?.removeEventListener('abort', cancelEmulator);
|
|
1089
|
+
}
|
|
587
1090
|
|
|
588
1091
|
writeState(this.userId, { bootstrapped: true, starting: false, startupPhase: null, lastStartError: null });
|
|
589
1092
|
console.log(`[Android] Emulator ready on ${this.adbSerial}`);
|
|
590
1093
|
|
|
591
1094
|
// Set wallpaper — best-effort, never fails the boot sequence.
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
})
|
|
1095
|
+
try {
|
|
1096
|
+
await this.#setWallpaper(this.adbSerial, { signal: options.signal });
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
if (options.signal?.aborted) throw error;
|
|
1099
|
+
console.warn(`[Android] Wallpaper not set: ${error.message}`);
|
|
1100
|
+
}
|
|
1101
|
+
if (!this.#isPidAlive(proc.pid)) {
|
|
1102
|
+
throw new Error('Android emulator exited immediately after boot completed.');
|
|
1103
|
+
}
|
|
595
1104
|
}
|
|
596
1105
|
|
|
597
|
-
async #waitForBoot({ timeoutMs = 10 * 60 * 1000, isAlive = () => true } = {}) {
|
|
1106
|
+
async #waitForBoot({ timeoutMs = 10 * 60 * 1000, isAlive = () => true, signal } = {}) {
|
|
598
1107
|
const adb = adbBin(this.sdkDir);
|
|
599
1108
|
const deadline = Date.now() + timeoutMs;
|
|
600
1109
|
while (Date.now() < deadline) {
|
|
@@ -602,41 +1111,65 @@ class AndroidController {
|
|
|
602
1111
|
throw new Error('Emulator process exited before Android finished booting (check virtualization/KVM support and the system image).');
|
|
603
1112
|
}
|
|
604
1113
|
try {
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
1114
|
+
const result = await runProcess(
|
|
1115
|
+
adb,
|
|
1116
|
+
['-s', this.adbSerial, 'shell', 'getprop', 'sys.boot_completed'],
|
|
1117
|
+
{ timeoutMs: 5000, maxOutputBytes: 64 * 1024, signal },
|
|
1118
|
+
);
|
|
1119
|
+
if (result.stdout.trim() === '1') return;
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
if (signal?.aborted) throw error;
|
|
1122
|
+
}
|
|
1123
|
+
await delay(3000, signal);
|
|
609
1124
|
}
|
|
610
1125
|
throw new Error('Emulator did not boot within timeout');
|
|
611
1126
|
}
|
|
612
1127
|
|
|
613
|
-
async #setWallpaper(serial) {
|
|
1128
|
+
async #setWallpaper(serial, options = {}) {
|
|
614
1129
|
if (!fs.existsSync(LOGO_PATH)) return;
|
|
615
1130
|
const adb = adbBin(this.sdkDir);
|
|
616
1131
|
|
|
617
1132
|
// Try to gain root access (works on AOSP default images).
|
|
618
|
-
|
|
619
|
-
|
|
1133
|
+
await runProcess(adb, ['-s', serial, 'root'], {
|
|
1134
|
+
timeoutMs: 5000,
|
|
1135
|
+
maxOutputBytes: 64 * 1024,
|
|
1136
|
+
signal: options.signal,
|
|
1137
|
+
}).catch(() => {});
|
|
1138
|
+
await delay(1500, options.signal);
|
|
620
1139
|
|
|
621
1140
|
// Push PNG to device sdcard.
|
|
622
|
-
|
|
623
|
-
|
|
1141
|
+
await runProcess(adb, ['-s', serial, 'push', LOGO_PATH, '/sdcard/neoagent-wallpaper.png'], {
|
|
1142
|
+
timeoutMs: 15_000,
|
|
1143
|
+
maxOutputBytes: 1024 * 1024,
|
|
1144
|
+
signal: options.signal,
|
|
1145
|
+
});
|
|
624
1146
|
|
|
625
1147
|
// cmd wallpaper set-stream reads PNG from stdin (Android 7.1+).
|
|
626
1148
|
const logoData = fs.readFileSync(LOGO_PATH);
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
1149
|
+
try {
|
|
1150
|
+
await runProcess(adb, ['-s', serial, 'shell', 'cmd', 'wallpaper', 'set-stream'], {
|
|
1151
|
+
input: logoData,
|
|
1152
|
+
timeoutMs: 15_000,
|
|
1153
|
+
maxOutputBytes: 1024 * 1024,
|
|
1154
|
+
signal: options.signal,
|
|
1155
|
+
});
|
|
631
1156
|
console.log('[Android] Wallpaper set');
|
|
632
1157
|
return;
|
|
633
|
-
}
|
|
1158
|
+
} catch {}
|
|
634
1159
|
|
|
635
1160
|
// Fallback: direct file copy for rooted images (Android 11 AOSP).
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
1161
|
+
for (const command of [
|
|
1162
|
+
'cp /sdcard/neoagent-wallpaper.png /data/system/users/0/wallpaper',
|
|
1163
|
+
'chmod 600 /data/system/users/0/wallpaper',
|
|
1164
|
+
'chown system:system /data/system/users/0/wallpaper',
|
|
1165
|
+
'am broadcast -a android.intent.action.WALLPAPER_CHANGED',
|
|
1166
|
+
]) {
|
|
1167
|
+
await runProcess(adb, ['-s', serial, 'shell', command], {
|
|
1168
|
+
timeoutMs: 5000,
|
|
1169
|
+
maxOutputBytes: 1024 * 1024,
|
|
1170
|
+
signal: options.signal,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
640
1173
|
console.log('[Android] Wallpaper set via direct copy');
|
|
641
1174
|
}
|
|
642
1175
|
}
|