android-midscene-automation 0.1.13 → 0.1.14

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.
@@ -32,6 +32,7 @@ export type AppiumRecordedStepRecord = {
32
32
  | 'pinch'
33
33
  | 'runScript';
34
34
  label: string;
35
+ note?: string;
35
36
  selector?: {
36
37
  strategy: 'accessibilityId' | 'id' | 'androidUiAutomator' | 'xpath' | 'bounds';
37
38
  value?: string;
@@ -80,6 +81,7 @@ export type AppiumRecordedStepRecord = {
80
81
  parentBranch?: 'yes' | 'no';
81
82
  successTargetId?: string;
82
83
  failureTargetId?: string;
84
+ textMatch?: 'contains' | 'exact';
83
85
  collapsed?: boolean;
84
86
  };
85
87
  pageBefore?: {
@@ -137,6 +139,17 @@ type AppiumRecordedScriptRow = {
137
139
  updated_at: string;
138
140
  };
139
141
 
142
+ export type AppiumReplayReportRecord = {
143
+ id: string;
144
+ scriptId: string;
145
+ scriptName: string;
146
+ success: boolean;
147
+ filePath: string;
148
+ startedAt: string;
149
+ completedAt: string;
150
+ createdAt: string;
151
+ };
152
+
140
153
  let initialized = false;
141
154
 
142
155
  function createAppiumRecordedScriptsTable() {
@@ -156,10 +169,28 @@ function createAppiumRecordedScriptsTable() {
156
169
  `);
157
170
  }
158
171
 
172
+ function createAppiumReplayReportsTable() {
173
+ runSql(`
174
+ CREATE TABLE IF NOT EXISTS appium_replay_reports (
175
+ id TEXT PRIMARY KEY,
176
+ script_id TEXT NOT NULL,
177
+ script_name TEXT NOT NULL,
178
+ success INTEGER NOT NULL,
179
+ file_path TEXT NOT NULL,
180
+ started_at TEXT NOT NULL,
181
+ completed_at TEXT NOT NULL,
182
+ created_at TEXT NOT NULL
183
+ );
184
+ CREATE INDEX IF NOT EXISTS idx_appium_replay_reports_script_id
185
+ ON appium_replay_reports(script_id, created_at DESC);
186
+ `);
187
+ }
188
+
159
189
  function initDb() {
160
190
  if (initialized) return;
161
191
  initialized = true;
162
192
  createAppiumRecordedScriptsTable();
193
+ createAppiumReplayReportsTable();
163
194
  const columns = querySql<{ name: string }>('PRAGMA table_info(appium_recorded_scripts);').map((column) => column.name);
164
195
  if (columns.includes('steps_json') || !columns.includes('flow_json')) {
165
196
  runSql('DROP TABLE appium_recorded_scripts;');
@@ -320,3 +351,50 @@ export function deleteAppiumRecordedScript(id: string) {
320
351
  WHERE id = ${sqlString(id)};
321
352
  `);
322
353
  }
354
+
355
+ export function importAppiumRecordedScript(input: {
356
+ name: string;
357
+ appPackage: string;
358
+ appActivity?: string;
359
+ deviceId?: string;
360
+ steps: AppiumRecordedStepRecord[];
361
+ }) {
362
+ initDb();
363
+ const baseName = input.name.trim() || '导入脚本';
364
+ const existingNames = new Set(listAppiumRecordedScripts().map((script) => script.name));
365
+ let name = baseName;
366
+ let suffix = 2;
367
+ while (existingNames.has(name)) {
368
+ name = `${baseName} (${suffix})`;
369
+ suffix += 1;
370
+ }
371
+ return saveAppiumRecordedScript({ ...input, name });
372
+ }
373
+
374
+ export function saveAppiumReplayReport(input: Omit<AppiumReplayReportRecord, 'id' | 'createdAt'>) {
375
+ initDb();
376
+ const id = createId('appium_report');
377
+ const createdAt = new Date().toISOString();
378
+ runSql(`
379
+ INSERT INTO appium_replay_reports (
380
+ id,
381
+ script_id,
382
+ script_name,
383
+ success,
384
+ file_path,
385
+ started_at,
386
+ completed_at,
387
+ created_at
388
+ ) VALUES (
389
+ ${sqlString(id)},
390
+ ${sqlString(input.scriptId)},
391
+ ${sqlString(input.scriptName)},
392
+ ${input.success ? 1 : 0},
393
+ ${sqlString(input.filePath)},
394
+ ${sqlString(input.startedAt)},
395
+ ${sqlString(input.completedAt)},
396
+ ${sqlString(createdAt)}
397
+ );
398
+ `);
399
+ return { id, ...input, createdAt };
400
+ }
@@ -6,12 +6,17 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
6
6
  import {
7
7
  deleteAppiumRecordedScript,
8
8
  getAppiumRecordedScript,
9
+ importAppiumRecordedScript,
9
10
  listAppiumRecordedScripts,
10
11
  saveAppiumRecordedScript,
11
12
  type AppiumRecordedStepRecord,
12
13
  } from './repository';
13
- import { replayAppiumScript } from './appium-runner';
14
+ import { launchAppOnDevice, replayAppiumScript } from './appium-runner';
14
15
  import { isRemoteDeviceId, sendRemoteCommand } from '../remote-agents/registry';
16
+ import { getAdbCommand } from '../android-sdk';
17
+
18
+ const treeDumpTasks = new Map<string, Promise<string>>();
19
+ const replayingDevices = new Set<string>();
15
20
 
16
21
  function sendJson(res: ServerResponse, payload: unknown, statusCode = 200) {
17
22
  res.statusCode = statusCode;
@@ -19,6 +24,17 @@ function sendJson(res: ServerResponse, payload: unknown, statusCode = 200) {
19
24
  res.end(JSON.stringify(payload));
20
25
  }
21
26
 
27
+ function sendJsonDownload(res: ServerResponse, payload: unknown, fileName: string) {
28
+ res.statusCode = 200;
29
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
30
+ res.setHeader('Content-Disposition', `attachment; filename="appium-script.json"; filename*=UTF-8''${encodeURIComponent(fileName)}`);
31
+ res.end(JSON.stringify(payload, null, 2));
32
+ }
33
+
34
+ function sendStreamEvent(res: ServerResponse, payload: unknown) {
35
+ res.write(`${JSON.stringify(payload)}\n`);
36
+ }
37
+
22
38
  async function readBody<T>(req: IncomingMessage) {
23
39
  let body = '';
24
40
  req.on('data', (chunk) => {
@@ -44,19 +60,30 @@ function execFileText(command: string, args: string[] = []) {
44
60
  }
45
61
 
46
62
  async function dumpWindowHierarchy(deviceId: string) {
47
- const localPath = path.join(os.tmpdir(), `midscene-appium-${deviceId.replace(/[^\w.-]/g, '_')}-${Date.now()}.xml`);
48
- const remotePath = '/data/local/tmp/midscene_appium_uidump.xml';
49
- await execFileText('adb', ['-s', deviceId, 'shell', 'uiautomator', 'dump', remotePath]);
50
- await execFileText('adb', ['-s', deviceId, 'pull', remotePath, localPath]);
63
+ const activeTask = treeDumpTasks.get(deviceId);
64
+ if (activeTask) return activeTask;
65
+
66
+ const task = (async () => {
67
+ const localPath = path.join(os.tmpdir(), `midscene-appium-${deviceId.replace(/[^\w.-]/g, '_')}-${Date.now()}.xml`);
68
+ const remotePath = '/data/local/tmp/midscene_appium_uidump.xml';
69
+ await execFileText(getAdbCommand(), ['-s', deviceId, 'shell', 'uiautomator', 'dump', remotePath]);
70
+ await execFileText(getAdbCommand(), ['-s', deviceId, 'pull', remotePath, localPath]);
71
+ try {
72
+ return await fs.readFile(localPath, 'utf8');
73
+ } finally {
74
+ await fs.unlink(localPath).catch(() => undefined);
75
+ }
76
+ })();
77
+ treeDumpTasks.set(deviceId, task);
51
78
  try {
52
- return await fs.readFile(localPath, 'utf8');
79
+ return await task;
53
80
  } finally {
54
- await fs.unlink(localPath).catch(() => undefined);
81
+ if (treeDumpTasks.get(deviceId) === task) treeDumpTasks.delete(deviceId);
55
82
  }
56
83
  }
57
84
 
58
85
  async function getCurrentActivity(deviceId: string) {
59
- const output = await execFileText('adb', ['-s', deviceId, 'shell', 'dumpsys', 'activity', 'activities']);
86
+ const output = await execFileText(getAdbCommand(), ['-s', deviceId, 'shell', 'dumpsys', 'activity', 'activities']);
60
87
  const resumedLine = output
61
88
  .split(/\r?\n/)
62
89
  .find((line) => /(?:topResumedActivity|ResumedActivity|mResumedActivity)/.test(line));
@@ -64,7 +91,7 @@ async function getCurrentActivity(deviceId: string) {
64
91
  }
65
92
 
66
93
  async function tapDevice(deviceId: string, x: number, y: number) {
67
- await execFileText('adb', [
94
+ await execFileText(getAdbCommand(), [
68
95
  '-s',
69
96
  deviceId,
70
97
  'shell',
@@ -76,7 +103,7 @@ async function tapDevice(deviceId: string, x: number, y: number) {
76
103
  }
77
104
 
78
105
  async function pressDeviceKey(deviceId: string, keyCode: number) {
79
- await execFileText('adb', [
106
+ await execFileText(getAdbCommand(), [
80
107
  '-s',
81
108
  deviceId,
82
109
  'shell',
@@ -109,6 +136,10 @@ export async function handleAppiumRecorderRequest(
109
136
  const deviceId = requestUrl.searchParams.get('deviceId')?.trim() || selectedDeviceId;
110
137
  if (!deviceId) throw new Error('未检测到可用设备');
111
138
  assertDeviceAllowed(deviceId);
139
+ if (replayingDevices.has(deviceId)) {
140
+ sendJson(res, { message: '回放期间已暂停组件树刷新' }, 409);
141
+ return true;
142
+ }
112
143
  if (isRemoteDeviceId(deviceId)) {
113
144
  const data = await sendRemoteCommand(deviceId, 'tree') as { xml?: string; activity?: string; dumpedAt?: string };
114
145
  sendJson(res, {
@@ -160,6 +191,19 @@ export async function handleAppiumRecorderRequest(
160
191
  return true;
161
192
  }
162
193
 
194
+ if (pathname === '/api/appium-recorder/launch-app' && req.method === 'POST') {
195
+ const parsed = await readBody<{ deviceId?: string; packageName?: string }>(req);
196
+ const deviceId = parsed.deviceId?.trim() || selectedDeviceId;
197
+ const packageName = parsed.packageName?.trim() || '';
198
+ if (!deviceId) throw new Error('未检测到可用设备');
199
+ if (!packageName) throw new Error('请选择预设 App');
200
+ assertDeviceAllowed(deviceId);
201
+ if (isRemoteDeviceId(deviceId)) throw new Error('远程设备暂不支持直接启动 App');
202
+ await launchAppOnDevice(deviceId, packageName);
203
+ sendJson(res, { success: true });
204
+ return true;
205
+ }
206
+
163
207
  if (pathname === '/api/appium-recorder/scripts' && req.method === 'GET') {
164
208
  sendJson(res, { scripts: listAppiumRecordedScripts() });
165
209
  return true;
@@ -186,6 +230,53 @@ export async function handleAppiumRecorderRequest(
186
230
  return true;
187
231
  }
188
232
 
233
+ if (pathname === '/api/appium-recorder/scripts/import' && req.method === 'POST') {
234
+ const parsed = await readBody<{
235
+ schemaVersion?: number;
236
+ script?: {
237
+ name?: string;
238
+ appPackage?: string;
239
+ appActivity?: string;
240
+ deviceId?: string;
241
+ steps?: AppiumRecordedStepRecord[];
242
+ };
243
+ }>(req);
244
+ const imported = parsed.script;
245
+ if (!imported || typeof imported !== 'object') throw new Error('导入文件缺少 script 数据');
246
+ if (!Array.isArray(imported.steps)) throw new Error('导入文件的 steps 格式无效');
247
+ const invalidStep = imported.steps.some((step) => (
248
+ !step || typeof step !== 'object' || typeof step.id !== 'string' || typeof step.type !== 'string'
249
+ ));
250
+ if (invalidStep) throw new Error('导入文件包含无效节点');
251
+ const script = importAppiumRecordedScript({
252
+ name: imported.name || '',
253
+ appPackage: imported.appPackage || '',
254
+ appActivity: imported.appActivity || '',
255
+ deviceId: imported.deviceId || '',
256
+ steps: imported.steps,
257
+ });
258
+ sendJson(res, { script });
259
+ return true;
260
+ }
261
+
262
+ const exportMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/export$/);
263
+ if (exportMatch && req.method === 'GET') {
264
+ const script = getAppiumRecordedScript(decodeURIComponent(exportMatch[1]));
265
+ if (!script) throw new Error('Appium 录制脚本不存在');
266
+ sendJsonDownload(res, {
267
+ schemaVersion: 1,
268
+ exportedAt: new Date().toISOString(),
269
+ script: {
270
+ name: script.name,
271
+ appPackage: script.appPackage,
272
+ appActivity: script.appActivity,
273
+ deviceId: script.deviceId,
274
+ steps: script.steps,
275
+ },
276
+ }, `${script.name}.json`);
277
+ return true;
278
+ }
279
+
189
280
  const deleteMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)$/);
190
281
  if (deleteMatch && req.method === 'DELETE') {
191
282
  deleteAppiumRecordedScript(decodeURIComponent(deleteMatch[1]));
@@ -200,20 +291,56 @@ export async function handleAppiumRecorderRequest(
200
291
  if (!script) throw new Error('Appium 录制脚本不存在');
201
292
  const deviceId = parsed.deviceId || selectedDeviceId;
202
293
  assertDeviceAllowed(deviceId);
294
+ const streamOutput = req.headers.accept?.includes('application/x-ndjson') === true;
295
+ if (streamOutput) {
296
+ res.statusCode = 200;
297
+ res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
298
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
299
+ res.setHeader('X-Accel-Buffering', 'no');
300
+ res.flushHeaders();
301
+ }
203
302
  if (isRemoteDeviceId(deviceId)) {
204
303
  const result = await sendRemoteCommand(deviceId, 'replay', { script }) as { success?: boolean; output?: string };
205
- sendJson(res, result, result.success ? 200 : 500);
304
+ if (streamOutput) {
305
+ result.output?.split(/\r?\n/).forEach((line) => sendStreamEvent(res, { type: 'log', line }));
306
+ sendStreamEvent(res, { type: 'result', ...result });
307
+ res.end();
308
+ } else {
309
+ sendJson(res, result, result.success ? 200 : 500);
310
+ }
206
311
  return true;
207
312
  }
208
- const result = await replayAppiumScript(script, deviceId);
209
- sendJson(res, result, result.success ? 200 : 500);
313
+ replayingDevices.add(deviceId);
314
+ try {
315
+ await treeDumpTasks.get(deviceId)?.catch(() => undefined);
316
+ await new Promise((resolve) => setTimeout(resolve, 300));
317
+ const result = await replayAppiumScript(
318
+ script,
319
+ deviceId,
320
+ streamOutput ? (line) => sendStreamEvent(res, { type: 'log', line }) : undefined,
321
+ );
322
+ if (streamOutput) {
323
+ sendStreamEvent(res, { type: 'result', ...result });
324
+ res.end();
325
+ } else {
326
+ sendJson(res, result, result.success ? 200 : 500);
327
+ }
328
+ } finally {
329
+ replayingDevices.delete(deviceId);
330
+ }
210
331
  return true;
211
332
  }
212
333
 
213
334
  sendJson(res, { message: 'Appium Recorder 接口不存在' }, 404);
214
335
  return true;
215
336
  } catch (error) {
216
- sendJson(res, { message: error instanceof Error ? error.message : 'Appium Recorder 请求失败' }, 500);
337
+ const message = error instanceof Error ? error.message : 'Appium Recorder 请求失败';
338
+ if (res.headersSent) {
339
+ sendStreamEvent(res, { type: 'error', message });
340
+ res.end();
341
+ } else {
342
+ sendJson(res, { message }, 500);
343
+ }
217
344
  return true;
218
345
  }
219
346
  }
package/server/config.ts CHANGED
@@ -5,6 +5,10 @@ import { appDataPath } from './paths';
5
5
  import { loadModelConfigFromDb, saveModelConfigToDb } from './config-store';
6
6
 
7
7
  export type AppConfig = {
8
+ runtime: {
9
+ androidSdkPath: string;
10
+ reportOutputPath: string;
11
+ };
8
12
  midscene: {
9
13
  model: {
10
14
  provider: 'custom' | 'codex';
@@ -30,6 +34,10 @@ const legacyConfigPath = appDataPath('config.yaml');
30
34
 
31
35
  function defaultConfig(): AppConfig {
32
36
  return {
37
+ runtime: {
38
+ androidSdkPath: '',
39
+ reportOutputPath: '',
40
+ },
33
41
  midscene: {
34
42
  model: {
35
43
  provider: 'custom',
@@ -61,6 +69,10 @@ function normalizeEnv(env: unknown): Record<string, string> {
61
69
  function normalizeConfig(config: Partial<AppConfig> | null | undefined): AppConfig {
62
70
  const fallback = defaultConfig();
63
71
  return {
72
+ runtime: {
73
+ androidSdkPath: config?.runtime?.androidSdkPath?.trim() || fallback.runtime.androidSdkPath,
74
+ reportOutputPath: config?.runtime?.reportOutputPath?.trim() || fallback.runtime.reportOutputPath,
75
+ },
64
76
  midscene: {
65
77
  model: {
66
78
  provider: config?.midscene?.model?.provider || (
@@ -39,6 +39,7 @@ import { importTestCaseFile, MAX_TEST_CASE_FILE_SIZE } from './test-case-import/
39
39
  import { handleAppiumRecorderRequest } from './appium-recorder/routes';
40
40
  import { handleRemoteAgentRequest } from './remote-agents/routes';
41
41
  import { isRemoteDeviceId, listRemoteAndroidDevices, sendRemoteCommand } from './remote-agents/registry';
42
+ import { getAdbCommand } from './android-sdk';
42
43
 
43
44
  async function readBody<T>(req: IncomingMessage) {
44
45
  let body = '';
@@ -110,7 +111,7 @@ type AndroidDisplayInfo = {
110
111
  };
111
112
 
112
113
  async function listAdbDevices() {
113
- const output = await execFileText('adb', ['devices', '-l']);
114
+ const output = await execFileText(getAdbCommand(), ['devices', '-l']);
114
115
  const devices = output
115
116
  .split('\n')
116
117
  .map((line) => line.trim())
@@ -133,7 +134,7 @@ async function listAdbDevices() {
133
134
  }
134
135
 
135
136
  async function getAdbDisplayInfo(deviceId: string) {
136
- const output = await execFileText('adb', ['-s', deviceId, 'shell', 'wm', 'size']);
137
+ const output = await execFileText(getAdbCommand(), ['-s', deviceId, 'shell', 'wm', 'size']);
137
138
  const match = output.match(/Override size:\s*(\d+)x(\d+)/i) || output.match(/Physical size:\s*(\d+)x(\d+)/i);
138
139
 
139
140
  if (!match) {
@@ -270,6 +271,8 @@ const PLAYGROUND_PROXY_PREFIXES = [
270
271
  '/runtime-info',
271
272
  '/interface-info',
272
273
  '/screenshot',
274
+ '/action-space',
275
+ '/execute',
273
276
  '/interact',
274
277
  '/config',
275
278
  '/connectivity-test',
@@ -714,7 +717,7 @@ export function createApiMiddleware() {
714
717
  return;
715
718
  }
716
719
 
717
- execFile('adb', ['-s', deviceId, 'exec-out', 'screencap', '-p'], { encoding: 'buffer', maxBuffer: 20 * 1024 * 1024 }, (error, stdout, stderr) => {
720
+ execFile(getAdbCommand(), ['-s', deviceId, 'exec-out', 'screencap', '-p'], { encoding: 'buffer', maxBuffer: 20 * 1024 * 1024 }, (error, stdout, stderr) => {
718
721
  if (error) {
719
722
  res.statusCode = 500;
720
723
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
@@ -752,7 +755,7 @@ export function createApiMiddleware() {
752
755
  res.end(JSON.stringify({ success: true, result }));
753
756
  return;
754
757
  }
755
- const result = await execFileJson('adb', ['-s', deviceId, 'shell', 'input', 'tap', String(x), String(y)]);
758
+ const result = await execFileJson(getAdbCommand(), ['-s', deviceId, 'shell', 'input', 'tap', String(x), String(y)]);
756
759
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
757
760
  res.end(JSON.stringify({ success: true, ...result }));
758
761
  } catch (error) {
@@ -781,7 +784,7 @@ export function createApiMiddleware() {
781
784
  res.end(JSON.stringify({ success: true, result }));
782
785
  return;
783
786
  }
784
- const result = await execFileJson('adb', ['-s', deviceId, 'shell', 'input', 'keyevent', String(keyCode)]);
787
+ const result = await execFileJson(getAdbCommand(), ['-s', deviceId, 'shell', 'input', 'keyevent', String(keyCode)]);
785
788
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
786
789
  res.end(JSON.stringify({ success: true, ...result }));
787
790
  } catch (error) {
@@ -846,7 +849,7 @@ export function createApiMiddleware() {
846
849
  return;
847
850
  }
848
851
  const result = await execFileJson(
849
- 'adb',
852
+ getAdbCommand(),
850
853
  ['-s', deviceId, 'shell', 'input', 'swipe', String(startX), String(startY), String(endX), String(endY), String(duration)],
851
854
  );
852
855
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
package/src/App.vue CHANGED
@@ -47,7 +47,10 @@ import type {
47
47
  SavedScript,
48
48
  } from './types';
49
49
 
50
- const activeMenu = ref<MenuKey>('generator');
50
+ const ACTIVE_MENU_STORAGE_KEY = 'android-midscene-automation:active-menu';
51
+ const menuKeys: MenuKey[] = ['generator', 'automation', 'config', 'appium'];
52
+ const storedMenu = window.localStorage.getItem(ACTIVE_MENU_STORAGE_KEY) as MenuKey | null;
53
+ const activeMenu = ref<MenuKey>(storedMenu && menuKeys.includes(storedMenu) ? storedMenu : 'generator');
51
54
  const activeGeneratorMode = ref<GeneratorMode>('ai');
52
55
  const sourcePrompt = ref('');
53
56
  const steps = ref<ScriptStep[]>([]);
@@ -141,6 +144,10 @@ const appPresetForm = reactive({
141
144
  });
142
145
 
143
146
  const configForm = reactive<ConfigForm>({
147
+ runtime: {
148
+ androidSdkPath: '',
149
+ reportOutputPath: '',
150
+ },
144
151
  midscene: {
145
152
  model: {
146
153
  provider: 'custom',
@@ -587,6 +594,7 @@ const generateWithModel = async () => {
587
594
 
588
595
  const loadConfig = async () => {
589
596
  const payload = await api.getConfig();
597
+ Object.assign(configForm.runtime, payload.runtime || {});
590
598
  Object.assign(configForm.midscene.model, payload.midscene.model);
591
599
  Object.keys(configForm.midscene.env).forEach((key) => {
592
600
  delete configForm.midscene.env[key];
@@ -647,13 +655,13 @@ const getMidsceneModelConfigError = () => {
647
655
  const saveModelConfig = async () => {
648
656
  isSavingModelConfig.value = true;
649
657
  errorMessage.value = '';
650
- openActionDialog('保存模型配置');
658
+ openActionDialog('保存参数配置');
651
659
  try {
652
660
  await api.saveConfig(configForm);
653
661
  closeActionDialog();
654
- ElMessage.success('模型配置已保存');
662
+ ElMessage.success('参数配置已保存');
655
663
  } catch (error) {
656
- const message = error instanceof Error ? error.message : '模型配置保存失败';
664
+ const message = error instanceof Error ? error.message : '参数配置保存失败';
657
665
  errorMessage.value = message;
658
666
  failActionDialog(message);
659
667
  } finally {
@@ -797,10 +805,6 @@ const refreshAdbPreviewAfterInput = () => {
797
805
  };
798
806
 
799
807
  const refreshDevicePreview = () => {
800
- if (activeMenu.value === 'appium') {
801
- loadAdbPreview();
802
- return;
803
- }
804
808
  if (devicePreviewMode.value === 'stream' && playgroundFrameUrl.value) {
805
809
  playgroundFrameUrl.value = `${api.APP_BASE}/__android_playground__/?ts=${Date.now()}`;
806
810
  return;
@@ -838,6 +842,7 @@ const markBackendOffline = () => {
838
842
  const loadPlaygroundStatus = async () => {
839
843
  if (backendOffline.value) return;
840
844
  if (!playgroundDeviceId.value) {
845
+ stopAppiumPreviewTimer();
841
846
  playgroundAvailable.value = false;
842
847
  playgroundPreviewError.value = '';
843
848
  playgroundFrameUrl.value = '';
@@ -853,7 +858,7 @@ const loadPlaygroundStatus = async () => {
853
858
 
854
859
  const matchedDevice = !payload.deviceId || payload.deviceId === playgroundDeviceId.value;
855
860
  const hasRealtimeStream = payload.previewKind === 'scrcpy' && payload.sessionConnected === true;
856
- const hasPlayground = Boolean(payload.available) && matchedDevice && (hasRealtimeStream || !payload.previewError);
861
+ const hasPlayground = Boolean(payload.available) && matchedDevice && hasRealtimeStream;
857
862
  playgroundAvailable.value = androidDevices.value.some((item) => item.status === 'device');
858
863
  playgroundPreviewError.value =
859
864
  matchedDevice && !hasRealtimeStream
@@ -862,18 +867,13 @@ const loadPlaygroundStatus = async () => {
862
867
 
863
868
  const nextSignature = `${payload.url || ''}::${playgroundDeviceId.value}`;
864
869
  if (hasPlayground) {
865
- if (nextSignature !== playgroundFrameSignature.value) {
870
+ stopAppiumPreviewTimer();
871
+ if (nextSignature !== playgroundFrameSignature.value || !playgroundFrameUrl.value) {
866
872
  playgroundFrameSignature.value = nextSignature;
867
873
  playgroundFrameUrl.value = `${api.APP_BASE}/__android_playground__/?ts=${Date.now()}`;
868
874
  }
869
- if (activeMenu.value === 'appium') {
870
- if (devicePreviewMode.value !== 'screenshot' || !devicePreviewUrl.value) {
871
- loadAdbPreview();
872
- }
873
- } else {
874
- devicePreviewUrl.value = '';
875
- devicePreviewMode.value = 'stream';
876
- }
875
+ devicePreviewUrl.value = '';
876
+ devicePreviewMode.value = 'stream';
877
877
  playgroundPreviewError.value = '';
878
878
  }
879
879
 
@@ -881,6 +881,7 @@ const loadPlaygroundStatus = async () => {
881
881
  playgroundFrameUrl.value = '';
882
882
  playgroundFrameSignature.value = '';
883
883
  loadAdbPreview();
884
+ if (activeMenu.value === 'appium') startAppiumPreviewTimer();
884
885
  }
885
886
  } catch (error) {
886
887
  if (isBackendFetchError(error)) {
@@ -1230,15 +1231,14 @@ watch(
1230
1231
  );
1231
1232
 
1232
1233
  watch(activeMenu, (menu) => {
1233
- if (menu === 'appium') {
1234
- startAppiumPreviewTimer();
1235
- } else if (menu === 'automation') {
1234
+ window.localStorage.setItem(ACTIVE_MENU_STORAGE_KEY, menu);
1235
+ if (menu === 'appium' || menu === 'automation') {
1236
1236
  stopAppiumPreviewTimer();
1237
1237
  void loadPlaygroundStatus();
1238
1238
  } else {
1239
1239
  stopAppiumPreviewTimer();
1240
1240
  }
1241
- });
1241
+ }, { immediate: true });
1242
1242
 
1243
1243
  onMounted(async () => {
1244
1244
  try {
@@ -1478,7 +1478,7 @@ onUnmounted(() => {
1478
1478
  :device-actions="deviceActions"
1479
1479
  :playground-available="playgroundAvailable"
1480
1480
  :playground-device-id="playgroundDeviceId"
1481
- :playground-frame-url="''"
1481
+ :playground-frame-url="activeMenu === 'appium' ? playgroundFrameUrl : ''"
1482
1482
  :playground-preview-error="playgroundPreviewError"
1483
1483
  :device-preview-url="activeMenu === 'appium' ? devicePreviewUrl : ''"
1484
1484
  :android-devices="androidDevices"