Skip to content

Commit 54a3e48

Browse files
wangczclaude
andcommitted
Address reviewer feedback on HarmonyOS platform support (#679)
- Fix duplicate import in snapshot.ts - Wire HarmonyOS recording into daemon recording path (start/stop) - Reject HarmonyOS permission deny with explicit UNSUPPORTED_OPERATION - Fix ui-input typo to uiInput in app-lifecycle.ts - Add harmonyos to Node client normalizers (normalizeOpenDevice, buildClientDevicePlatformFields) - Update remote config schema and CLI tests to include harmonyos platform Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 54a4f1f commit 54a3e48

10 files changed

Lines changed: 149 additions & 24 deletions

File tree

src/client-normalizers.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ function buildClientDevicePlatformFields(
142142
platform: AgentDeviceDevice['platform'],
143143
id: string,
144144
simulatorSetPath?: string | null,
145-
): Pick<AgentDeviceSessionDevice, 'ios' | 'android'> {
145+
): Pick<AgentDeviceSessionDevice, 'ios' | 'android' | 'harmonyos'> {
146146
return {
147147
ios:
148148
platform === 'ios'
@@ -152,6 +152,7 @@ function buildClientDevicePlatformFields(
152152
}
153153
: undefined,
154154
android: platform === 'android' ? { serial: id } : undefined,
155+
harmonyos: platform === 'harmonyos' ? { serial: id } : undefined,
155156
};
156157
}
157158

@@ -181,6 +182,7 @@ export function normalizeOpenDevice(
181182
(platform !== 'ios' &&
182183
platform !== 'macos' &&
183184
platform !== 'android' &&
185+
platform !== 'harmonyos' &&
184186
platform !== 'linux') ||
185187
!id ||
186188
!name
@@ -204,6 +206,10 @@ export function normalizeOpenDevice(
204206
: undefined,
205207
android:
206208
platform === 'android' ? { serial: readOptionalString(value, 'serial') ?? id } : undefined,
209+
harmonyos:
210+
platform === 'harmonyos'
211+
? { serial: readOptionalString(value, 'serial') ?? id }
212+
: undefined,
207213
};
208214
}
209215

src/client-types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ export type AgentDeviceDevice = {
114114
android?: {
115115
serial: string;
116116
};
117+
harmonyos?: {
118+
serial: string;
119+
};
117120
};
118121

119122
export type AgentDeviceSessionDevice = {
@@ -129,6 +132,9 @@ export type AgentDeviceSessionDevice = {
129132
android?: {
130133
serial: string;
131134
};
135+
harmonyos?: {
136+
serial: string;
137+
};
132138
};
133139

134140
export type AgentDeviceSession = {

src/daemon/handlers/record-trace-recording.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ import {
4141
stopIosSimulatorRecordingProcess,
4242
} from './record-trace-ios-simulator.ts';
4343
import { resolveImplicitSessionScope, resolvePublicSessionName } from '../session-routing.ts';
44+
import {
45+
startHarmonyRecording,
46+
stopHarmonyRecording,
47+
} from '../../platforms/harmonyos/recording.ts';
4448

4549
const IOS_DEVICE_RECORD_MIN_FPS = 1;
4650
const IOS_DEVICE_RECORD_MAX_FPS = 120;
@@ -175,6 +179,35 @@ async function startIosSimulatorRecording(params: {
175179
};
176180
}
177181

182+
// --- HarmonyOS start helper ---
183+
184+
async function startHarmonyOsRecording(params: {
185+
device: SessionState['device'];
186+
recordingBase: RecordingBase;
187+
}): Promise<DaemonResponse | NonNullable<SessionState['recording']>> {
188+
const { device, recordingBase } = params;
189+
const remotePath = `/data/local/tmp/agent-device-recording-${Date.now()}.mp4`;
190+
191+
let startResult: { remotePid: string };
192+
try {
193+
startResult = await startHarmonyRecording(device, remotePath);
194+
} catch (error) {
195+
return errorResponse(
196+
'COMMAND_FAILED',
197+
error instanceof Error ? error.message : String(error),
198+
);
199+
}
200+
201+
const recording: Extract<NonNullable<SessionState['recording']>, { platform: 'harmonyos' }> = {
202+
platform: 'harmonyos',
203+
remotePath,
204+
remotePid: startResult.remotePid,
205+
...recordingBase,
206+
startedAt: Date.now(),
207+
};
208+
return recording;
209+
}
210+
178211
// --- Start recording orchestrator ---
179212

180213
// fallow-ignore-next-line complexity
@@ -276,6 +309,8 @@ async function startRecording(params: {
276309
recordingBase,
277310
resolvedOut,
278311
});
312+
} else if (device.platform === 'harmonyos') {
313+
recording = await startHarmonyOsRecording({ device, recordingBase });
279314
} else {
280315
recording = await startAndroidRecording({ device, recordingBase });
281316
}
@@ -427,6 +462,28 @@ function removeInvalidRecordingOutput(outPath: string): void {
427462
}
428463
}
429464

465+
async function stopHarmonyOsRecording(params: {
466+
device: SessionState['device'];
467+
recording: Extract<NonNullable<SessionState['recording']>, { platform: 'harmonyos' }>;
468+
stopRequestedAt: number;
469+
}): Promise<DaemonResponse | null> {
470+
const { device, recording, stopRequestedAt } = params;
471+
try {
472+
await stopHarmonyRecording({
473+
device,
474+
remotePid: recording.remotePid,
475+
remotePath: recording.remotePath,
476+
localPath: recording.outPath,
477+
});
478+
} catch (error) {
479+
const message = error instanceof Error ? error.message : String(error);
480+
const failure = buildRecordStopFailure(message, recording, stopRequestedAt);
481+
removeInvalidRecordingOutput(recording.outPath);
482+
return errorResponse('COMMAND_FAILED', failure.message);
483+
}
484+
return null;
485+
}
486+
430487
async function stopRecording(params: {
431488
req: DaemonRequest;
432489
activeSession: SessionState;
@@ -450,7 +507,9 @@ async function stopRecording(params: {
450507
? await stopIosDeviceRecording({ req, activeSession, device, logPath, deps, recording })
451508
: recording.platform === 'macos-runner'
452509
? await stopMacOsRecording({ req, activeSession, device, logPath, deps, recording })
453-
: await stopNonRunnerRecording({ deps, device, recording, stopRequestedAt });
510+
: recording.platform === 'harmonyos'
511+
? await stopHarmonyOsRecording({ device, recording, stopRequestedAt })
512+
: await stopNonRunnerRecording({ deps, device, recording, stopRequestedAt });
454513
if (stopError) {
455514
return stopError;
456515
}

src/daemon/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,11 @@ export type SessionState = {
250250
| (SessionRecordingBase & {
251251
platform: 'macos-runner';
252252
remotePath?: string;
253+
})
254+
| (SessionRecordingBase & {
255+
platform: 'harmonyos';
256+
remotePath: string;
257+
remotePid: string;
253258
});
254259
/** Session-scoped app log stream; logs written to outPath for agent to grep */
255260
appLog?: {

src/platforms/harmonyos/app-lifecycle.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ async function checkAndHandleScreenLock(device: DeviceInfo): Promise<void> {
182182
// Try to unlock (swipe up)
183183
await runHarmonyHdc(
184184
device,
185-
['shell', 'uitest', 'ui-input', 'swipe', '540', '2000', '540', '800', '300'],
185+
['shell', 'uitest', 'uiInput', 'swipe', '540', '2000', '540', '800', '300'],
186186
{ allowFailure: true },
187187
);
188188
// Wait for screen to wake

src/platforms/harmonyos/recording.ts

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,46 @@ import type { DeviceInfo } from '../../utils/device.ts';
22
import { AppError } from '../../utils/errors.ts';
33
import { runHarmonyHdc } from './hdc.ts';
44

5-
export async function startHarmonyRecording(device: DeviceInfo, remotePath: string): Promise<void> {
6-
// HarmonyOS screen recording via hdc shell screenrecord
7-
// This command may not be available on all devices
8-
await runHarmonyHdc(device, ['shell', 'screenrecord', '--output', remotePath], {
9-
allowFailure: true,
10-
timeoutMs: 5_000,
11-
});
12-
}
5+
const HARMONY_PROCESS_EXIT_POLL_MS = 250;
6+
const HARMONY_PROCESS_EXIT_ATTEMPTS = 40;
137

14-
export async function stopHarmonyRecording(
8+
export async function startHarmonyRecording(
159
device: DeviceInfo,
1610
remotePath: string,
17-
localPath: string,
18-
): Promise<void> {
19-
// Stop recording (kill the screenrecord process)
20-
await runHarmonyHdc(device, ['shell', 'aa', 'force-stop', 'com.ohos.screenrecorder'], {
21-
allowFailure: true,
22-
});
11+
): Promise<{ remotePid: string }> {
12+
const shellCmd = `screenrecord --output ${remotePath} >/dev/null 2>&1 & echo $!`;
13+
const result = await runHarmonyHdc(device, ['shell', shellCmd], { allowFailure: true });
14+
15+
if (result.exitCode !== 0) {
16+
throw new AppError('COMMAND_FAILED', `Failed to start HarmonyOS recording: ${result.stderr}`);
17+
}
18+
19+
const remotePid = result.stdout
20+
.split(/\r?\n/)
21+
.map((line) => line.trim())
22+
.filter((line) => /^\d+$/.test(line))
23+
.at(-1);
24+
25+
if (!remotePid) {
26+
throw new AppError('COMMAND_FAILED', 'Failed to get HarmonyOS screenrecord PID');
27+
}
28+
29+
return { remotePid };
30+
}
31+
32+
export async function stopHarmonyRecording(params: {
33+
device: DeviceInfo;
34+
remotePid: string;
35+
remotePath: string;
36+
localPath: string;
37+
}): Promise<void> {
38+
const { device, remotePid, remotePath, localPath } = params;
39+
40+
// Send SIGINT to gracefully stop screenrecord
41+
await runHarmonyHdc(device, ['shell', 'kill', '-2', remotePid], { allowFailure: true });
42+
43+
// Wait for process to exit
44+
await waitForHarmonyProcessExit(device, remotePid);
2345

2446
// Pull the recording file
2547
try {
@@ -28,9 +50,31 @@ export async function stopHarmonyRecording(
2850
timeoutMs: 30_000,
2951
});
3052
} catch {
31-
throw new AppError('COMMAND_FAILED', 'Failed to retrieve recording file');
53+
throw new AppError('COMMAND_FAILED', 'Failed to retrieve HarmonyOS recording file');
3254
}
3355

3456
// Cleanup remote
3557
await runHarmonyHdc(device, ['shell', 'rm', '-f', remotePath], { allowFailure: true });
3658
}
59+
60+
async function isHarmonyProcessRunning(device: DeviceInfo, pid: string): Promise<boolean> {
61+
const result = await runHarmonyHdc(device, ['shell', 'ps', '-o', 'pid=', '-p', pid], {
62+
allowFailure: true,
63+
});
64+
if (result.exitCode !== 0) {
65+
return false;
66+
}
67+
return result.stdout
68+
.split(/\s+/)
69+
.map((value) => value.trim())
70+
.includes(pid);
71+
}
72+
73+
async function waitForHarmonyProcessExit(device: DeviceInfo, pid: string): Promise<void> {
74+
for (let attempt = 0; attempt < HARMONY_PROCESS_EXIT_ATTEMPTS; attempt += 1) {
75+
if (!(await isHarmonyProcessRunning(device, pid))) {
76+
return;
77+
}
78+
await new Promise((resolve) => setTimeout(resolve, HARMONY_PROCESS_EXIT_POLL_MS));
79+
}
80+
}

src/platforms/harmonyos/settings.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,14 +178,20 @@ async function setHarmonyPermission(
178178
): Promise<void> {
179179
// HarmonyOS permission management uses different commands than Android
180180
// This is a simplified implementation - actual HarmonyOS may need specific APIs
181+
if (action === 'deny') {
182+
throw new AppError(
183+
'UNSUPPORTED_OPERATION',
184+
'HarmonyOS permission deny is not yet implemented',
185+
);
186+
}
181187
if (action === 'reset') {
182188
throw new AppError(
183189
'UNSUPPORTED_OPERATION',
184190
'HarmonyOS permission reset is not yet implemented',
185191
);
186192
}
187193

188-
// Try using bm tool for permission management (if available)
194+
// Only grant is supported for now
189195
const result = await runHarmonyHdc(
190196
device,
191197
['shell', 'bm', 'grant-permission', '-n', appPackage, '-p', permission],

src/platforms/harmonyos/snapshot.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ import path from 'node:path';
44
import os from 'node:os';
55
import type { DeviceInfo } from '../../utils/device.ts';
66
import { AppError } from '../../utils/errors.ts';
7-
import type { SnapshotOptions } from '../../utils/snapshot.ts';
8-
import { attachRefs, type SnapshotNode } from '../../utils/snapshot.ts';
7+
import { attachRefs, type SnapshotNode, type SnapshotOptions } from '../../utils/snapshot.ts';
98
import { runHarmonyHdc } from './hdc.ts';
109
import {
1110
parseArkUiTree,

src/remote-config-schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export const REMOTE_CONFIG_FIELD_SPECS = [
7373
type: 'enum',
7474
enumValues: ['ios-simulator', 'ios-instance', 'android-instance'],
7575
},
76-
{ key: 'platform', type: 'enum', enumValues: ['ios', 'macos', 'android', 'linux', 'apple'] },
76+
{ key: 'platform', type: 'enum', enumValues: ['ios', 'macos', 'android', 'harmonyos', 'linux', 'apple'] },
7777
{ key: 'target', type: 'enum', enumValues: ['mobile', 'tv', 'desktop'] },
7878
{ key: 'device', type: 'string' },
7979
{ key: 'udid', type: 'string' },

src/utils/__tests__/args.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1518,7 +1518,7 @@ test('command usage shows command and global flags separately', () => {
15181518
assert.match(help, /Command flags:/);
15191519
assert.match(help, /--pattern one-way\|ping-pong/);
15201520
assert.match(help, /Global flags:/);
1521-
assert.match(help, /--platform ios\|macos\|android\|linux\|apple/);
1521+
assert.match(help, /--platform ios\|macos\|android\|harmonyos\|linux\|apple/);
15221522
});
15231523

15241524
test('back command usage documents explicit mode flags', () => {

0 commit comments

Comments
 (0)