android-midscene-automation 0.1.0
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/README.md +160 -0
- package/bin/android-midscene-automation.js +27 -0
- package/index.html +12 -0
- package/package.json +49 -0
- package/remote-agent/index.ts +206 -0
- package/server/appium-recorder/appium-runner.ts +427 -0
- package/server/appium-recorder/repository.ts +228 -0
- package/server/appium-recorder/routes.ts +219 -0
- package/server/config-store.ts +167 -0
- package/server/config.ts +130 -0
- package/server/device-locks/repository.ts +147 -0
- package/server/device-locks/service.ts +72 -0
- package/server/device-locks/types.ts +22 -0
- package/server/device-sessions/repository.ts +169 -0
- package/server/device-sessions/service.ts +59 -0
- package/server/device-sessions/types.ts +24 -0
- package/server/http-api.ts +1389 -0
- package/server/model-call-usage-importer.ts +108 -0
- package/server/model-tester.ts +104 -0
- package/server/model-usage-repository.ts +131 -0
- package/server/operations/repository.ts +218 -0
- package/server/operations/service.ts +84 -0
- package/server/operations/types.ts +27 -0
- package/server/paths.ts +27 -0
- package/server/remote-agents/protocol.ts +38 -0
- package/server/remote-agents/registry.ts +136 -0
- package/server/remote-agents/routes.ts +89 -0
- package/server/script-agent.ts +284 -0
- package/server/script-db.ts +281 -0
- package/server/script-runner.ts +551 -0
- package/server/storage/sqlite.ts +49 -0
- package/server/test-case-import/formatter.ts +28 -0
- package/server/test-case-import/parsers/excel.ts +69 -0
- package/server/test-case-import/parsers/txt.ts +11 -0
- package/server/test-case-import/parsers/word.ts +9 -0
- package/server/test-case-import/service.ts +58 -0
- package/server/test-case-import/text-normalizer.ts +98 -0
- package/server/test-case-import/types.ts +24 -0
- package/server/test-case-import/validator.ts +34 -0
- package/src/App.vue +1450 -0
- package/src/api.ts +290 -0
- package/src/appium-recorder/AppiumPage.vue +894 -0
- package/src/appium-recorder/api.ts +64 -0
- package/src/appium-recorder/components/ComponentTree.vue +44 -0
- package/src/appium-recorder/components/NodeDetail.vue +152 -0
- package/src/appium-recorder/components/RecordedSteps.vue +79 -0
- package/src/appium-recorder/tree.ts +129 -0
- package/src/appium-recorder/types.ts +88 -0
- package/src/assets/device-actions/back.svg +5 -0
- package/src/assets/device-actions/home.svg +3 -0
- package/src/assets/device-actions/power.svg +5 -0
- package/src/assets/device-actions/tasks.svg +3 -0
- package/src/assets/device-actions/volume-down.svg +3 -0
- package/src/assets/device-actions/volume-up.svg +3 -0
- package/src/components/config/ModelUsageChart.vue +188 -0
- package/src/components/device/DevicePreviewPanel.vue +266 -0
- package/src/components/generator/GeneratedCodePanel.vue +70 -0
- package/src/components/generator/TestCaseFileUpload.vue +97 -0
- package/src/config/midscene-model-presets.ts +75 -0
- package/src/config/prompt-example.ts +6 -0
- package/src/main.ts +7 -0
- package/src/pages/AiGeneratorPage.vue +90 -0
- package/src/pages/AutomationPage.vue +161 -0
- package/src/pages/ConfigPage.vue +273 -0
- package/src/pages/GeneratorPage.vue +97 -0
- package/src/pages/ManualStepsPage.vue +179 -0
- package/src/script-generator/codegen.ts +126 -0
- package/src/script-generator/index.ts +4 -0
- package/src/script-generator/presets.ts +37 -0
- package/src/script-generator/step-options.ts +52 -0
- package/src/script-generator/types.ts +27 -0
- package/src/style.css +1983 -0
- package/src/types.ts +157 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.app.json +8 -0
- package/tsconfig.json +11 -0
- package/tsconfig.node.json +16 -0
- package/vite.config.ts +28 -0
|
@@ -0,0 +1,1389 @@
|
|
|
1
|
+
// The Midscene Admin HTTP API — extracted from vite.config.ts so the exact
|
|
2
|
+
// same middleware serves the standalone dev/preview flow AND the DSH plugin.
|
|
3
|
+
// It is a Connect-style handler: (req, res, next). Callers that have no next
|
|
4
|
+
// stage (the DSH plugin) provide their own (e.g. static SPA serving).
|
|
5
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
6
|
+
|
|
7
|
+
type NextFunction = (err?: unknown) => void;
|
|
8
|
+
type NextHandleFunction = (req: IncomingMessage, res: ServerResponse, next: NextFunction) => void;
|
|
9
|
+
|
|
10
|
+
import { execFile, spawn } from 'node:child_process';
|
|
11
|
+
import { appPath } from './paths';
|
|
12
|
+
import { loadConfig, saveConfig, type AppConfig } from './config';
|
|
13
|
+
import { listAppPresetRecords, removeAppPresetRecord, saveAppPresetRecord } from './config-store';
|
|
14
|
+
import { generatePlan } from './script-agent';
|
|
15
|
+
import { importMidsceneModelUsage } from './model-call-usage-importer';
|
|
16
|
+
import { testModelConnection } from './model-tester';
|
|
17
|
+
import { listModelUsageRecords, saveModelUsageRecord } from './model-usage-repository';
|
|
18
|
+
import { DeviceLockConflictError } from './device-locks/types';
|
|
19
|
+
import { acquireDeviceLock, listActiveDeviceLocks, releaseDeviceLock } from './device-locks/service';
|
|
20
|
+
import { ensureDeviceSession, listDeviceSessions } from './device-sessions/service';
|
|
21
|
+
import {
|
|
22
|
+
finishScriptRunOperation,
|
|
23
|
+
getOperationWithEvents,
|
|
24
|
+
listOperations,
|
|
25
|
+
recordOperationEvent,
|
|
26
|
+
startScriptRunOperation,
|
|
27
|
+
} from './operations/service';
|
|
28
|
+
import {
|
|
29
|
+
checkGeneratedScriptExists,
|
|
30
|
+
deleteGeneratedScript,
|
|
31
|
+
runGeneratedScript,
|
|
32
|
+
saveGeneratedScript,
|
|
33
|
+
updateGeneratedScriptCode,
|
|
34
|
+
validateGeneratedScriptCode,
|
|
35
|
+
type RunGeneratedScriptEvent,
|
|
36
|
+
} from './script-runner';
|
|
37
|
+
import { listScriptRecords, type ScriptStepRecord } from './script-db';
|
|
38
|
+
import { importTestCaseFile, MAX_TEST_CASE_FILE_SIZE } from './test-case-import/service';
|
|
39
|
+
import { handleAppiumRecorderRequest } from './appium-recorder/routes';
|
|
40
|
+
import { handleRemoteAgentRequest } from './remote-agents/routes';
|
|
41
|
+
import { isRemoteDeviceId, listRemoteAndroidDevices, sendRemoteCommand } from './remote-agents/registry';
|
|
42
|
+
|
|
43
|
+
async function readBody<T>(req: IncomingMessage) {
|
|
44
|
+
let body = '';
|
|
45
|
+
req.on('data', (chunk) => {
|
|
46
|
+
body += chunk;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return await new Promise<T>((resolve) => {
|
|
50
|
+
req.on('end', () => {
|
|
51
|
+
resolve(JSON.parse(body || '{}') as T);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function readRawBody(req: IncomingMessage) {
|
|
57
|
+
const chunks: Buffer[] = [];
|
|
58
|
+
req.on('data', (chunk) => {
|
|
59
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return await new Promise<Buffer>((resolve) => {
|
|
63
|
+
req.on('end', () => {
|
|
64
|
+
resolve(Buffer.concat(chunks));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function execFileText(command: string, args: string[] = []) {
|
|
70
|
+
return new Promise<string>((resolve, reject) => {
|
|
71
|
+
execFile(command, args, { maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
72
|
+
if (error) {
|
|
73
|
+
reject(new Error(stderr || error.message));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
resolve(stdout);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function execFileJson(command: string, args: string[] = []) {
|
|
82
|
+
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
83
|
+
execFile(command, args, { maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
84
|
+
if (error) {
|
|
85
|
+
reject(new Error(stderr || error.message));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
resolve({ stdout, stderr });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function writeNdjson(res: ServerResponse, event: RunGeneratedScriptEvent | { type: 'error'; message: string }) {
|
|
94
|
+
res.write(`${JSON.stringify(event)}\n`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type AndroidDevice = {
|
|
98
|
+
id: string;
|
|
99
|
+
status: string;
|
|
100
|
+
description: string;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
type AndroidDisplayInfo = {
|
|
104
|
+
width: number;
|
|
105
|
+
height: number;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
async function listAdbDevices() {
|
|
109
|
+
const output = await execFileText('adb', ['devices', '-l']);
|
|
110
|
+
const devices = output
|
|
111
|
+
.split('\n')
|
|
112
|
+
.map((line) => line.trim())
|
|
113
|
+
.filter((line) => line && !line.startsWith('List of devices attached'))
|
|
114
|
+
.map((line) => {
|
|
115
|
+
const parts = line.split(/\s+/);
|
|
116
|
+
const id = parts[0] || '';
|
|
117
|
+
const status = parts[1] || 'unknown';
|
|
118
|
+
const model = parts.find((part) => part.startsWith('model:'))?.slice(6) || '';
|
|
119
|
+
const device = parts.find((part) => part.startsWith('device:'))?.slice(7) || '';
|
|
120
|
+
return {
|
|
121
|
+
id,
|
|
122
|
+
status,
|
|
123
|
+
description: [model, device].filter(Boolean).join(' · ') || status,
|
|
124
|
+
} satisfies AndroidDevice;
|
|
125
|
+
})
|
|
126
|
+
.filter((device) => device.id);
|
|
127
|
+
|
|
128
|
+
return devices;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function getAdbDisplayInfo(deviceId: string) {
|
|
132
|
+
const output = await execFileText('adb', ['-s', deviceId, 'shell', 'wm', 'size']);
|
|
133
|
+
const match = output.match(/Override size:\s*(\d+)x(\d+)/i) || output.match(/Physical size:\s*(\d+)x(\d+)/i);
|
|
134
|
+
|
|
135
|
+
if (!match) {
|
|
136
|
+
throw new Error('读取设备分辨率失败');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
width: Number(match[1]),
|
|
141
|
+
height: Number(match[2]),
|
|
142
|
+
} satisfies AndroidDisplayInfo;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getRequestedDeviceId(req: IncomingMessage) {
|
|
146
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
147
|
+
return url.searchParams.get('deviceId')?.trim() || '';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getCookie(req: IncomingMessage, name: string) {
|
|
151
|
+
const cookie = req.headers.cookie || '';
|
|
152
|
+
return cookie
|
|
153
|
+
.split(';')
|
|
154
|
+
.map((part) => part.trim())
|
|
155
|
+
.find((part) => part.startsWith(`${name}=`))
|
|
156
|
+
?.slice(name.length + 1) || '';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function setCookie(res: ServerResponse, value: string) {
|
|
160
|
+
const existing = res.getHeader('Set-Cookie');
|
|
161
|
+
const next = `midscene_client_id=${value}; Path=/; SameSite=Lax`;
|
|
162
|
+
if (Array.isArray(existing)) {
|
|
163
|
+
res.setHeader('Set-Cookie', [...existing, next]);
|
|
164
|
+
} else if (existing) {
|
|
165
|
+
res.setHeader('Set-Cookie', [String(existing), next]);
|
|
166
|
+
} else {
|
|
167
|
+
res.setHeader('Set-Cookie', next);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isLocalRequest(req: IncomingMessage) {
|
|
172
|
+
const address = req.socket.remoteAddress || '';
|
|
173
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function assertDeviceAllowedForRequest(req: IncomingMessage, deviceId: string) {
|
|
177
|
+
if (!isLocalRequest(req) && deviceId && !isRemoteDeviceId(deviceId)) {
|
|
178
|
+
throw new Error('局域网访问者只能使用远程代理设备');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function resolvePlaygroundUrl(selectedDeviceId: string) {
|
|
183
|
+
const ports = [5800, 5801, 5802, 5803, 5804, 5805];
|
|
184
|
+
const candidates: Array<{
|
|
185
|
+
url: string;
|
|
186
|
+
previewKind: string;
|
|
187
|
+
deviceId: string;
|
|
188
|
+
sessionConnected: boolean;
|
|
189
|
+
setupState: string;
|
|
190
|
+
previewError: string;
|
|
191
|
+
score: number;
|
|
192
|
+
}> = [];
|
|
193
|
+
|
|
194
|
+
for (const port of ports) {
|
|
195
|
+
const url = `http://localhost:${port}`;
|
|
196
|
+
try {
|
|
197
|
+
const statusResponse = await fetch(`${url}/status`);
|
|
198
|
+
if (!statusResponse.ok) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const [runtimeInfoResponse, screenshotResponse] = await Promise.all([
|
|
202
|
+
fetch(`${url}/runtime-info`).catch(() => null),
|
|
203
|
+
fetch(`${url}/screenshot`).catch(() => null),
|
|
204
|
+
]);
|
|
205
|
+
const runtimeInfo = runtimeInfoResponse?.ok
|
|
206
|
+
? (await runtimeInfoResponse.json()) as {
|
|
207
|
+
preview?: { kind?: string };
|
|
208
|
+
metadata?: { deviceId?: string; sessionConnected?: boolean; setupState?: string };
|
|
209
|
+
}
|
|
210
|
+
: null;
|
|
211
|
+
const screenshotPayload = await screenshotResponse?.json().catch(() => null) as
|
|
212
|
+
| { error?: string }
|
|
213
|
+
| null
|
|
214
|
+
| undefined;
|
|
215
|
+
const deviceId = runtimeInfo?.metadata?.deviceId || '';
|
|
216
|
+
const sessionConnected = runtimeInfo?.metadata?.sessionConnected === true;
|
|
217
|
+
const setupState = runtimeInfo?.metadata?.setupState || '';
|
|
218
|
+
const previewError = screenshotResponse?.ok ? '' : screenshotPayload?.error || '';
|
|
219
|
+
const matchesSelectedDevice = !selectedDeviceId || deviceId === selectedDeviceId;
|
|
220
|
+
let score = 0;
|
|
221
|
+
if (matchesSelectedDevice && selectedDeviceId) score += 4;
|
|
222
|
+
if (!previewError) score += 2;
|
|
223
|
+
if (runtimeInfo?.preview?.kind === 'scrcpy' && sessionConnected && matchesSelectedDevice) score += 4;
|
|
224
|
+
candidates.push({
|
|
225
|
+
url,
|
|
226
|
+
previewKind: runtimeInfo?.preview?.kind || '',
|
|
227
|
+
deviceId,
|
|
228
|
+
sessionConnected,
|
|
229
|
+
setupState,
|
|
230
|
+
previewError,
|
|
231
|
+
score,
|
|
232
|
+
});
|
|
233
|
+
} catch {
|
|
234
|
+
// Ignore unavailable ports.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
239
|
+
return candidates[0] || null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
type PlaygroundTarget = Awaited<ReturnType<typeof resolvePlaygroundUrl>>;
|
|
243
|
+
|
|
244
|
+
function isUsablePlaygroundTarget(target: PlaygroundTarget, selectedDeviceId: string) {
|
|
245
|
+
if (!target) return false;
|
|
246
|
+
const matchesSelectedDevice = !selectedDeviceId || target.deviceId === selectedDeviceId;
|
|
247
|
+
return target.previewKind === 'scrcpy' && target.sessionConnected && matchesSelectedDevice;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function createPlaygroundSession(target: PlaygroundTarget, selectedDeviceId: string) {
|
|
251
|
+
if (!target || !selectedDeviceId) return null;
|
|
252
|
+
const response = await fetch(`${target.url}/session`, {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: { 'Content-Type': 'application/json' },
|
|
255
|
+
body: JSON.stringify({ deviceId: selectedDeviceId }),
|
|
256
|
+
});
|
|
257
|
+
if (!response.ok) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
return await resolvePlaygroundUrl(selectedDeviceId);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const PLAYGROUND_FRAME_PREFIX = '/__android_playground__';
|
|
264
|
+
const PLAYGROUND_PROXY_PREFIXES = [
|
|
265
|
+
'/status',
|
|
266
|
+
'/runtime-info',
|
|
267
|
+
'/interface-info',
|
|
268
|
+
'/screenshot',
|
|
269
|
+
'/interact',
|
|
270
|
+
'/config',
|
|
271
|
+
'/connectivity-test',
|
|
272
|
+
'/task-progress',
|
|
273
|
+
'/session',
|
|
274
|
+
'/recorder',
|
|
275
|
+
'/cancel',
|
|
276
|
+
'/mjpeg',
|
|
277
|
+
] as const;
|
|
278
|
+
|
|
279
|
+
function isPlaygroundProxyPath(pathname: string) {
|
|
280
|
+
if (pathname === PLAYGROUND_FRAME_PREFIX || pathname === `${PLAYGROUND_FRAME_PREFIX}/`) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (pathname.startsWith(`${PLAYGROUND_FRAME_PREFIX}/`)) {
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return PLAYGROUND_PROXY_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function buildPlaygroundChromeCss() {
|
|
292
|
+
return `
|
|
293
|
+
html, body, #root {
|
|
294
|
+
width: 100% !important;
|
|
295
|
+
height: 100% !important;
|
|
296
|
+
margin: 0 !important;
|
|
297
|
+
padding: 0 !important;
|
|
298
|
+
overflow: hidden !important;
|
|
299
|
+
background: #ffffff !important;
|
|
300
|
+
}
|
|
301
|
+
body {
|
|
302
|
+
background: #ffffff !important;
|
|
303
|
+
}
|
|
304
|
+
.app-container,
|
|
305
|
+
.playground-container,
|
|
306
|
+
.app-content,
|
|
307
|
+
.app-content > * {
|
|
308
|
+
width: 100% !important;
|
|
309
|
+
height: 100% !important;
|
|
310
|
+
min-height: 0 !important;
|
|
311
|
+
background: #ffffff !important;
|
|
312
|
+
}
|
|
313
|
+
.app-panel.left-panel,
|
|
314
|
+
.panel-resize-handle,
|
|
315
|
+
.playground-panel-header,
|
|
316
|
+
.player-container .control-bar,
|
|
317
|
+
.player-container .player-subtitle,
|
|
318
|
+
.screenshot-viewer .screenshot-header,
|
|
319
|
+
.screenshot-viewer .screenshot-overlay {
|
|
320
|
+
display: none !important;
|
|
321
|
+
}
|
|
322
|
+
.app-panel.right-panel {
|
|
323
|
+
flex: 1 1 auto !important;
|
|
324
|
+
width: 100% !important;
|
|
325
|
+
padding-top: 0 !important;
|
|
326
|
+
box-shadow: none !important;
|
|
327
|
+
border-radius: 0 !important;
|
|
328
|
+
}
|
|
329
|
+
.panel-content.right-panel-content {
|
|
330
|
+
height: 100% !important;
|
|
331
|
+
padding: 0 !important;
|
|
332
|
+
overflow: hidden !important;
|
|
333
|
+
}
|
|
334
|
+
.player-container,
|
|
335
|
+
.result-wrapper,
|
|
336
|
+
.result-wrapper .player-container,
|
|
337
|
+
.screenshot-viewer,
|
|
338
|
+
.screenshot-viewer .screenshot-container,
|
|
339
|
+
.screenshot-viewer.screen-only > .screenshot-content {
|
|
340
|
+
width: 100% !important;
|
|
341
|
+
height: 100% !important;
|
|
342
|
+
min-height: 0 !important;
|
|
343
|
+
max-width: none !important;
|
|
344
|
+
max-height: none !important;
|
|
345
|
+
margin: 0 !important;
|
|
346
|
+
padding: 0 !important;
|
|
347
|
+
border: 0 !important;
|
|
348
|
+
border-radius: 0 !important;
|
|
349
|
+
background: #ffffff !important;
|
|
350
|
+
overflow: hidden !important;
|
|
351
|
+
}
|
|
352
|
+
.player-container .canvas-container,
|
|
353
|
+
.result-wrapper .player-container .canvas-container,
|
|
354
|
+
.screenshot-viewer .screenshot-content,
|
|
355
|
+
.screenshot-viewer .screenshot-image {
|
|
356
|
+
width: 100% !important;
|
|
357
|
+
height: 100% !important;
|
|
358
|
+
min-height: 0 !important;
|
|
359
|
+
max-height: none !important;
|
|
360
|
+
border-radius: 0 !important;
|
|
361
|
+
}
|
|
362
|
+
.player-container .canvas-container {
|
|
363
|
+
background: #ffffff !important;
|
|
364
|
+
}
|
|
365
|
+
.player-container .canvas-container .player-wrapper {
|
|
366
|
+
width: 100% !important;
|
|
367
|
+
height: 100% !important;
|
|
368
|
+
max-width: none !important;
|
|
369
|
+
max-height: none !important;
|
|
370
|
+
}
|
|
371
|
+
.player-container canvas,
|
|
372
|
+
.player-container video {
|
|
373
|
+
width: 100% !important;
|
|
374
|
+
height: 100% !important;
|
|
375
|
+
object-fit: contain !important;
|
|
376
|
+
}
|
|
377
|
+
`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function rewritePlaygroundHtml(html: string) {
|
|
381
|
+
const injectedStyle = `<style id="midscene-admin-playground-skin">${buildPlaygroundChromeCss()}</style>`;
|
|
382
|
+
let next = html
|
|
383
|
+
.replace(/(href|src)="\/static\//g, `$1="${PLAYGROUND_FRAME_PREFIX}/static/`)
|
|
384
|
+
.replace(/window\.SCRCPY_PORT\s*=\s*(\d+);/, `window.SCRCPY_PORT = $1;`);
|
|
385
|
+
|
|
386
|
+
if (next.includes('</head>')) {
|
|
387
|
+
next = next.replace('</head>', `${injectedStyle}</head>`);
|
|
388
|
+
} else {
|
|
389
|
+
next = `${injectedStyle}${next}`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return next;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function proxyPlaygroundRequest(req: IncomingMessage, res: ServerResponse, selectedDeviceId: string) {
|
|
396
|
+
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
|
397
|
+
const target = await resolvePlaygroundUrl(selectedDeviceId);
|
|
398
|
+
|
|
399
|
+
if (!target) {
|
|
400
|
+
res.statusCode = 503;
|
|
401
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
402
|
+
res.end(JSON.stringify({ message: '未检测到可用的 Android Playground 实例' }));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let targetPath = requestUrl.pathname;
|
|
407
|
+
if (targetPath === PLAYGROUND_FRAME_PREFIX || targetPath === `${PLAYGROUND_FRAME_PREFIX}/`) {
|
|
408
|
+
targetPath = '/';
|
|
409
|
+
} else if (targetPath.startsWith(`${PLAYGROUND_FRAME_PREFIX}/`)) {
|
|
410
|
+
targetPath = targetPath.slice(PLAYGROUND_FRAME_PREFIX.length);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const targetUrl = new URL(targetPath + requestUrl.search, target.url);
|
|
414
|
+
const headers = new Headers();
|
|
415
|
+
|
|
416
|
+
Object.entries(req.headers).forEach(([key, value]) => {
|
|
417
|
+
if (!value || key === 'host' || key === 'content-length') {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (Array.isArray(value)) {
|
|
421
|
+
value.forEach((item) => headers.append(key, item));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
headers.set(key, String(value));
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const method = req.method || 'GET';
|
|
428
|
+
const body = method === 'GET' || method === 'HEAD' ? undefined : await readRawBody(req);
|
|
429
|
+
const response = await fetch(targetUrl, {
|
|
430
|
+
method,
|
|
431
|
+
headers,
|
|
432
|
+
body,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
res.statusCode = response.status;
|
|
436
|
+
response.headers.forEach((value, key) => {
|
|
437
|
+
if (key === 'content-length') {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
res.setHeader(key, value);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
if (targetPath === '/' && response.headers.get('content-type')?.includes('text/html')) {
|
|
444
|
+
const html = await response.text();
|
|
445
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
446
|
+
res.end(rewritePlaygroundHtml(html));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
451
|
+
res.end(buffer);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function createApiMiddleware() {
|
|
455
|
+
const selectedDeviceByClient = new Map<string, string>();
|
|
456
|
+
let playgroundProcess: ReturnType<typeof spawn> | null = null;
|
|
457
|
+
let playgroundEnsurePromise: Promise<unknown> | null = null;
|
|
458
|
+
let lastPlaygroundEnsureAt = 0;
|
|
459
|
+
let lastPlaygroundEnsureDeviceId = '';
|
|
460
|
+
let activeScriptAbortController: AbortController | null = null;
|
|
461
|
+
|
|
462
|
+
const getClientId = (req: IncomingMessage, res: ServerResponse) => {
|
|
463
|
+
const existing = getCookie(req, 'midscene_client_id');
|
|
464
|
+
if (existing) return existing;
|
|
465
|
+
const next = `client_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
466
|
+
setCookie(res, next);
|
|
467
|
+
return next;
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const getSelectedDeviceId = (req: IncomingMessage, res: ServerResponse) => {
|
|
471
|
+
return selectedDeviceByClient.get(getClientId(req, res)) || '';
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const setSelectedDeviceId = (req: IncomingMessage, res: ServerResponse, deviceId: string) => {
|
|
475
|
+
selectedDeviceByClient.set(getClientId(req, res), deviceId);
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const listVisibleAndroidDevices = async (req: IncomingMessage) => {
|
|
479
|
+
const localDevices = isLocalRequest(req) ? await listAdbDevices() : [];
|
|
480
|
+
return [...localDevices, ...listRemoteAndroidDevices()];
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const ensureAndroidPlayground = async (selectedDeviceId: string) => {
|
|
484
|
+
const existing = await resolvePlaygroundUrl(selectedDeviceId);
|
|
485
|
+
if (isUsablePlaygroundTarget(existing, selectedDeviceId)) {
|
|
486
|
+
return { target: existing, started: false, skippedReason: '' };
|
|
487
|
+
}
|
|
488
|
+
if (existing && selectedDeviceId && !existing.sessionConnected) {
|
|
489
|
+
const created = await createPlaygroundSession(existing, selectedDeviceId);
|
|
490
|
+
if (isUsablePlaygroundTarget(created, selectedDeviceId)) {
|
|
491
|
+
return { target: created, started: false, skippedReason: '' };
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (existing?.sessionConnected) {
|
|
495
|
+
return {
|
|
496
|
+
target: null,
|
|
497
|
+
started: false,
|
|
498
|
+
skippedReason: '已有 Android Playground 实例连接到其他设备',
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
if (playgroundProcess && !playgroundProcess.killed) {
|
|
502
|
+
return {
|
|
503
|
+
target: null,
|
|
504
|
+
started: false,
|
|
505
|
+
skippedReason: 'Android Playground 正在启动中',
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
playgroundProcess = spawn('npx', ['--yes', '@midscene/android-playground'], {
|
|
510
|
+
cwd: appPath(),
|
|
511
|
+
env: {
|
|
512
|
+
...process.env,
|
|
513
|
+
...(selectedDeviceId ? { ANDROID_SERIAL: selectedDeviceId } : {}),
|
|
514
|
+
},
|
|
515
|
+
stdio: 'ignore',
|
|
516
|
+
detached: false,
|
|
517
|
+
});
|
|
518
|
+
playgroundProcess.once('exit', () => {
|
|
519
|
+
playgroundProcess = null;
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
return { target: null, started: true, skippedReason: '' };
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const ensureAndroidPlaygroundInBackground = (selectedDeviceId: string) => {
|
|
526
|
+
if (!selectedDeviceId) return;
|
|
527
|
+
if (isRemoteDeviceId(selectedDeviceId)) return;
|
|
528
|
+
const now = Date.now();
|
|
529
|
+
if (
|
|
530
|
+
playgroundEnsurePromise ||
|
|
531
|
+
(lastPlaygroundEnsureDeviceId === selectedDeviceId && now - lastPlaygroundEnsureAt < 10000)
|
|
532
|
+
) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
lastPlaygroundEnsureAt = now;
|
|
537
|
+
lastPlaygroundEnsureDeviceId = selectedDeviceId;
|
|
538
|
+
playgroundEnsurePromise = ensureAndroidPlayground(selectedDeviceId)
|
|
539
|
+
.catch(() => undefined)
|
|
540
|
+
.finally(() => {
|
|
541
|
+
playgroundEnsurePromise = null;
|
|
542
|
+
});
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const handler: NextHandleFunction = async (req, res, next) => {
|
|
546
|
+
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
|
547
|
+
const pathname = requestUrl.pathname;
|
|
548
|
+
const allowLocalDevice = isLocalRequest(req);
|
|
549
|
+
let selectedDeviceId = getSelectedDeviceId(req, res);
|
|
550
|
+
if (!allowLocalDevice && selectedDeviceId && !isRemoteDeviceId(selectedDeviceId)) {
|
|
551
|
+
selectedDeviceId = '';
|
|
552
|
+
setSelectedDeviceId(req, res, selectedDeviceId);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (isPlaygroundProxyPath(pathname)) {
|
|
556
|
+
try {
|
|
557
|
+
await proxyPlaygroundRequest(req, res, selectedDeviceId);
|
|
558
|
+
} catch (error) {
|
|
559
|
+
res.statusCode = 502;
|
|
560
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
561
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Playground 代理失败' }));
|
|
562
|
+
}
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (await handleRemoteAgentRequest(req, res)) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (await handleAppiumRecorderRequest(req, res, selectedDeviceId, allowLocalDevice)) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (req.url?.startsWith('/api/device-sessions') && req.method === 'GET') {
|
|
575
|
+
try {
|
|
576
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
577
|
+
res.end(JSON.stringify({ sessions: listDeviceSessions() }));
|
|
578
|
+
} catch (error) {
|
|
579
|
+
res.statusCode = 500;
|
|
580
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
581
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '读取设备会话失败' }));
|
|
582
|
+
}
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (req.url?.startsWith('/api/device-locks') && req.method === 'GET') {
|
|
587
|
+
try {
|
|
588
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
589
|
+
res.end(JSON.stringify({ locks: listActiveDeviceLocks() }));
|
|
590
|
+
} catch (error) {
|
|
591
|
+
res.statusCode = 500;
|
|
592
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
593
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '读取设备锁失败' }));
|
|
594
|
+
}
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (req.url?.startsWith('/api/operations') && req.method === 'GET') {
|
|
599
|
+
try {
|
|
600
|
+
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
|
601
|
+
const operationId = requestUrl.pathname.split('/').filter(Boolean)[2] || '';
|
|
602
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
603
|
+
if (operationId) {
|
|
604
|
+
const detail = getOperationWithEvents(operationId);
|
|
605
|
+
if (!detail) {
|
|
606
|
+
res.statusCode = 404;
|
|
607
|
+
res.end(JSON.stringify({ message: '执行记录不存在' }));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
res.end(JSON.stringify(detail));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const limit = Number(requestUrl.searchParams.get('limit') || 30);
|
|
614
|
+
res.end(JSON.stringify({ operations: listOperations(limit) }));
|
|
615
|
+
} catch (error) {
|
|
616
|
+
res.statusCode = 500;
|
|
617
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
618
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '读取执行记录失败' }));
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (req.url === '/api/config' && req.method === 'GET') {
|
|
624
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
625
|
+
res.end(JSON.stringify(loadConfig()));
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (req.url === '/api/config' && req.method === 'POST') {
|
|
630
|
+
try {
|
|
631
|
+
const config = await readBody<AppConfig>(req);
|
|
632
|
+
saveConfig(config);
|
|
633
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
634
|
+
res.end(JSON.stringify({ success: true }));
|
|
635
|
+
} catch (error) {
|
|
636
|
+
res.statusCode = 500;
|
|
637
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
638
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
639
|
+
}
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (req.url?.startsWith('/api/android-devices') && req.method === 'GET') {
|
|
644
|
+
try {
|
|
645
|
+
const devices = await listVisibleAndroidDevices(req);
|
|
646
|
+
const onlineDevices = devices.filter((device) => device.status === 'device');
|
|
647
|
+
if (!onlineDevices.some((device) => device.id === selectedDeviceId)) {
|
|
648
|
+
selectedDeviceId = onlineDevices[0]?.id || devices[0]?.id || '';
|
|
649
|
+
setSelectedDeviceId(req, res, selectedDeviceId);
|
|
650
|
+
}
|
|
651
|
+
if (selectedDeviceId) {
|
|
652
|
+
ensureDeviceSession({ deviceId: selectedDeviceId });
|
|
653
|
+
}
|
|
654
|
+
if (!isRemoteDeviceId(selectedDeviceId)) {
|
|
655
|
+
ensureAndroidPlaygroundInBackground(selectedDeviceId);
|
|
656
|
+
}
|
|
657
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
658
|
+
res.end(JSON.stringify({ devices, currentDeviceId: selectedDeviceId }));
|
|
659
|
+
} catch (error) {
|
|
660
|
+
res.statusCode = 500;
|
|
661
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
662
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
663
|
+
}
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (req.url === '/api/android-device' && req.method === 'POST') {
|
|
668
|
+
try {
|
|
669
|
+
const parsed = await readBody<{ deviceId?: string }>(req);
|
|
670
|
+
const devices = await listVisibleAndroidDevices(req);
|
|
671
|
+
const exists = devices.some((device) => device.id === parsed.deviceId);
|
|
672
|
+
if (!exists) {
|
|
673
|
+
throw new Error('设备不存在或已断开');
|
|
674
|
+
}
|
|
675
|
+
selectedDeviceId = parsed.deviceId || '';
|
|
676
|
+
setSelectedDeviceId(req, res, selectedDeviceId);
|
|
677
|
+
ensureDeviceSession({ deviceId: selectedDeviceId });
|
|
678
|
+
lastPlaygroundEnsureDeviceId = '';
|
|
679
|
+
if (!isRemoteDeviceId(selectedDeviceId)) {
|
|
680
|
+
ensureAndroidPlaygroundInBackground(selectedDeviceId);
|
|
681
|
+
}
|
|
682
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
683
|
+
res.end(JSON.stringify({ success: true, currentDeviceId: selectedDeviceId }));
|
|
684
|
+
} catch (error) {
|
|
685
|
+
res.statusCode = 500;
|
|
686
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
687
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
688
|
+
}
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (req.url?.startsWith('/api/android-preview') && req.method === 'GET') {
|
|
693
|
+
try {
|
|
694
|
+
const deviceId = getRequestedDeviceId(req) || selectedDeviceId;
|
|
695
|
+
if (!deviceId) {
|
|
696
|
+
throw new Error('未检测到可用设备');
|
|
697
|
+
}
|
|
698
|
+
assertDeviceAllowedForRequest(req, deviceId);
|
|
699
|
+
|
|
700
|
+
if (isRemoteDeviceId(deviceId)) {
|
|
701
|
+
const data = await sendRemoteCommand(deviceId, 'screenshot') as { base64?: string };
|
|
702
|
+
if (!data.base64) throw new Error('远程设备预览不可用');
|
|
703
|
+
res.setHeader('Content-Type', 'image/png');
|
|
704
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
705
|
+
res.end(Buffer.from(data.base64, 'base64'));
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
execFile('adb', ['-s', deviceId, 'exec-out', 'screencap', '-p'], { encoding: 'buffer', maxBuffer: 20 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
710
|
+
if (error) {
|
|
711
|
+
res.statusCode = 500;
|
|
712
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
713
|
+
res.end(JSON.stringify({ message: stderr || error.message || '设备预览不可用' }));
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
res.setHeader('Content-Type', 'image/png');
|
|
717
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
718
|
+
res.end(stdout);
|
|
719
|
+
});
|
|
720
|
+
} catch (error) {
|
|
721
|
+
res.statusCode = 500;
|
|
722
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
723
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
724
|
+
}
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (req.url === '/api/android-tap' && req.method === 'POST') {
|
|
729
|
+
try {
|
|
730
|
+
const parsed = await readBody<{ deviceId?: string; x?: number; y?: number }>(req);
|
|
731
|
+
const deviceId = parsed.deviceId || selectedDeviceId;
|
|
732
|
+
const x = Math.round(Number(parsed.x));
|
|
733
|
+
const y = Math.round(Number(parsed.y));
|
|
734
|
+
if (!deviceId) {
|
|
735
|
+
throw new Error('未检测到可用设备');
|
|
736
|
+
}
|
|
737
|
+
assertDeviceAllowedForRequest(req, deviceId);
|
|
738
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
739
|
+
throw new Error('点击坐标无效');
|
|
740
|
+
}
|
|
741
|
+
if (isRemoteDeviceId(deviceId)) {
|
|
742
|
+
const result = await sendRemoteCommand(deviceId, 'tap', { x, y });
|
|
743
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
744
|
+
res.end(JSON.stringify({ success: true, result }));
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const result = await execFileJson('adb', ['-s', deviceId, 'shell', 'input', 'tap', String(x), String(y)]);
|
|
748
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
749
|
+
res.end(JSON.stringify({ success: true, ...result }));
|
|
750
|
+
} catch (error) {
|
|
751
|
+
res.statusCode = 500;
|
|
752
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
753
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
754
|
+
}
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (req.url === '/api/android-keyevent' && req.method === 'POST') {
|
|
759
|
+
try {
|
|
760
|
+
const parsed = await readBody<{ deviceId?: string; keyCode?: number }>(req);
|
|
761
|
+
const deviceId = parsed.deviceId || selectedDeviceId;
|
|
762
|
+
const keyCode = Math.round(Number(parsed.keyCode));
|
|
763
|
+
if (!deviceId) {
|
|
764
|
+
throw new Error('未检测到可用设备');
|
|
765
|
+
}
|
|
766
|
+
assertDeviceAllowedForRequest(req, deviceId);
|
|
767
|
+
if (!Number.isFinite(keyCode)) {
|
|
768
|
+
throw new Error('按键无效');
|
|
769
|
+
}
|
|
770
|
+
if (isRemoteDeviceId(deviceId)) {
|
|
771
|
+
const result = await sendRemoteCommand(deviceId, 'key', { keyCode });
|
|
772
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
773
|
+
res.end(JSON.stringify({ success: true, result }));
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const result = await execFileJson('adb', ['-s', deviceId, 'shell', 'input', 'keyevent', String(keyCode)]);
|
|
777
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
778
|
+
res.end(JSON.stringify({ success: true, ...result }));
|
|
779
|
+
} catch (error) {
|
|
780
|
+
res.statusCode = 500;
|
|
781
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
782
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
783
|
+
}
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (req.url?.startsWith('/api/android-display-info') && req.method === 'GET') {
|
|
788
|
+
try {
|
|
789
|
+
const deviceId = getRequestedDeviceId(req) || selectedDeviceId;
|
|
790
|
+
if (!deviceId) {
|
|
791
|
+
throw new Error('未检测到可用设备');
|
|
792
|
+
}
|
|
793
|
+
assertDeviceAllowedForRequest(req, deviceId);
|
|
794
|
+
if (isRemoteDeviceId(deviceId)) {
|
|
795
|
+
const displayInfo = await sendRemoteCommand(deviceId, 'displayInfo') as AndroidDisplayInfo;
|
|
796
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
797
|
+
res.end(JSON.stringify({ deviceId, ...displayInfo }));
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const displayInfo = await getAdbDisplayInfo(deviceId);
|
|
801
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
802
|
+
res.end(JSON.stringify({ deviceId, ...displayInfo }));
|
|
803
|
+
} catch (error) {
|
|
804
|
+
res.statusCode = 500;
|
|
805
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
806
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
807
|
+
}
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (req.url === '/api/android-swipe' && req.method === 'POST') {
|
|
812
|
+
try {
|
|
813
|
+
const parsed = await readBody<{
|
|
814
|
+
deviceId?: string;
|
|
815
|
+
startX?: number;
|
|
816
|
+
startY?: number;
|
|
817
|
+
endX?: number;
|
|
818
|
+
endY?: number;
|
|
819
|
+
duration?: number;
|
|
820
|
+
}>(req);
|
|
821
|
+
const deviceId = parsed.deviceId || selectedDeviceId;
|
|
822
|
+
const startX = Math.round(Number(parsed.startX));
|
|
823
|
+
const startY = Math.round(Number(parsed.startY));
|
|
824
|
+
const endX = Math.round(Number(parsed.endX));
|
|
825
|
+
const endY = Math.round(Number(parsed.endY));
|
|
826
|
+
const duration = Math.max(80, Math.round(Number(parsed.duration) || 160));
|
|
827
|
+
if (!deviceId) {
|
|
828
|
+
throw new Error('未检测到可用设备');
|
|
829
|
+
}
|
|
830
|
+
assertDeviceAllowedForRequest(req, deviceId);
|
|
831
|
+
if (![startX, startY, endX, endY].every(Number.isFinite)) {
|
|
832
|
+
throw new Error('滑动坐标无效');
|
|
833
|
+
}
|
|
834
|
+
if (isRemoteDeviceId(deviceId)) {
|
|
835
|
+
const result = await sendRemoteCommand(deviceId, 'swipe', { startX, startY, endX, endY, duration });
|
|
836
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
837
|
+
res.end(JSON.stringify({ success: true, result }));
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const result = await execFileJson(
|
|
841
|
+
'adb',
|
|
842
|
+
['-s', deviceId, 'shell', 'input', 'swipe', String(startX), String(startY), String(endX), String(endY), String(duration)],
|
|
843
|
+
);
|
|
844
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
845
|
+
res.end(JSON.stringify({ success: true, ...result }));
|
|
846
|
+
} catch (error) {
|
|
847
|
+
res.statusCode = 500;
|
|
848
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
849
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
850
|
+
}
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (req.url === '/api/playground-status' && req.method === 'GET') {
|
|
855
|
+
try {
|
|
856
|
+
if (selectedDeviceId && isRemoteDeviceId(selectedDeviceId)) {
|
|
857
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
858
|
+
res.end(JSON.stringify({
|
|
859
|
+
available: false,
|
|
860
|
+
url: '',
|
|
861
|
+
previewKind: '',
|
|
862
|
+
deviceId: selectedDeviceId,
|
|
863
|
+
sessionConnected: false,
|
|
864
|
+
setupState: 'remote-device',
|
|
865
|
+
previewError: '',
|
|
866
|
+
}));
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
let resolved = await resolvePlaygroundUrl(selectedDeviceId);
|
|
870
|
+
if (resolved && selectedDeviceId && !isUsablePlaygroundTarget(resolved, selectedDeviceId) && !resolved.sessionConnected) {
|
|
871
|
+
resolved = await createPlaygroundSession(resolved, selectedDeviceId) || resolved;
|
|
872
|
+
}
|
|
873
|
+
const available = isUsablePlaygroundTarget(resolved, selectedDeviceId);
|
|
874
|
+
if (selectedDeviceId) {
|
|
875
|
+
ensureDeviceSession({
|
|
876
|
+
deviceId: selectedDeviceId,
|
|
877
|
+
patch: {
|
|
878
|
+
playgroundUrl: resolved?.url || '',
|
|
879
|
+
previewKind: resolved?.previewKind || '',
|
|
880
|
+
sessionConnected: resolved?.sessionConnected || false,
|
|
881
|
+
setupState: resolved?.setupState || '',
|
|
882
|
+
previewError: resolved?.previewError || '',
|
|
883
|
+
},
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
887
|
+
res.end(
|
|
888
|
+
JSON.stringify({
|
|
889
|
+
available,
|
|
890
|
+
url: resolved?.url || '',
|
|
891
|
+
previewKind: resolved?.previewKind || '',
|
|
892
|
+
deviceId: resolved?.deviceId || '',
|
|
893
|
+
sessionConnected: resolved?.sessionConnected || false,
|
|
894
|
+
setupState: resolved?.setupState || '',
|
|
895
|
+
previewError: resolved?.previewError || '',
|
|
896
|
+
}),
|
|
897
|
+
);
|
|
898
|
+
} catch {
|
|
899
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
900
|
+
res.end(JSON.stringify({ available: false, url: '', previewKind: '', deviceId: '', sessionConnected: false, setupState: '', previewError: '' }));
|
|
901
|
+
}
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (req.url === '/api/playground-ensure' && req.method === 'POST') {
|
|
906
|
+
try {
|
|
907
|
+
const result = await ensureAndroidPlayground(selectedDeviceId);
|
|
908
|
+
if (selectedDeviceId) {
|
|
909
|
+
ensureDeviceSession({
|
|
910
|
+
deviceId: selectedDeviceId,
|
|
911
|
+
patch: {
|
|
912
|
+
playgroundUrl: result.target?.url || '',
|
|
913
|
+
previewKind: result.target?.previewKind || '',
|
|
914
|
+
sessionConnected: result.target?.sessionConnected || false,
|
|
915
|
+
setupState: result.target?.setupState || result.skippedReason || '',
|
|
916
|
+
previewError: result.target?.previewError || '',
|
|
917
|
+
},
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
921
|
+
res.end(JSON.stringify({
|
|
922
|
+
started: result.started,
|
|
923
|
+
available: !!result.target,
|
|
924
|
+
url: result.target?.url || '',
|
|
925
|
+
skippedReason: result.skippedReason,
|
|
926
|
+
}));
|
|
927
|
+
} catch (error) {
|
|
928
|
+
res.statusCode = 500;
|
|
929
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
930
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '启动 Android Playground 失败' }));
|
|
931
|
+
}
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (req.url === '/api/test-model' && req.method === 'POST') {
|
|
936
|
+
const startedAt = Date.now();
|
|
937
|
+
let modelKey: 'midscene' | 'scriptOptimizer' = 'midscene';
|
|
938
|
+
let model: {
|
|
939
|
+
provider?: 'custom' | 'codex';
|
|
940
|
+
baseUrl?: string;
|
|
941
|
+
apiKey?: string;
|
|
942
|
+
name?: string;
|
|
943
|
+
family?: string;
|
|
944
|
+
} = {};
|
|
945
|
+
try {
|
|
946
|
+
const parsed = await readBody<{
|
|
947
|
+
modelKey?: 'midscene' | 'scriptOptimizer';
|
|
948
|
+
model?: {
|
|
949
|
+
provider?: 'custom' | 'codex';
|
|
950
|
+
baseUrl?: string;
|
|
951
|
+
apiKey?: string;
|
|
952
|
+
name?: string;
|
|
953
|
+
family?: string;
|
|
954
|
+
};
|
|
955
|
+
}>(req);
|
|
956
|
+
model = parsed.model || {};
|
|
957
|
+
modelKey = parsed.modelKey === 'scriptOptimizer' ? 'scriptOptimizer' : 'midscene';
|
|
958
|
+
|
|
959
|
+
const result = await testModelConnection({
|
|
960
|
+
provider: model.provider,
|
|
961
|
+
baseUrl: model.baseUrl || '',
|
|
962
|
+
apiKey: model.apiKey || '',
|
|
963
|
+
name: model.name || '',
|
|
964
|
+
family: model.family || '',
|
|
965
|
+
});
|
|
966
|
+
saveModelUsageRecord({
|
|
967
|
+
modelKey,
|
|
968
|
+
provider: modelKey === 'midscene' ? model.provider || 'custom' : 'custom',
|
|
969
|
+
modelName: model.name || '',
|
|
970
|
+
family: model.family || '',
|
|
971
|
+
success: true,
|
|
972
|
+
durationMs: result.durationMs ?? Date.now() - startedAt,
|
|
973
|
+
promptTokens: result.usage?.promptTokens,
|
|
974
|
+
completionTokens: result.usage?.completionTokens,
|
|
975
|
+
totalTokens: result.usage?.totalTokens,
|
|
976
|
+
});
|
|
977
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
978
|
+
res.end(JSON.stringify(result));
|
|
979
|
+
} catch (error) {
|
|
980
|
+
saveModelUsageRecord({
|
|
981
|
+
modelKey,
|
|
982
|
+
provider: modelKey === 'midscene' ? model.provider || 'custom' : 'custom',
|
|
983
|
+
modelName: model.name || '',
|
|
984
|
+
family: model.family || '',
|
|
985
|
+
success: false,
|
|
986
|
+
durationMs: Date.now() - startedAt,
|
|
987
|
+
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
|
988
|
+
});
|
|
989
|
+
res.statusCode = 500;
|
|
990
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
991
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
992
|
+
}
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (req.url?.startsWith('/api/model-usage-records') && req.method === 'GET') {
|
|
997
|
+
try {
|
|
998
|
+
const requestUrl = new URL(req.url, 'http://localhost');
|
|
999
|
+
const limit = Number(requestUrl.searchParams.get('limit') || 50);
|
|
1000
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1001
|
+
res.end(JSON.stringify({ records: listModelUsageRecords(limit) }));
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
res.statusCode = 500;
|
|
1004
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1005
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
1006
|
+
}
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (req.url === '/api/app-presets' && req.method === 'GET') {
|
|
1011
|
+
try {
|
|
1012
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1013
|
+
res.end(JSON.stringify({ apps: listAppPresetRecords() }));
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
res.statusCode = 500;
|
|
1016
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1017
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
1018
|
+
}
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (req.url === '/api/save-app-preset' && req.method === 'POST') {
|
|
1023
|
+
try {
|
|
1024
|
+
const parsed = await readBody<{ id?: string; name?: string; packageName?: string }>(req);
|
|
1025
|
+
const app = saveAppPresetRecord({
|
|
1026
|
+
id: parsed.id,
|
|
1027
|
+
name: parsed.name || '',
|
|
1028
|
+
packageName: parsed.packageName || '',
|
|
1029
|
+
});
|
|
1030
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1031
|
+
res.end(JSON.stringify({ app }));
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
res.statusCode = 400;
|
|
1034
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1035
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '保存 App 配置失败' }));
|
|
1036
|
+
}
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (req.url === '/api/delete-app-preset' && req.method === 'POST') {
|
|
1041
|
+
try {
|
|
1042
|
+
const parsed = await readBody<{ id?: string }>(req);
|
|
1043
|
+
if (!parsed.id) throw new Error('App ID 不能为空');
|
|
1044
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1045
|
+
res.end(JSON.stringify(removeAppPresetRecord(parsed.id)));
|
|
1046
|
+
} catch (error) {
|
|
1047
|
+
res.statusCode = 400;
|
|
1048
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1049
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '删除 App 配置失败' }));
|
|
1050
|
+
}
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (req.url === '/api/check-script' && req.method === 'POST') {
|
|
1055
|
+
try {
|
|
1056
|
+
const parsed = await readBody<{ scriptName?: string }>(req);
|
|
1057
|
+
const result = checkGeneratedScriptExists({
|
|
1058
|
+
scriptName: parsed.scriptName || 'generated-script',
|
|
1059
|
+
});
|
|
1060
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1061
|
+
res.end(JSON.stringify(result));
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
res.statusCode = 500;
|
|
1064
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1065
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
1066
|
+
}
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
if (pathname === '/api/import-test-case' && req.method === 'POST') {
|
|
1071
|
+
try {
|
|
1072
|
+
const contentLength = Number(req.headers['content-length'] || 0);
|
|
1073
|
+
if (contentLength > MAX_TEST_CASE_FILE_SIZE) {
|
|
1074
|
+
throw new Error('文件大小不能超过 10MB');
|
|
1075
|
+
}
|
|
1076
|
+
const fileName = requestUrl.searchParams.get('fileName') || '';
|
|
1077
|
+
const buffer = await readRawBody(req);
|
|
1078
|
+
const result = await importTestCaseFile({ fileName, buffer });
|
|
1079
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1080
|
+
res.end(JSON.stringify(result));
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
res.statusCode = 400;
|
|
1083
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1084
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '测试用例文件解析失败' }));
|
|
1085
|
+
}
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
if (req.url === '/api/validate-script-code' && req.method === 'POST') {
|
|
1090
|
+
try {
|
|
1091
|
+
const parsed = await readBody<{ code?: string }>(req);
|
|
1092
|
+
validateGeneratedScriptCode(parsed.code || '');
|
|
1093
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1094
|
+
res.end(JSON.stringify({ valid: true }));
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
res.statusCode = 400;
|
|
1097
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1098
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '代码格式错误' }));
|
|
1099
|
+
}
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
if (req.url === '/api/scripts' && req.method === 'GET') {
|
|
1104
|
+
try {
|
|
1105
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1106
|
+
res.end(JSON.stringify({ scripts: listScriptRecords() }));
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
res.statusCode = 500;
|
|
1109
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1110
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
1111
|
+
}
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if (req.url === '/api/delete-script' && req.method === 'POST') {
|
|
1116
|
+
try {
|
|
1117
|
+
const parsed = await readBody<{ id?: string }>(req);
|
|
1118
|
+
if (!parsed.id) {
|
|
1119
|
+
throw new Error('脚本 ID 不能为空');
|
|
1120
|
+
}
|
|
1121
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1122
|
+
res.end(JSON.stringify(deleteGeneratedScript({ id: parsed.id })));
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
res.statusCode = 500;
|
|
1125
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1126
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
1127
|
+
}
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
if (req.url === '/api/update-script-code' && req.method === 'POST') {
|
|
1132
|
+
try {
|
|
1133
|
+
const parsed = await readBody<{ id?: string; code?: string }>(req);
|
|
1134
|
+
if (!parsed.id) {
|
|
1135
|
+
throw new Error('脚本 ID 不能为空');
|
|
1136
|
+
}
|
|
1137
|
+
const result = updateGeneratedScriptCode({
|
|
1138
|
+
id: parsed.id,
|
|
1139
|
+
code: parsed.code || '',
|
|
1140
|
+
});
|
|
1141
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1142
|
+
res.end(JSON.stringify(result));
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
res.statusCode = 400;
|
|
1145
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1146
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '保存代码失败' }));
|
|
1147
|
+
}
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (
|
|
1152
|
+
req.url !== '/api/generate' &&
|
|
1153
|
+
req.url !== '/api/run-script' &&
|
|
1154
|
+
req.url !== '/api/stop-script' &&
|
|
1155
|
+
req.url !== '/api/save-script'
|
|
1156
|
+
) {
|
|
1157
|
+
next();
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
try {
|
|
1162
|
+
if (req.url === '/api/generate' && req.method === 'POST') {
|
|
1163
|
+
const startedAt = Date.now();
|
|
1164
|
+
const config = loadConfig();
|
|
1165
|
+
const parsed = await readBody<{
|
|
1166
|
+
prompt?: string;
|
|
1167
|
+
}>(req);
|
|
1168
|
+
|
|
1169
|
+
try {
|
|
1170
|
+
const result = await generatePlan({
|
|
1171
|
+
prompt: parsed.prompt || '',
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
saveModelUsageRecord({
|
|
1175
|
+
modelKey: 'scriptOptimizer',
|
|
1176
|
+
provider: 'custom',
|
|
1177
|
+
modelName: config.scriptOptimizer.model.name || '',
|
|
1178
|
+
family: '',
|
|
1179
|
+
success: true,
|
|
1180
|
+
durationMs: result.durationMs || Date.now() - startedAt,
|
|
1181
|
+
promptTokens: result.usage?.promptTokens,
|
|
1182
|
+
completionTokens: result.usage?.completionTokens,
|
|
1183
|
+
totalTokens: result.usage?.totalTokens,
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1187
|
+
res.end(JSON.stringify(result));
|
|
1188
|
+
} catch (error) {
|
|
1189
|
+
saveModelUsageRecord({
|
|
1190
|
+
modelKey: 'scriptOptimizer',
|
|
1191
|
+
provider: 'custom',
|
|
1192
|
+
modelName: config.scriptOptimizer.model.name || '',
|
|
1193
|
+
family: '',
|
|
1194
|
+
success: false,
|
|
1195
|
+
durationMs: Date.now() - startedAt,
|
|
1196
|
+
errorMessage: error instanceof Error ? error.message : 'AI 生成失败',
|
|
1197
|
+
});
|
|
1198
|
+
throw error;
|
|
1199
|
+
}
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
if (req.url === '/api/run-script' && req.method === 'POST') {
|
|
1204
|
+
const parsed = await readBody<{
|
|
1205
|
+
code?: string;
|
|
1206
|
+
scriptName?: string;
|
|
1207
|
+
deviceId?: string;
|
|
1208
|
+
steps?: ScriptStepRecord[];
|
|
1209
|
+
}>(req);
|
|
1210
|
+
const deviceId = parsed.deviceId || selectedDeviceId;
|
|
1211
|
+
const scriptName = parsed.scriptName || 'generated-script';
|
|
1212
|
+
if (!deviceId) {
|
|
1213
|
+
throw new Error('未检测到可用设备');
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
const deviceSession = ensureDeviceSession({ deviceId });
|
|
1217
|
+
const operation = startScriptRunOperation({
|
|
1218
|
+
scriptName,
|
|
1219
|
+
deviceId,
|
|
1220
|
+
sessionId: deviceSession.id,
|
|
1221
|
+
request: {
|
|
1222
|
+
scriptName,
|
|
1223
|
+
deviceId,
|
|
1224
|
+
stepCount: Array.isArray(parsed.steps) ? parsed.steps.length : 0,
|
|
1225
|
+
},
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
try {
|
|
1229
|
+
acquireDeviceLock({
|
|
1230
|
+
deviceId,
|
|
1231
|
+
ownerType: 'script_run',
|
|
1232
|
+
ownerId: operation.id,
|
|
1233
|
+
metadata: {
|
|
1234
|
+
scriptName,
|
|
1235
|
+
sessionId: deviceSession.id,
|
|
1236
|
+
},
|
|
1237
|
+
});
|
|
1238
|
+
} catch (error) {
|
|
1239
|
+
if (error instanceof DeviceLockConflictError) {
|
|
1240
|
+
finishScriptRunOperation({
|
|
1241
|
+
operationId: operation.id,
|
|
1242
|
+
status: 'failed',
|
|
1243
|
+
errorMessage: error.message,
|
|
1244
|
+
result: {
|
|
1245
|
+
conflict: error.conflict,
|
|
1246
|
+
},
|
|
1247
|
+
});
|
|
1248
|
+
res.statusCode = 409;
|
|
1249
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1250
|
+
res.end(JSON.stringify({ message: error.message, conflict: error.conflict, operation }));
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
throw error;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const runAbortController = new AbortController();
|
|
1257
|
+
activeScriptAbortController = runAbortController;
|
|
1258
|
+
res.on('close', () => {
|
|
1259
|
+
if (!res.writableEnded) {
|
|
1260
|
+
runAbortController.abort();
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
1265
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
1266
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
1267
|
+
writeNdjson(res, { type: 'operation', operation });
|
|
1268
|
+
|
|
1269
|
+
const usageImportSinceMs = Date.now();
|
|
1270
|
+
try {
|
|
1271
|
+
const result = await runGeneratedScript({
|
|
1272
|
+
code: parsed.code || '',
|
|
1273
|
+
scriptName,
|
|
1274
|
+
deviceId,
|
|
1275
|
+
steps: parsed.steps || [],
|
|
1276
|
+
signal: runAbortController.signal,
|
|
1277
|
+
onEvent: (event) => {
|
|
1278
|
+
if (event.type === 'output') {
|
|
1279
|
+
recordOperationEvent({
|
|
1280
|
+
operationId: operation.id,
|
|
1281
|
+
eventType: 'output',
|
|
1282
|
+
message: event.chunk,
|
|
1283
|
+
});
|
|
1284
|
+
} else if (event.type === 'step') {
|
|
1285
|
+
recordOperationEvent({
|
|
1286
|
+
operationId: operation.id,
|
|
1287
|
+
eventType: `step_${event.status}`,
|
|
1288
|
+
message: event.title || '',
|
|
1289
|
+
data: event,
|
|
1290
|
+
});
|
|
1291
|
+
} else if (event.type === 'done') {
|
|
1292
|
+
recordOperationEvent({
|
|
1293
|
+
operationId: operation.id,
|
|
1294
|
+
eventType: 'script_done',
|
|
1295
|
+
data: {
|
|
1296
|
+
success: event.success,
|
|
1297
|
+
filePath: event.filePath,
|
|
1298
|
+
},
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
writeNdjson(res, event);
|
|
1302
|
+
},
|
|
1303
|
+
});
|
|
1304
|
+
try {
|
|
1305
|
+
const usageImportResult = importMidsceneModelUsage({
|
|
1306
|
+
sinceMs: usageImportSinceMs,
|
|
1307
|
+
model: loadConfig().midscene.model,
|
|
1308
|
+
success: result.success,
|
|
1309
|
+
});
|
|
1310
|
+
if (usageImportResult.imported > 0) {
|
|
1311
|
+
recordOperationEvent({
|
|
1312
|
+
operationId: operation.id,
|
|
1313
|
+
eventType: 'model_usage_imported',
|
|
1314
|
+
data: usageImportResult,
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
} catch (error) {
|
|
1318
|
+
recordOperationEvent({
|
|
1319
|
+
operationId: operation.id,
|
|
1320
|
+
eventType: 'model_usage_import_failed',
|
|
1321
|
+
message: error instanceof Error ? error.message : '模型消耗导入失败',
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
finishScriptRunOperation({
|
|
1325
|
+
operationId: operation.id,
|
|
1326
|
+
status: result.success ? 'succeeded' : runAbortController.signal.aborted ? 'cancelled' : 'failed',
|
|
1327
|
+
output: result.output,
|
|
1328
|
+
filePath: result.filePath,
|
|
1329
|
+
result: {
|
|
1330
|
+
success: result.success,
|
|
1331
|
+
},
|
|
1332
|
+
});
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
finishScriptRunOperation({
|
|
1335
|
+
operationId: operation.id,
|
|
1336
|
+
status: runAbortController.signal.aborted ? 'cancelled' : 'failed',
|
|
1337
|
+
errorMessage: error instanceof Error ? error.message : '执行失败',
|
|
1338
|
+
});
|
|
1339
|
+
writeNdjson(res, {
|
|
1340
|
+
type: 'error',
|
|
1341
|
+
message: error instanceof Error ? error.message : '执行失败',
|
|
1342
|
+
});
|
|
1343
|
+
} finally {
|
|
1344
|
+
releaseDeviceLock(operation.id);
|
|
1345
|
+
if (activeScriptAbortController === runAbortController) {
|
|
1346
|
+
activeScriptAbortController = null;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
res.end();
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
if (req.url === '/api/stop-script' && req.method === 'POST') {
|
|
1354
|
+
const hadActiveRun = Boolean(activeScriptAbortController);
|
|
1355
|
+
activeScriptAbortController?.abort();
|
|
1356
|
+
activeScriptAbortController = null;
|
|
1357
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1358
|
+
res.end(JSON.stringify({ success: true, stopped: hadActiveRun }));
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
if (req.url === '/api/save-script' && req.method === 'POST') {
|
|
1363
|
+
const parsed = await readBody<{
|
|
1364
|
+
code?: string;
|
|
1365
|
+
scriptName?: string;
|
|
1366
|
+
promptTitle?: string;
|
|
1367
|
+
sourcePrompt?: string;
|
|
1368
|
+
steps?: ScriptStepRecord[];
|
|
1369
|
+
}>(req);
|
|
1370
|
+
const result = saveGeneratedScript({
|
|
1371
|
+
code: parsed.code || '',
|
|
1372
|
+
scriptName: parsed.scriptName || 'generated-script',
|
|
1373
|
+
promptTitle: parsed.promptTitle || '',
|
|
1374
|
+
sourcePrompt: parsed.sourcePrompt || '',
|
|
1375
|
+
steps: Array.isArray(parsed.steps) ? parsed.steps : [],
|
|
1376
|
+
});
|
|
1377
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1378
|
+
res.end(JSON.stringify(result));
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
res.statusCode = 500;
|
|
1383
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1384
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
1385
|
+
}
|
|
1386
|
+
};
|
|
1387
|
+
|
|
1388
|
+
return handler;
|
|
1389
|
+
}
|