@tapflowio/android-agent 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +34 -0
- package/bin/.gitkeep +0 -0
- package/bin/scrcpy-server.jar +0 -0
- package/dist/AdbWrapper.d.ts +28 -0
- package/dist/AdbWrapper.d.ts.map +1 -0
- package/dist/AdbWrapper.js +138 -0
- package/dist/AdbWrapper.js.map +1 -0
- package/dist/AndroidAgent.d.ts +44 -0
- package/dist/AndroidAgent.d.ts.map +1 -0
- package/dist/AndroidAgent.js +695 -0
- package/dist/AndroidAgent.js.map +1 -0
- package/dist/AndroidTouchHelper.d.ts +23 -0
- package/dist/AndroidTouchHelper.d.ts.map +1 -0
- package/dist/AndroidTouchHelper.js +73 -0
- package/dist/AndroidTouchHelper.js.map +1 -0
- package/dist/EmulatorLauncher.d.ts +6 -0
- package/dist/EmulatorLauncher.d.ts.map +1 -0
- package/dist/EmulatorLauncher.js +75 -0
- package/dist/EmulatorLauncher.js.map +1 -0
- package/dist/adb.d.ts +7 -0
- package/dist/adb.d.ts.map +1 -0
- package/dist/adb.js +41 -0
- package/dist/adb.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/scrcpy/ScrcpyControl.d.ts +17 -0
- package/dist/scrcpy/ScrcpyControl.d.ts.map +1 -0
- package/dist/scrcpy/ScrcpyControl.js +99 -0
- package/dist/scrcpy/ScrcpyControl.js.map +1 -0
- package/dist/scrcpy/ScrcpySession.d.ts +20 -0
- package/dist/scrcpy/ScrcpySession.d.ts.map +1 -0
- package/dist/scrcpy/ScrcpySession.js +127 -0
- package/dist/scrcpy/ScrcpySession.js.map +1 -0
- package/dist/scrcpy/ScrcpyVideo.d.ts +24 -0
- package/dist/scrcpy/ScrcpyVideo.d.ts.map +1 -0
- package/dist/scrcpy/ScrcpyVideo.js +145 -0
- package/dist/scrcpy/ScrcpyVideo.js.map +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import { WebSocket } from 'ws';
|
|
3
|
+
import { createLogger } from '@tapflowio/agent-core';
|
|
4
|
+
import { createResourceSampler, registerStreamWs } from '@tapflowio/agent-core/utils';
|
|
5
|
+
import { AdbWrapper } from './AdbWrapper.js';
|
|
6
|
+
import { EmulatorLauncher } from './EmulatorLauncher.js';
|
|
7
|
+
import { AndroidTouchHelper } from './AndroidTouchHelper.js';
|
|
8
|
+
import { ScrcpySession } from './scrcpy/ScrcpySession.js';
|
|
9
|
+
const logger = createLogger('android-agent');
|
|
10
|
+
// Parse H.264 SPS NAL unit to extract frame dimensions.
|
|
11
|
+
// scrcpy sends a new SPS (inside an IDR keyframe) whenever the capture size changes —
|
|
12
|
+
// e.g. portrait→landscape for landscape-aware apps. This lets the agent track the
|
|
13
|
+
// actual video dimensions and keep ScrcpyControl.screenSize in sync without guessing.
|
|
14
|
+
function parseSpsFromNal(nal) {
|
|
15
|
+
// Locate NAL header byte after Annex B start code
|
|
16
|
+
let offset = 0;
|
|
17
|
+
if (nal.length >= 4 && nal[0] === 0 && nal[1] === 0 && nal[2] === 0 && nal[3] === 1)
|
|
18
|
+
offset = 4;
|
|
19
|
+
else if (nal.length >= 3 && nal[0] === 0 && nal[1] === 0 && nal[2] === 1)
|
|
20
|
+
offset = 3;
|
|
21
|
+
else
|
|
22
|
+
return null;
|
|
23
|
+
if (offset >= nal.length || (nal[offset] & 0x1f) !== 7)
|
|
24
|
+
return null; // not SPS
|
|
25
|
+
// Collect RBSP bytes (remove emulation-prevention 0x03 bytes)
|
|
26
|
+
const bytes = [];
|
|
27
|
+
for (let i = offset + 1; i < nal.length; i++) {
|
|
28
|
+
const b = nal[i];
|
|
29
|
+
const len = bytes.length;
|
|
30
|
+
if (len >= 2 && b === 3 && bytes[len - 1] === 0 && bytes[len - 2] === 0)
|
|
31
|
+
continue;
|
|
32
|
+
bytes.push(b);
|
|
33
|
+
}
|
|
34
|
+
let bit = 0;
|
|
35
|
+
const readU = (n) => {
|
|
36
|
+
let v = 0;
|
|
37
|
+
for (let i = 0; i < n; i++) {
|
|
38
|
+
if ((bit >> 3) >= bytes.length)
|
|
39
|
+
throw new Error('truncated');
|
|
40
|
+
v = (v << 1) | ((bytes[bit >> 3] >> (7 - (bit & 7))) & 1);
|
|
41
|
+
bit++;
|
|
42
|
+
}
|
|
43
|
+
return v;
|
|
44
|
+
};
|
|
45
|
+
const readUE = () => {
|
|
46
|
+
let lz = 0;
|
|
47
|
+
while (readU(1) === 0) {
|
|
48
|
+
if (++lz > 31)
|
|
49
|
+
throw new Error('overflow');
|
|
50
|
+
}
|
|
51
|
+
return lz === 0 ? 0 : (1 << lz) - 1 + readU(lz);
|
|
52
|
+
};
|
|
53
|
+
const readSE = () => { const v = readUE(); return v % 2 === 0 ? -(v >> 1) : (v + 1) >> 1; };
|
|
54
|
+
try {
|
|
55
|
+
const profile = readU(8);
|
|
56
|
+
readU(8);
|
|
57
|
+
readU(8); // constraint_flags, level_idc
|
|
58
|
+
readUE(); // seq_parameter_set_id
|
|
59
|
+
let subWC = 2, subHC = 2; // 4:2:0 defaults
|
|
60
|
+
if ([100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135].includes(profile)) {
|
|
61
|
+
const cfmt = readUE();
|
|
62
|
+
subWC = cfmt === 0 ? 1 : cfmt === 2 ? 2 : cfmt === 3 ? 1 : 2;
|
|
63
|
+
subHC = cfmt === 0 ? 1 : cfmt === 1 ? 2 : 1;
|
|
64
|
+
if (cfmt === 3)
|
|
65
|
+
readU(1); // separate_colour_plane_flag
|
|
66
|
+
readUE();
|
|
67
|
+
readUE(); // bit_depth_luma/chroma_minus8
|
|
68
|
+
readU(1); // qpprime_y_zero_transform_bypass_flag
|
|
69
|
+
if (readU(1))
|
|
70
|
+
return null; // seq_scaling_matrix_present_flag — skip
|
|
71
|
+
}
|
|
72
|
+
readUE(); // log2_max_frame_num_minus4
|
|
73
|
+
const pocType = readUE();
|
|
74
|
+
if (pocType === 0)
|
|
75
|
+
readUE();
|
|
76
|
+
else if (pocType === 1) {
|
|
77
|
+
readU(1);
|
|
78
|
+
readSE();
|
|
79
|
+
readSE();
|
|
80
|
+
const n = readUE();
|
|
81
|
+
for (let i = 0; i < n; i++)
|
|
82
|
+
readSE();
|
|
83
|
+
}
|
|
84
|
+
readUE();
|
|
85
|
+
readU(1); // max_num_ref_frames, gaps_in_frame_num_value_allowed_flag
|
|
86
|
+
const codedW = (readUE() + 1) * 16;
|
|
87
|
+
const mapH = readUE();
|
|
88
|
+
const frameMbsOnly = readU(1);
|
|
89
|
+
const codedH = (mapH + 1) * 16 * (frameMbsOnly ? 1 : 2);
|
|
90
|
+
if (!frameMbsOnly)
|
|
91
|
+
readU(1); // mb_adaptive_frame_field_flag
|
|
92
|
+
readU(1); // direct_8x8_inference_flag
|
|
93
|
+
let w = codedW, h = codedH;
|
|
94
|
+
if (readU(1)) { // frame_cropping_flag
|
|
95
|
+
const cl = readUE(), cr = readUE(), ct = readUE(), cb = readUE();
|
|
96
|
+
w = codedW - (cl + cr) * subWC;
|
|
97
|
+
h = codedH - (ct + cb) * subHC * (frameMbsOnly ? 1 : 2);
|
|
98
|
+
}
|
|
99
|
+
return { width: w, height: h };
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const ANDROID_BUTTONS = [
|
|
106
|
+
{ name: 'home', accessibilityTitle: 'Home', keyCode: 3 },
|
|
107
|
+
{ name: 'back', accessibilityTitle: 'Back', keyCode: 4 },
|
|
108
|
+
{ name: 'recent_apps', accessibilityTitle: 'Recent Apps', keyCode: 187 },
|
|
109
|
+
{ name: 'volume_up', accessibilityTitle: 'Volume Up', keyCode: 24 },
|
|
110
|
+
{ name: 'volume_down', accessibilityTitle: 'Volume Down', keyCode: 25 },
|
|
111
|
+
{ name: 'power', accessibilityTitle: 'Power', keyCode: 26 },
|
|
112
|
+
];
|
|
113
|
+
export class AndroidAgent {
|
|
114
|
+
adb;
|
|
115
|
+
launcher;
|
|
116
|
+
ws = null;
|
|
117
|
+
deviceStates = new Map();
|
|
118
|
+
relayUrl = null;
|
|
119
|
+
resourcesTimer = null;
|
|
120
|
+
resources = createResourceSampler();
|
|
121
|
+
deviceFilter;
|
|
122
|
+
constructor(options = {}, adb) {
|
|
123
|
+
this.adb = adb ?? new AdbWrapper();
|
|
124
|
+
this.launcher = new EmulatorLauncher();
|
|
125
|
+
this.deviceFilter = options.deviceFilter;
|
|
126
|
+
}
|
|
127
|
+
get sessionId() {
|
|
128
|
+
const first = this.deviceStates.values().next().value;
|
|
129
|
+
return first?.sessionId ?? null;
|
|
130
|
+
}
|
|
131
|
+
async connect(relayUrl) {
|
|
132
|
+
this.relayUrl = relayUrl;
|
|
133
|
+
const allDevices = await this.adb.listDevices();
|
|
134
|
+
const devices = this.deviceFilter
|
|
135
|
+
? allDevices.filter((d) => d.name === this.deviceFilter || d.id === this.deviceFilter)
|
|
136
|
+
: allDevices;
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const ws = new WebSocket(relayUrl);
|
|
139
|
+
ws.once('open', () => {
|
|
140
|
+
ws.send(JSON.stringify({
|
|
141
|
+
type: 'agent:register',
|
|
142
|
+
platform: 'android',
|
|
143
|
+
agentName: os.hostname(),
|
|
144
|
+
devices: devices.map((d) => ({
|
|
145
|
+
id: d.id,
|
|
146
|
+
name: d.name,
|
|
147
|
+
platform: d.platform,
|
|
148
|
+
status: d.status,
|
|
149
|
+
osVersion: d.osVersion,
|
|
150
|
+
})),
|
|
151
|
+
}));
|
|
152
|
+
});
|
|
153
|
+
ws.once('message', (data) => {
|
|
154
|
+
const msg = JSON.parse(data.toString());
|
|
155
|
+
if (msg.type === 'agent:registered') {
|
|
156
|
+
this.ws = ws;
|
|
157
|
+
this.initDeviceStates(msg.registeredSessions);
|
|
158
|
+
ws.on('message', (d) => {
|
|
159
|
+
try {
|
|
160
|
+
this.handleRelayMessage(JSON.parse(d.toString()));
|
|
161
|
+
}
|
|
162
|
+
catch { /* ignore malformed */ }
|
|
163
|
+
});
|
|
164
|
+
this.reportResources();
|
|
165
|
+
this.resourcesTimer = setInterval(() => this.reportResources(), 5000);
|
|
166
|
+
resolve();
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
reject(new Error(`Unexpected message during handshake: ${msg.type}`));
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
ws.once('error', reject);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
initDeviceStates(registeredSessions) {
|
|
176
|
+
registeredSessions.forEach(({ deviceId, sessionId }) => {
|
|
177
|
+
this.deviceStates.set(sessionId, {
|
|
178
|
+
sessionId,
|
|
179
|
+
deviceId,
|
|
180
|
+
touchHelper: null,
|
|
181
|
+
streamWs: null,
|
|
182
|
+
scrcpySession: null,
|
|
183
|
+
displayWidth: 0,
|
|
184
|
+
displayHeight: 0,
|
|
185
|
+
videoWidth: 0,
|
|
186
|
+
videoHeight: 0,
|
|
187
|
+
deviceRotation: 0,
|
|
188
|
+
lastTouchPx: { x: 0, y: 0 },
|
|
189
|
+
bootSeq: 0,
|
|
190
|
+
restarting: false,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
disconnect() {
|
|
195
|
+
if (this.resourcesTimer) {
|
|
196
|
+
clearInterval(this.resourcesTimer);
|
|
197
|
+
this.resourcesTimer = null;
|
|
198
|
+
}
|
|
199
|
+
for (const state of this.deviceStates.values()) {
|
|
200
|
+
this.cleanupDeviceState(state);
|
|
201
|
+
}
|
|
202
|
+
this.deviceStates.clear();
|
|
203
|
+
this.ws?.close();
|
|
204
|
+
this.ws = null;
|
|
205
|
+
this.relayUrl = null;
|
|
206
|
+
}
|
|
207
|
+
reportResources() {
|
|
208
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
209
|
+
return;
|
|
210
|
+
const bootedCount = Array.from(this.deviceStates.values()).filter((s) => s.scrcpySession !== null).length;
|
|
211
|
+
const slotsTotal = this.deviceStates.size;
|
|
212
|
+
const { memUsedMB, memTotalMB } = this.resources.getMemoryUsage();
|
|
213
|
+
this.ws.send(JSON.stringify({
|
|
214
|
+
type: 'agent:resources',
|
|
215
|
+
resources: {
|
|
216
|
+
cpuPercent: this.resources.getCpuPercent(),
|
|
217
|
+
memUsedMB,
|
|
218
|
+
memTotalMB,
|
|
219
|
+
slotsAvailable: Math.max(0, slotsTotal - bootedCount),
|
|
220
|
+
slotsTotal,
|
|
221
|
+
reportedAt: Date.now(),
|
|
222
|
+
},
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
cleanupDeviceState(state) {
|
|
226
|
+
const serial = this.adb.getSerial(state.deviceId);
|
|
227
|
+
if (serial && state.scrcpySession)
|
|
228
|
+
state.scrcpySession.stop(serial);
|
|
229
|
+
state.scrcpySession = null;
|
|
230
|
+
state.touchHelper?.stop();
|
|
231
|
+
state.touchHelper = null;
|
|
232
|
+
state.streamWs?.close();
|
|
233
|
+
state.streamWs = null;
|
|
234
|
+
}
|
|
235
|
+
sendDeviceInfo(state, device) {
|
|
236
|
+
if (!this.ws)
|
|
237
|
+
return;
|
|
238
|
+
this.ws.send(JSON.stringify({
|
|
239
|
+
type: 'session:deviceInfo',
|
|
240
|
+
sessionId: state.sessionId,
|
|
241
|
+
payload: {
|
|
242
|
+
deviceName: device.name,
|
|
243
|
+
osVersion: device.osVersion ?? '',
|
|
244
|
+
},
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
async startVideoStream(state, streamWs) {
|
|
248
|
+
const serial = this.adb.getSerial(state.deviceId);
|
|
249
|
+
if (!serial)
|
|
250
|
+
return;
|
|
251
|
+
const touchHelper = new AndroidTouchHelper(this.adb, serial);
|
|
252
|
+
touchHelper.start();
|
|
253
|
+
state.touchHelper = touchHelper;
|
|
254
|
+
const session = new ScrcpySession();
|
|
255
|
+
const info = await session.start(serial, (rotation) => this.handleRotationNotification(state, rotation));
|
|
256
|
+
state.scrcpySession = session;
|
|
257
|
+
state.deviceRotation = 0;
|
|
258
|
+
state.displayWidth = info.width;
|
|
259
|
+
state.displayHeight = info.height;
|
|
260
|
+
state.videoWidth = info.width;
|
|
261
|
+
state.videoHeight = info.height;
|
|
262
|
+
const stream = session.video.start();
|
|
263
|
+
const reader = stream.getReader();
|
|
264
|
+
const pump = async () => {
|
|
265
|
+
try {
|
|
266
|
+
while (true) {
|
|
267
|
+
const { value, done } = await reader.read();
|
|
268
|
+
if (done)
|
|
269
|
+
break;
|
|
270
|
+
// Detect video size changes via H.264 SPS so ScrcpyControl.screenSize always
|
|
271
|
+
// matches what scrcpy server is actually encoding (landscape-aware vs portrait-locked).
|
|
272
|
+
const parsed = parseSpsFromNal(value);
|
|
273
|
+
if (parsed && (parsed.width !== state.videoWidth || parsed.height !== state.videoHeight)) {
|
|
274
|
+
state.videoWidth = parsed.width;
|
|
275
|
+
state.videoHeight = parsed.height;
|
|
276
|
+
state.scrcpySession?.control.updateScreenSize(parsed.width, parsed.height);
|
|
277
|
+
logger.info(`video size → ${parsed.width}×${parsed.height}`);
|
|
278
|
+
}
|
|
279
|
+
if (streamWs.readyState === WebSocket.OPEN)
|
|
280
|
+
streamWs.send(value);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// stream cancelled or ws closed — expected on disconnect
|
|
285
|
+
}
|
|
286
|
+
if (state.scrcpySession === session && !state.restarting) {
|
|
287
|
+
state.restarting = true;
|
|
288
|
+
void this.restartVideoStream(state);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
void pump();
|
|
292
|
+
}
|
|
293
|
+
async restartVideoStream(state) {
|
|
294
|
+
const serial = this.adb.getSerial(state.deviceId);
|
|
295
|
+
if (!serial) {
|
|
296
|
+
state.restarting = false;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
state.scrcpySession?.stop(serial);
|
|
300
|
+
state.scrcpySession = null;
|
|
301
|
+
state.touchHelper?.stop();
|
|
302
|
+
state.touchHelper = null;
|
|
303
|
+
const { streamWs } = state;
|
|
304
|
+
if (!streamWs || streamWs.readyState !== WebSocket.OPEN) {
|
|
305
|
+
state.restarting = false;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
// kill any lingering scrcpy server process on the device before restarting
|
|
309
|
+
await this.adb.pkill(serial, 'scrcpy-server').catch(() => { });
|
|
310
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
311
|
+
if (!this.deviceStates.has(state.sessionId))
|
|
312
|
+
return;
|
|
313
|
+
try {
|
|
314
|
+
await this.startVideoStream(state, streamWs);
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
logger.error(`scrcpy restart failed: ${err}`);
|
|
318
|
+
this.ws?.send(JSON.stringify({
|
|
319
|
+
type: 'device:boot-error',
|
|
320
|
+
sessionId: state.sessionId,
|
|
321
|
+
message: 'scrcpy failed to restart',
|
|
322
|
+
}));
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
state.restarting = false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
handleRotationNotification(state, rotation) {
|
|
329
|
+
if (rotation === state.deviceRotation)
|
|
330
|
+
return;
|
|
331
|
+
const prevRotation = state.deviceRotation;
|
|
332
|
+
state.deviceRotation = rotation;
|
|
333
|
+
// Swap displayW/H when rotation changes by an odd number of 90° steps.
|
|
334
|
+
// Do NOT call control.updateScreenSize here — scrcpy screenWidth/Height must match
|
|
335
|
+
// the actual video frame dimensions set in the ScrcpyControl constructor, not the
|
|
336
|
+
// display orientation. Portrait-locked apps keep portrait video even on a landscape
|
|
337
|
+
// device; mismatching screenSize causes scrcpy to silently drop all touch events.
|
|
338
|
+
const quarters = ((rotation - prevRotation) + 4) % 4;
|
|
339
|
+
if (quarters === 1 || quarters === 3) {
|
|
340
|
+
;
|
|
341
|
+
[state.displayWidth, state.displayHeight] = [state.displayHeight, state.displayWidth];
|
|
342
|
+
}
|
|
343
|
+
logger.info(`rotation ${prevRotation}→${rotation} displaySize=${state.displayWidth}×${state.displayHeight}`);
|
|
344
|
+
this.ws?.send(JSON.stringify({
|
|
345
|
+
type: 'device:rotate',
|
|
346
|
+
sessionId: state.sessionId,
|
|
347
|
+
payload: { rotation, displayWidth: state.displayWidth, displayHeight: state.displayHeight },
|
|
348
|
+
}));
|
|
349
|
+
}
|
|
350
|
+
async openStreamWs(state) {
|
|
351
|
+
const streamWs = new WebSocket(this.relayUrl);
|
|
352
|
+
state.streamWs = streamWs;
|
|
353
|
+
await registerStreamWs(streamWs, state.sessionId);
|
|
354
|
+
return streamWs;
|
|
355
|
+
}
|
|
356
|
+
async handleDeviceBoot(sessionId, avdId) {
|
|
357
|
+
const state = this.deviceStates.get(sessionId);
|
|
358
|
+
if (!state || !this.ws)
|
|
359
|
+
return;
|
|
360
|
+
const seq = ++state.bootSeq;
|
|
361
|
+
this.cleanupDeviceState(state);
|
|
362
|
+
this.ws.send(JSON.stringify({ type: 'device:booting', sessionId }));
|
|
363
|
+
try {
|
|
364
|
+
const avdName = avdId.replace(/^avd:/, '');
|
|
365
|
+
const devices = await this.adb.listDevices();
|
|
366
|
+
if (seq !== state.bootSeq)
|
|
367
|
+
return;
|
|
368
|
+
const target = devices.find((d) => d.id === avdId);
|
|
369
|
+
if (!target)
|
|
370
|
+
throw new Error(`Device not found: ${avdId}`);
|
|
371
|
+
if (target.status !== 'booted') {
|
|
372
|
+
this.launcher.launch(avdName);
|
|
373
|
+
const serial = await this.launcher.findSerial(avdName);
|
|
374
|
+
if (seq !== state.bootSeq)
|
|
375
|
+
return;
|
|
376
|
+
await this.launcher.waitForBoot(serial);
|
|
377
|
+
if (seq !== state.bootSeq)
|
|
378
|
+
return;
|
|
379
|
+
this.adb.setSerial(avdId, serial);
|
|
380
|
+
}
|
|
381
|
+
const refreshed = await this.adb.listDevices();
|
|
382
|
+
if (seq !== state.bootSeq)
|
|
383
|
+
return;
|
|
384
|
+
const refreshedDevice = refreshed.find((d) => d.id === avdId) ?? target;
|
|
385
|
+
this.sendDeviceInfo(state, { ...refreshedDevice, status: 'booted' });
|
|
386
|
+
const streamWs = await this.openStreamWs(state);
|
|
387
|
+
if (seq !== state.bootSeq) {
|
|
388
|
+
streamWs.close();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
await this.startVideoStream(state, streamWs);
|
|
392
|
+
if (seq !== state.bootSeq)
|
|
393
|
+
return;
|
|
394
|
+
this.ws?.send(JSON.stringify({
|
|
395
|
+
type: 'session:chrome',
|
|
396
|
+
sessionId: state.sessionId,
|
|
397
|
+
payload: {
|
|
398
|
+
buttons: ANDROID_BUTTONS,
|
|
399
|
+
streamType: 'h264',
|
|
400
|
+
screenWidth: state.displayWidth,
|
|
401
|
+
screenHeight: state.displayHeight,
|
|
402
|
+
},
|
|
403
|
+
}));
|
|
404
|
+
this.ws?.send(JSON.stringify({ type: 'device:ready', sessionId, payload: { deviceId: avdId } }));
|
|
405
|
+
}
|
|
406
|
+
catch (e) {
|
|
407
|
+
if (seq !== state.bootSeq)
|
|
408
|
+
return;
|
|
409
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
410
|
+
logger.error('boot failed:', message);
|
|
411
|
+
this.ws?.send(JSON.stringify({ type: 'device:boot-error', sessionId, message }));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
async handleDeviceShutdown(sessionId, avdId) {
|
|
415
|
+
const state = this.deviceStates.get(sessionId);
|
|
416
|
+
if (!state)
|
|
417
|
+
return;
|
|
418
|
+
state.bootSeq++;
|
|
419
|
+
this.cleanupDeviceState(state);
|
|
420
|
+
const serial = this.adb.getSerial(avdId);
|
|
421
|
+
if (serial) {
|
|
422
|
+
// best-effort — emulator may already be gone
|
|
423
|
+
await this.adb.shutdown(serial).catch((e) => {
|
|
424
|
+
logger.warn('emu kill failed (already gone?):', e.message);
|
|
425
|
+
});
|
|
426
|
+
this.adb.clearSerial(avdId);
|
|
427
|
+
}
|
|
428
|
+
this.ws?.send(JSON.stringify({
|
|
429
|
+
type: 'device:shutdown-done',
|
|
430
|
+
sessionId,
|
|
431
|
+
payload: { deviceId: avdId },
|
|
432
|
+
}));
|
|
433
|
+
}
|
|
434
|
+
handleRelayMessage(msg) {
|
|
435
|
+
switch (msg.type) {
|
|
436
|
+
case 'device:boot': {
|
|
437
|
+
const { deviceId } = msg.payload;
|
|
438
|
+
this.handleDeviceBoot(msg.sessionId, deviceId)
|
|
439
|
+
.catch((e) => logger.error('handleDeviceBoot failed:', e));
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
case 'device:shutdown': {
|
|
443
|
+
const { deviceId } = msg.payload;
|
|
444
|
+
this.handleDeviceShutdown(msg.sessionId, deviceId)
|
|
445
|
+
.catch((e) => logger.error('handleDeviceShutdown failed:', e));
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
case 'app:install': {
|
|
449
|
+
const { filePath, bundleId } = msg.payload;
|
|
450
|
+
const sessionId = msg.sessionId;
|
|
451
|
+
const state = this.deviceStates.get(sessionId);
|
|
452
|
+
const serial = state ? this.adb.getSerial(state.deviceId) : undefined;
|
|
453
|
+
if (!serial) {
|
|
454
|
+
this.ws?.send(JSON.stringify({ type: 'app:install-error', sessionId, message: 'No booted device' }));
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
if (filePath.endsWith('.app.zip') || filePath.endsWith('.app')) {
|
|
458
|
+
this.ws?.send(JSON.stringify({
|
|
459
|
+
type: 'app:install-error',
|
|
460
|
+
sessionId,
|
|
461
|
+
message: '.app.zip is an iOS simulator build — upload a .apk file for Android.',
|
|
462
|
+
}));
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
const doInstall = async () => {
|
|
466
|
+
if (bundleId)
|
|
467
|
+
await this.adb.clearAppData(serial, bundleId).catch(() => { });
|
|
468
|
+
await this.adb.installApp(serial, filePath);
|
|
469
|
+
};
|
|
470
|
+
doInstall()
|
|
471
|
+
.then(() => this.ws?.send(JSON.stringify({ type: 'app:install-done', sessionId })))
|
|
472
|
+
.catch((e) => {
|
|
473
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
474
|
+
this.ws?.send(JSON.stringify({ type: 'app:install-error', sessionId, message }));
|
|
475
|
+
});
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
case 'app:launch': {
|
|
479
|
+
const { bundleId } = msg.payload;
|
|
480
|
+
const sessionId = msg.sessionId;
|
|
481
|
+
const state = this.deviceStates.get(sessionId);
|
|
482
|
+
const serial = state ? this.adb.getSerial(state.deviceId) : undefined;
|
|
483
|
+
if (!serial) {
|
|
484
|
+
this.ws?.send(JSON.stringify({ type: 'app:launch-error', sessionId, message: 'No booted device' }));
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
this.adb.launchApp(serial, bundleId)
|
|
488
|
+
.then(() => this.ws?.send(JSON.stringify({ type: 'app:launch-done', sessionId })))
|
|
489
|
+
.catch((e) => {
|
|
490
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
491
|
+
this.ws?.send(JSON.stringify({ type: 'app:launch-error', sessionId, message }));
|
|
492
|
+
});
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
case 'input:touch:start': {
|
|
496
|
+
const state = this.deviceStates.get(msg.sessionId);
|
|
497
|
+
if (!state)
|
|
498
|
+
break;
|
|
499
|
+
const { x, y } = msg.payload;
|
|
500
|
+
if (state.scrcpySession && state.videoWidth > 0) {
|
|
501
|
+
const px = Math.round(x * state.videoWidth);
|
|
502
|
+
const py = Math.round(y * state.videoHeight);
|
|
503
|
+
state.lastTouchPx = { x: px, y: py };
|
|
504
|
+
state.scrcpySession.control.touchDown(0, px, py);
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
state.touchHelper?.touchStart(x, y);
|
|
508
|
+
}
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
case 'input:touch:move': {
|
|
512
|
+
const state = this.deviceStates.get(msg.sessionId);
|
|
513
|
+
if (!state)
|
|
514
|
+
break;
|
|
515
|
+
const { x, y } = msg.payload;
|
|
516
|
+
if (state.scrcpySession && state.videoWidth > 0) {
|
|
517
|
+
const px = Math.round(x * state.videoWidth);
|
|
518
|
+
const py = Math.round(y * state.videoHeight);
|
|
519
|
+
state.lastTouchPx = { x: px, y: py };
|
|
520
|
+
state.scrcpySession.control.touchMove(0, px, py);
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
state.touchHelper?.touchMove(x, y);
|
|
524
|
+
}
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
case 'input:touch:end': {
|
|
528
|
+
const state = this.deviceStates.get(msg.sessionId);
|
|
529
|
+
if (!state)
|
|
530
|
+
break;
|
|
531
|
+
if (state.scrcpySession) {
|
|
532
|
+
state.scrcpySession.control.touchUp(0, state.lastTouchPx.x, state.lastTouchPx.y);
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
state.touchHelper?.touchEnd();
|
|
536
|
+
}
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
case 'input:pinch:start': {
|
|
540
|
+
const state = this.deviceStates.get(msg.sessionId);
|
|
541
|
+
if (!state?.touchHelper)
|
|
542
|
+
break;
|
|
543
|
+
const { x1, y1, x2, y2 } = msg.payload;
|
|
544
|
+
state.touchHelper.pinchStart(x1, y1, x2, y2);
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
case 'input:pinch:move': {
|
|
548
|
+
const state = this.deviceStates.get(msg.sessionId);
|
|
549
|
+
if (!state?.touchHelper)
|
|
550
|
+
break;
|
|
551
|
+
const { x1, y1, x2, y2 } = msg.payload;
|
|
552
|
+
state.touchHelper.pinchMove(x1, y1, x2, y2);
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
case 'input:pinch:end': {
|
|
556
|
+
const state = this.deviceStates.get(msg.sessionId);
|
|
557
|
+
state?.touchHelper?.pinchEnd();
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
case 'input:rotate': {
|
|
561
|
+
const state = this.deviceStates.get(msg.sessionId);
|
|
562
|
+
if (!state)
|
|
563
|
+
break;
|
|
564
|
+
const serial = this.adb.getSerial(state.deviceId);
|
|
565
|
+
if (!serial)
|
|
566
|
+
break;
|
|
567
|
+
// Optimistically advance rotation by 90° so the view updates immediately.
|
|
568
|
+
// ROTATION_NOTIFICATION will reconcile the actual value; dedup logic in handleRotationNotification
|
|
569
|
+
// prevents a double-swap if prediction matches.
|
|
570
|
+
this.handleRotationNotification(state, (state.deviceRotation + 1) % 4);
|
|
571
|
+
this.adb.enableAutoRotate(serial).catch(() => { });
|
|
572
|
+
this.adb.emuRotate(serial).catch(() => { });
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
case 'input:button': {
|
|
576
|
+
const state = this.deviceStates.get(msg.sessionId);
|
|
577
|
+
if (!state?.touchHelper)
|
|
578
|
+
break;
|
|
579
|
+
const { name } = msg.payload;
|
|
580
|
+
state.touchHelper.pressButton(name);
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
case 'input:keyboard:toggle': {
|
|
584
|
+
// client-side key forwarding toggle only — no ADB side effect needed
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
case 'input:key': {
|
|
588
|
+
const state = this.deviceStates.get(msg.sessionId);
|
|
589
|
+
const serial = state ? this.adb.getSerial(state.deviceId) : undefined;
|
|
590
|
+
if (!serial)
|
|
591
|
+
break;
|
|
592
|
+
const { code, modifiers } = msg.payload;
|
|
593
|
+
this.handleKeyInput(serial, code, modifiers).catch(() => { });
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
async handleKeyInput(serial, code, modifiers) {
|
|
599
|
+
const SPECIAL = {
|
|
600
|
+
Backspace: '67', Enter: '66', Tab: '61', Space: '62', Escape: '111',
|
|
601
|
+
ArrowLeft: '21', ArrowRight: '22', ArrowUp: '19', ArrowDown: '20',
|
|
602
|
+
Delete: '112', Home: '122', End: '123', PageUp: '92', PageDown: '93',
|
|
603
|
+
F1: '131', F2: '132', F3: '133', F4: '134', F5: '135',
|
|
604
|
+
F6: '136', F7: '137', F8: '138', F9: '139', F10: '140', F11: '141', F12: '142',
|
|
605
|
+
};
|
|
606
|
+
if (SPECIAL[code]) {
|
|
607
|
+
await this.adb.sendKeyEvent(serial, SPECIAL[code]);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
const shift = Boolean(modifiers & 0x02);
|
|
611
|
+
let char = null;
|
|
612
|
+
if (code.startsWith('Key')) {
|
|
613
|
+
const letter = code.slice(3);
|
|
614
|
+
char = shift ? letter.toUpperCase() : letter.toLowerCase();
|
|
615
|
+
}
|
|
616
|
+
else if (code.startsWith('Digit')) {
|
|
617
|
+
const digit = code.slice(5);
|
|
618
|
+
const shiftDigits = {
|
|
619
|
+
'1': '!', '2': '@', '3': '#', '4': '$', '5': '%',
|
|
620
|
+
'6': '^', '7': '&', '8': '*', '9': '(', '0': ')',
|
|
621
|
+
};
|
|
622
|
+
char = shift ? (shiftDigits[digit] ?? digit) : digit;
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
const PUNCT = {
|
|
626
|
+
Minus: ['-', '_'], Equal: ['=', '+'],
|
|
627
|
+
BracketLeft: ['[', '{'], BracketRight: [']', '}'],
|
|
628
|
+
Backslash: ['\\', '|'], Semicolon: [';', ':'],
|
|
629
|
+
Quote: ["'", '"'], Comma: [',', '<'],
|
|
630
|
+
Period: ['.', '>'], Slash: ['/', '?'], Backquote: ['`', '~'],
|
|
631
|
+
};
|
|
632
|
+
if (PUNCT[code])
|
|
633
|
+
char = shift ? PUNCT[code][1] : PUNCT[code][0];
|
|
634
|
+
}
|
|
635
|
+
if (char)
|
|
636
|
+
await this.adb.sendInput(serial, 'text', char);
|
|
637
|
+
}
|
|
638
|
+
listDevices() { return this.adb.listDevices(); }
|
|
639
|
+
async boot(avdId) {
|
|
640
|
+
const avdName = avdId.replace(/^avd:/, '');
|
|
641
|
+
this.launcher.launch(avdName);
|
|
642
|
+
const serial = await this.launcher.findSerial(avdName);
|
|
643
|
+
await this.launcher.waitForBoot(serial);
|
|
644
|
+
this.adb.setSerial(avdId, serial);
|
|
645
|
+
}
|
|
646
|
+
async shutdown(avdId) {
|
|
647
|
+
const serial = this.adb.getSerial(avdId);
|
|
648
|
+
if (serial) {
|
|
649
|
+
await this.adb.shutdown(serial);
|
|
650
|
+
this.adb.clearSerial(avdId);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
async installApp(apkPath) {
|
|
654
|
+
const first = this.deviceStates.values().next().value;
|
|
655
|
+
const serial = first ? this.adb.getSerial(first.deviceId) : undefined;
|
|
656
|
+
if (!serial)
|
|
657
|
+
throw new Error('no booted device — call connect() first');
|
|
658
|
+
await this.adb.installApp(serial, apkPath);
|
|
659
|
+
}
|
|
660
|
+
async launchApp(packageName) {
|
|
661
|
+
const first = this.deviceStates.values().next().value;
|
|
662
|
+
const serial = first ? this.adb.getSerial(first.deviceId) : undefined;
|
|
663
|
+
if (!serial)
|
|
664
|
+
throw new Error('no booted device — call connect() first');
|
|
665
|
+
await this.adb.launchApp(serial, packageName);
|
|
666
|
+
}
|
|
667
|
+
async screenshot() {
|
|
668
|
+
const first = this.deviceStates.values().next().value;
|
|
669
|
+
const serial = first ? this.adb.getSerial(first.deviceId) : undefined;
|
|
670
|
+
if (!serial)
|
|
671
|
+
throw new Error('no booted device — call connect() first');
|
|
672
|
+
return this.adb.screenshot(serial);
|
|
673
|
+
}
|
|
674
|
+
stream() {
|
|
675
|
+
const state = this.deviceStates.values().next().value;
|
|
676
|
+
if (!state?.scrcpySession)
|
|
677
|
+
throw new Error('no active scrcpy session — call connect() first');
|
|
678
|
+
return state.scrcpySession.video.start();
|
|
679
|
+
}
|
|
680
|
+
touchStart(x, y) {
|
|
681
|
+
const first = this.deviceStates.values().next().value;
|
|
682
|
+
first?.touchHelper?.touchStart(x, y);
|
|
683
|
+
}
|
|
684
|
+
touchMove(x, y) {
|
|
685
|
+
const first = this.deviceStates.values().next().value;
|
|
686
|
+
first?.touchHelper?.touchMove(x, y);
|
|
687
|
+
return Promise.resolve();
|
|
688
|
+
}
|
|
689
|
+
touchEnd() {
|
|
690
|
+
const first = this.deviceStates.values().next().value;
|
|
691
|
+
first?.touchHelper?.touchEnd();
|
|
692
|
+
return Promise.resolve();
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
//# sourceMappingURL=AndroidAgent.js.map
|