From 7fd0bffa0aeed0adac11b826d47e7888b6712dec Mon Sep 17 00:00:00 2001 From: wangcz Date: Wed, 3 Jun 2026 14:40:20 +0800 Subject: [PATCH 1/6] fix: correct keyboard action enum to include enter and return --- src/commands/client-command-metadata.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/client-command-metadata.ts b/src/commands/client-command-metadata.ts index c22c270f3..433969136 100644 --- a/src/commands/client-command-metadata.ts +++ b/src/commands/client-command-metadata.ts @@ -149,7 +149,7 @@ export const clientCommandMetadata = [ }), defineClientCommandMetadata('app-switcher', {}), defineClientCommandMetadata('keyboard', { - action: enumField(['status', 'dismiss']), + action: enumField(['status', 'dismiss', 'enter', 'return']), }), defineClientCommandMetadata('clipboard', { action: requiredField(enumField(CLIPBOARD_ACTION_VALUES)), From 54a4f1f92998e88b124cbd3f330d0a858e92db1a Mon Sep 17 00:00:00 2001 From: wangcz Date: Wed, 3 Jun 2026 14:41:23 +0800 Subject: [PATCH 2/6] feat: add HarmonyOS platform support --- .gitignore | 1 + src/backend.ts | 2 +- src/commands/command-input.ts | 2 +- src/contracts.ts | 4 +- src/core/capabilities.ts | 62 ++- src/core/dispatch-resolve.ts | 4 +- src/core/dispatch.ts | 53 +- src/core/interactor-types.ts | 2 +- src/core/interactors.ts | 4 + src/core/interactors/harmonyos.ts | 90 ++++ src/core/platform-inventory.ts | 9 + src/daemon/app-log.ts | 1 + src/daemon/handlers/install-source.ts | 21 +- src/daemon/handlers/session-deploy.ts | 46 +- src/daemon/handlers/session-inventory.ts | 15 + src/daemon/network-log.ts | 2 +- src/daemon/request-lock-policy.ts | 3 + .../harmonyos/__tests__/app-parsers.test.ts | 93 ++++ .../harmonyos/__tests__/app-storage.test.ts | 32 ++ .../__tests__/arkui-hierarchy.test.ts | 75 +++ .../harmonyos/__tests__/devices.test.ts | 36 ++ .../__tests__/launch-abilities.test.ts | 48 ++ .../__tests__/uitest-preflight.test.ts | 20 + src/platforms/harmonyos/alert.ts | 458 ++++++++++++++++++ src/platforms/harmonyos/app-lifecycle.ts | 369 ++++++++++++++ src/platforms/harmonyos/app-parsers.ts | 119 +++++ src/platforms/harmonyos/arkui-hierarchy.ts | 290 +++++++++++ src/platforms/harmonyos/clipboard.ts | 48 ++ src/platforms/harmonyos/devices.ts | 80 +++ src/platforms/harmonyos/hdc-executor.ts | 124 +++++ src/platforms/harmonyos/hdc.ts | 27 ++ src/platforms/harmonyos/input-actions.ts | 362 ++++++++++++++ src/platforms/harmonyos/launch-abilities.ts | 51 ++ src/platforms/harmonyos/logcat.ts | 43 ++ src/platforms/harmonyos/notifications.ts | 69 +++ src/platforms/harmonyos/perf.ts | 51 ++ src/platforms/harmonyos/recording.ts | 36 ++ src/platforms/harmonyos/screenshot.ts | 38 ++ src/platforms/harmonyos/settings.ts | 231 +++++++++ src/platforms/harmonyos/snapshot.ts | 75 +++ src/platforms/harmonyos/uitest-preflight.ts | 55 +++ src/utils/cli-flags.ts | 8 +- src/utils/cli-help.ts | 58 ++- src/utils/device.ts | 2 +- src/utils/parsing.ts | 2 +- src/utils/snapshot.ts | 2 +- 46 files changed, 3185 insertions(+), 38 deletions(-) create mode 100644 src/core/interactors/harmonyos.ts create mode 100644 src/platforms/harmonyos/__tests__/app-parsers.test.ts create mode 100644 src/platforms/harmonyos/__tests__/app-storage.test.ts create mode 100644 src/platforms/harmonyos/__tests__/arkui-hierarchy.test.ts create mode 100644 src/platforms/harmonyos/__tests__/devices.test.ts create mode 100644 src/platforms/harmonyos/__tests__/launch-abilities.test.ts create mode 100644 src/platforms/harmonyos/__tests__/uitest-preflight.test.ts create mode 100644 src/platforms/harmonyos/alert.ts create mode 100644 src/platforms/harmonyos/app-lifecycle.ts create mode 100644 src/platforms/harmonyos/app-parsers.ts create mode 100644 src/platforms/harmonyos/arkui-hierarchy.ts create mode 100644 src/platforms/harmonyos/clipboard.ts create mode 100644 src/platforms/harmonyos/devices.ts create mode 100644 src/platforms/harmonyos/hdc-executor.ts create mode 100644 src/platforms/harmonyos/hdc.ts create mode 100644 src/platforms/harmonyos/input-actions.ts create mode 100644 src/platforms/harmonyos/launch-abilities.ts create mode 100644 src/platforms/harmonyos/logcat.ts create mode 100644 src/platforms/harmonyos/notifications.ts create mode 100644 src/platforms/harmonyos/perf.ts create mode 100644 src/platforms/harmonyos/recording.ts create mode 100644 src/platforms/harmonyos/screenshot.ts create mode 100644 src/platforms/harmonyos/settings.ts create mode 100644 src/platforms/harmonyos/snapshot.ts create mode 100644 src/platforms/harmonyos/uitest-preflight.ts diff --git a/.gitignore b/.gitignore index 0f683df1c..c3ca3363f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ android-snapshot-helper/build/ android-snapshot-helper/dist/ android-multitouch-helper/build/ android-multitouch-helper/dist/ +harmonyos-lab/ diff --git a/src/backend.ts b/src/backend.ts index 0518ee6c7..32a7bd0f5 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -9,7 +9,7 @@ import type { SnapshotState, } from './utils/snapshot.ts'; -export type AgentDeviceBackendPlatform = 'ios' | 'android' | 'macos' | 'linux'; +export type AgentDeviceBackendPlatform = 'ios' | 'android' | 'macos' | 'harmonyos' | 'linux'; export const BACKEND_CAPABILITY_NAMES = [ 'android.shell', diff --git a/src/commands/command-input.ts b/src/commands/command-input.ts index 0a083ee3b..d0eb29af6 100644 --- a/src/commands/command-input.ts +++ b/src/commands/command-input.ts @@ -7,7 +7,7 @@ import type { import type { DeviceTarget, PlatformSelector } from '../utils/device.ts'; import type { JsonSchema } from './command-contract.ts'; -const PLATFORM_VALUES = ['ios', 'android', 'macos', 'linux', 'apple'] as const; +const PLATFORM_VALUES = ['ios', 'android', 'macos', 'harmonyos', 'linux', 'apple'] as const; const DEVICE_TARGET_VALUES = ['mobile', 'tv', 'desktop'] as const; const INTERACTION_TARGET_KINDS = ['ref', 'selector', 'point'] as const; diff --git a/src/contracts.ts b/src/contracts.ts index cae5c4efe..fde2fb8b3 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -57,7 +57,7 @@ export type DaemonRequestMeta = { materializedPathRetentionMs?: number; materializationId?: string; lockPolicy?: DaemonLockPolicy; - lockPlatform?: 'ios' | 'macos' | 'android' | 'linux' | 'apple'; + lockPlatform?: 'ios' | 'macos' | 'android' | 'harmonyos' | 'linux' | 'apple'; requestProgress?: 'replay-test'; }; @@ -395,7 +395,7 @@ export const daemonCommandRequestSchema = schema((input, path) => lockPlatform: optionalEnum( meta, 'lockPlatform', - ['ios', 'macos', 'android', 'linux', 'apple'] as const, + ['ios', 'macos', 'android', 'harmonyos', 'linux', 'apple'] as const, `${path}.meta`, ), }, diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 01e653431..59d4748ba 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -10,6 +10,7 @@ type KindMatrix = { export type CommandCapability = { apple?: KindMatrix; android?: KindMatrix; + harmonyos?: KindMatrix; linux?: KindMatrix; supports?: (device: DeviceInfo) => boolean; /** Optional actionable hint surfaced when this command is rejected at admission for `device`. */ @@ -39,42 +40,40 @@ const synthesisGestureUnsupportedHint = (device: DeviceInfo): string | undefined // Linux device kind is always 'device' (local desktop). const LINUX_DEVICE: KindMatrix = { device: true }; const LINUX_NONE: KindMatrix = {}; +const HARMONYOS_DEVICE: KindMatrix = { device: true }; const ALL_DEVICE_COMMAND_CAPABILITY = { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_DEVICE, } as const satisfies CommandCapability; const APP_RUNTIME_CAPABILITY = ALL_DEVICE_COMMAND_CAPABILITY; const APP_INVENTORY_CAPABILITY = { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_NONE, } as const satisfies CommandCapability; const APP_INSTALL_CAPABILITY = { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_NONE, supports: isNotMacOs, } as const satisfies CommandCapability; const COMMAND_CAPABILITY_MATRIX: Record = { - // Apple simulator-only. alert: { - // macOS desktop targets report kind=device, so this stays enabled here and the - // supports() guard excludes iOS physical devices. apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_NONE, - supports: (device) => device.platform === 'android' || isMacOsOrAppleSimulator(device), + supports: (device) => device.platform === 'android' || device.platform === 'harmonyos' || isMacOsOrAppleSimulator(device), }, pinch: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, linux: LINUX_NONE, - // iOS-simulator-only (plus Android): pinch is driven by the two-finger XCTest synthesis - // path (RunnerSynthesizedGesture), which is iOS-only. macOS has no multi-touch synthesis, so - // it is excluded and fails fast at admission rather than round-tripping to an unsupported - // runner. Matches rotate-gesture / transform-gesture. supports: (device) => device.platform === 'android' || isIosMobileSimulator(device), unsupportedHint: synthesisGestureUnsupportedHint, }, @@ -95,8 +94,9 @@ const COMMAND_CAPABILITY_MATRIX: Record = { 'app-switcher': { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_NONE, - supports: isNotMacOs, + supports: (device) => isNotMacOs(device) || device.platform === 'harmonyos', }, open: APP_RUNTIME_CAPABILITY, close: APP_RUNTIME_CAPABILITY, @@ -107,45 +107,54 @@ const COMMAND_CAPABILITY_MATRIX: Record = { back: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_DEVICE, }, boot: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: {}, linux: LINUX_NONE, - supports: isNotMacOs, + supports: (device) => isNotMacOs(device) && device.platform !== 'harmonyos', }, click: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_DEVICE, }, clipboard: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_DEVICE, supports: (device) => device.platform === 'android' || + device.platform === 'harmonyos' || device.platform === 'linux' || device.platform === 'macos' || device.kind === 'simulator', }, keyboard: { - // iOS only supports keyboard dismiss/enter; status/get remains Android-only. apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_NONE, supports: (device) => - device.platform === 'android' || (device.platform === 'ios' && device.target !== 'tv'), + device.platform === 'android' || + device.platform === 'harmonyos' || + (device.platform === 'ios' && device.target !== 'tv'), }, fill: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_DEVICE, }, fling: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_NONE, }, snapshot: ALL_DEVICE_COMMAND_CAPABILITY, @@ -158,87 +167,104 @@ const COMMAND_CAPABILITY_MATRIX: Record = { focus: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_DEVICE, }, home: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_DEVICE, - supports: isNotMacOs, + supports: (device) => isNotMacOs(device) || device.platform === 'harmonyos', }, logs: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_NONE, }, network: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: {}, linux: LINUX_NONE, + supports: (device) => device.platform !== 'harmonyos', }, longpress: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_DEVICE, }, perf: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_NONE, }, pan: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_DEVICE, }, press: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_DEVICE, }, push: { apple: { simulator: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_NONE, - supports: isNotMacOs, + supports: (device) => isNotMacOs(device) || device.platform === 'harmonyos', }, record: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_NONE, }, 'react-native': { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_NONE, }, rotate: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_NONE, supports: (device) => - device.platform === 'android' || (device.platform === 'ios' && device.target !== 'tv'), + device.platform === 'android' || device.platform === 'harmonyos' || (device.platform === 'ios' && device.target !== 'tv'), }, scroll: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_DEVICE, }, swipe: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_DEVICE, }, settings: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_NONE, supports: (device) => - device.platform === 'android' || device.platform === 'macos' || device.kind === 'simulator', + device.platform === 'android' || device.platform === 'harmonyos' || device.platform === 'macos' || device.kind === 'simulator', }, 'trigger-app-event': { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, + harmonyos: HARMONYOS_DEVICE, linux: LINUX_NONE, }, type: ALL_DEVICE_COMMAND_CAPABILITY, @@ -251,7 +277,9 @@ export function isCommandSupportedOnDevice(command: string, device: DeviceInfo): ? capability.apple : device.platform === 'linux' ? capability.linux - : capability.android; + : device.platform === 'harmonyos' + ? capability.harmonyos + : capability.android; if (!byPlatform) return false; if (capability.supports && !capability.supports(device)) return false; const kind = (device.kind ?? 'unknown') as keyof KindMatrix; diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index 57307d6c2..ff1b3ba2d 100644 --- a/src/core/dispatch-resolve.ts +++ b/src/core/dispatch-resolve.ts @@ -155,7 +155,7 @@ export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise): string | undefined ? result.message : undefined; } + +async function handleHarmonyKeyboardCommand( + device: DeviceInfo, + action: KeyboardAction, +): Promise> { + const { getHarmonyKeyboardState, dismissHarmonyKeyboard } = await import( + '../platforms/harmonyos/input-actions.ts' + ); + if (action === 'enter' || action === 'return') { + const { pressKeyHarmony } = await import('../platforms/harmonyos/input-actions.ts'); + await pressKeyHarmony(device, 'Enter'); + return { + platform: 'harmonyos', + action: 'enter', + ...successText('Keyboard enter pressed'), + }; + } + if (action === 'dismiss') { + const before = await getHarmonyKeyboardState(device); + await dismissHarmonyKeyboard(device); + const after = await getHarmonyKeyboardState(device); + return { + platform: 'harmonyos', + action: 'dismiss', + wasVisible: before.visible, + dismissed: before.visible && !after.visible, + visible: after.visible, + }; + } + const state = await getHarmonyKeyboardState(device); + return { + platform: 'harmonyos', + action: 'status', + visible: state.visible, + ...(state.height !== undefined ? { height: state.height } : {}), + }; +} diff --git a/src/core/interactor-types.ts b/src/core/interactor-types.ts index 2ddf377a8..0a197657b 100644 --- a/src/core/interactor-types.ts +++ b/src/core/interactor-types.ts @@ -39,7 +39,7 @@ export type SnapshotOptions = BaseSnapshotOptions & { export type SnapshotResult = Omit & { nodes?: RawSnapshotNode[]; - backend: Extract; + backend: Extract; }; export type Interactor = { diff --git a/src/core/interactors.ts b/src/core/interactors.ts index e6b9152cc..239928ddd 100644 --- a/src/core/interactors.ts +++ b/src/core/interactors.ts @@ -15,6 +15,10 @@ export async function getInteractor( const { createLinuxInteractor } = await import('./interactors/linux.ts'); return createLinuxInteractor(); } + case 'harmonyos': { + const { createHarmonyInteractor } = await import('./interactors/harmonyos.ts'); + return createHarmonyInteractor(device); + } case 'ios': case 'macos': { const { createAppleInteractor } = await import('./interactors/apple.ts'); diff --git a/src/core/interactors/harmonyos.ts b/src/core/interactors/harmonyos.ts new file mode 100644 index 000000000..f17501a00 --- /dev/null +++ b/src/core/interactors/harmonyos.ts @@ -0,0 +1,90 @@ +import { closeHarmonyApp, openHarmonyApp } from '../../platforms/harmonyos/app-lifecycle.ts'; +import { + fillHarmony, + focusHarmony, + longPressHarmony, + pressHarmony, + doubleTapHarmony, + pressBackHarmony, + pressHomeHarmony, + pressKeyHarmony, + scrollHarmony, + swipeHarmony, + typeHarmony, + rotateHarmony, +} from '../../platforms/harmonyos/input-actions.ts'; +import { snapshotHarmony } from '../../platforms/harmonyos/snapshot.ts'; +import { screenshotHarmony } from '../../platforms/harmonyos/screenshot.ts'; +import { setHarmonySetting } from '../../platforms/harmonyos/settings.ts'; +import { + readHarmonyClipboardText, + writeHarmonyClipboardText, +} from '../../platforms/harmonyos/clipboard.ts'; +import { withDiagnosticTimer } from '../../utils/diagnostics.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; +import type { Interactor } from '../interactor-types.ts'; + +export function createHarmonyInteractor(device: DeviceInfo): Interactor { + return { + open: (app, options) => openHarmonyApp(device, app, options?.activity), + openDevice: async () => { + // HarmonyOS doesn't have a "home screen" app; just press home + await pressHomeHarmony(device); + }, + close: (app) => closeHarmonyApp(device, app), + tap: (x, y) => pressHarmony(device, x, y), + doubleTap: (x, y) => doubleTapHarmony(device, x, y), + swipe: (x1, y1, x2, y2, durationMs) => + swipeHarmony(device, { x: x1, y: y1 }, { x: x2, y: y2 }, durationMs), + pan: (x1, y1, x2, y2, durationMs) => + swipeHarmony(device, { x: x1, y: y1 }, { x: x2, y: y2 }, durationMs), + fling: (x1, y1, x2, y2, durationMs) => + swipeHarmony(device, { x: x1, y: y1 }, { x: x2, y: y2 }, durationMs), + longPress: (x, y, durationMs) => longPressHarmony(device, x, y, durationMs ?? 1000), + focus: (x, y) => focusHarmony(device, x, y), + type: (text, _delayMs) => typeHarmony(device, text), + fill: (x, y, text, delayMs) => fillHarmony(device, { x, y }, text, delayMs ?? 100), + scroll: (direction, options) => scrollHarmony(device, direction, options), + pinch: async () => { throw new Error('Pinch gesture not supported on HarmonyOS'); }, + rotateGesture: async () => { throw new Error('Rotate gesture not supported on HarmonyOS'); }, + transformGesture: async () => { throw new Error('Transform gesture not supported on HarmonyOS'); }, + screenshot: async (outPath, _options) => { + await screenshotHarmony(device, outPath); + }, + snapshot: async (options) => { + const result = await withDiagnosticTimer( + 'snapshot_capture', + async () => + await snapshotHarmony(device, { + interactiveOnly: options?.interactiveOnly, + compact: options?.compact, + depth: options?.depth, + scope: options?.scope, + raw: options?.raw, + }), + { backend: 'harmonyos-arkui' }, + ); + return { + nodes: result.nodes ?? [], + truncated: result.truncated ?? false, + backend: 'harmonyos-arkui', + analysis: { + rawNodeCount: result.rawNodeCount, + maxDepth: result.maxDepth, + }, + }; + }, + back: (_mode) => pressBackHarmony(device), + home: () => pressHomeHarmony(device), + rotate: (orientation) => rotateHarmony(device, orientation), + appSwitcher: async () => { + // App switcher via recent apps key + await pressKeyHarmony(device, 'Recent'); + }, + readClipboard: async () => readHarmonyClipboardText(device), + writeClipboard: async (text) => writeHarmonyClipboardText(device, text), + setSetting: async (setting, state, appId, options) => { + return await setHarmonySetting(device, setting, state, appId, options); + }, + }; +} diff --git a/src/core/platform-inventory.ts b/src/core/platform-inventory.ts index 5835b08b6..2d71f1cb3 100644 --- a/src/core/platform-inventory.ts +++ b/src/core/platform-inventory.ts @@ -32,6 +32,11 @@ export async function listLocalDeviceInventory( }); } + if (request.platform === 'harmonyos') { + const { listHarmonyDevices } = await import('../platforms/harmonyos/devices.ts'); + return await listHarmonyDevices(); + } + if (request.platform) { const { listAppleDevices } = await import('../platforms/ios/devices.ts'); return await listAppleDevices({ @@ -66,6 +71,10 @@ export async function listLocalDeviceInventory( const { listLinuxDevices } = await import('../platforms/linux/devices.ts'); devices.push(...(await listLinuxDevices())); } catch {} + try { + const { listHarmonyDevices } = await import('../platforms/harmonyos/devices.ts'); + devices.push(...(await listHarmonyDevices())); + } catch {} return devices; } diff --git a/src/daemon/app-log.ts b/src/daemon/app-log.ts index 2229d9783..5ba0f1086 100644 --- a/src/daemon/app-log.ts +++ b/src/daemon/app-log.ts @@ -178,6 +178,7 @@ export function getAppLogPathMetadata(outPath: string): { function resolveNetworkLogBackend(device: DeviceInfo): NetworkLogBackend { if (device.platform === 'macos') return 'macos'; + if (device.platform === 'harmonyos') return 'harmonyos'; if (device.platform === 'ios') { return device.kind === 'device' ? 'ios-device' : 'ios-simulator'; } diff --git a/src/daemon/handlers/install-source.ts b/src/daemon/handlers/install-source.ts index 88da4b013..840adac78 100644 --- a/src/daemon/handlers/install-source.ts +++ b/src/daemon/handlers/install-source.ts @@ -33,8 +33,8 @@ type InstallFromSourceResult = { materializationExpiresAt?: string; }; -function normalizePlatform(platform: CommandFlags['platform']): 'ios' | 'android' | undefined { - return platform === 'ios' || platform === 'android' ? platform : undefined; +function normalizePlatform(platform: CommandFlags['platform']): 'ios' | 'android' | 'harmonyos' | undefined { + return platform === 'ios' || platform === 'android' || platform === 'harmonyos' ? platform : undefined; } function resolveRetainMaterializedPaths(req: DaemonRequest): { enabled: boolean; ttlMs?: number } { @@ -69,7 +69,7 @@ async function resolveInstallDevice(params: { if (!requestedPlatform) { throw new AppError( 'INVALID_ARGS', - 'install_from_source requires platform "ios" or "android" when no session is provided', + 'install_from_source requires platform "ios", "android", or "harmonyos" when no session is provided', ); } const device = await resolveTargetDevice(params.flags ?? {}); @@ -210,6 +210,21 @@ export async function handleInstallFromSourceCommand(params: { }); } + if (device.platform === 'harmonyos') { + const { installHarmonyApp } = await import('../../platforms/harmonyos/app-lifecycle.ts'); + const installablePath = resolvedSource.source.kind === 'path' + ? resolvedSource.source.path + : resolvedSource.source.url; + await installHarmonyApp(device, installablePath); + return { + ok: true, + data: withSuccessText( + { launchTarget: installablePath }, + `Installed: ${installablePath}`, + ), + }; + } + const { prepareAndroidInstallArtifact } = await import('../../platforms/android/install-artifact.ts'); const { installAndroidInstallablePathAndResolvePackageName } = diff --git a/src/daemon/handlers/session-deploy.ts b/src/daemon/handlers/session-deploy.ts index d8974c79e..8633b387e 100644 --- a/src/daemon/handlers/session-deploy.ts +++ b/src/daemon/handlers/session-deploy.ts @@ -13,6 +13,7 @@ import { errorResponse } from './response.ts'; export type ReinstallOps = { ios: (device: DeviceInfo, app: string, appPath: string) => Promise<{ bundleId: string }>; android: (device: DeviceInfo, app: string, appPath: string) => Promise<{ package: string }>; + harmonyos: (device: DeviceInfo, app: string, appPath: string) => Promise<{ bundleName: string }>; }; export type AppDeployOps = { @@ -26,6 +27,11 @@ export type AppDeployOps = { app: string, appPath: string, ) => Promise<{ package?: string; appName?: string; launchTarget?: string }>; + harmonyos: ( + device: DeviceInfo, + app: string, + appPath: string, + ) => Promise<{ bundleName?: string; appName?: string; launchTarget?: string }>; }; export type InstallOps = AppDeployOps; @@ -50,7 +56,13 @@ type AndroidDeployCommandResult = DeployCommandResultBase & { packageName?: string; }; -type DeployCommandResult = IosDeployCommandResult | AndroidDeployCommandResult; +type HarmonyDeployCommandResult = DeployCommandResultBase & { + platform: 'harmonyos'; + appId?: string; + bundleName?: string; +}; + +type DeployCommandResult = IosDeployCommandResult | AndroidDeployCommandResult | HarmonyDeployCommandResult; export const defaultReinstallOps: ReinstallOps = { ios: async (device, app, appPath) => { @@ -61,6 +73,10 @@ export const defaultReinstallOps: ReinstallOps = { const { reinstallAndroidApp } = await import('../../platforms/android/app-lifecycle.ts'); return await reinstallAndroidApp(device, app, appPath); }, + harmonyos: async (device, app, appPath) => { + const { reinstallHarmonyApp } = await import('../../platforms/harmonyos/app-lifecycle.ts'); + return await reinstallHarmonyApp(device, app, appPath); + }, }; export const defaultInstallOps: InstallOps = { @@ -82,6 +98,14 @@ export const defaultInstallOps: InstallOps = { launchTarget: result.launchTarget, }; }, + harmonyos: async (device, _app, appPath) => { + const { installHarmonyApp } = await import('../../platforms/harmonyos/app-lifecycle.ts'); + await installHarmonyApp(device, appPath); + return { + bundleName: _app, + launchTarget: _app, + }; + }, }; export async function handleAppDeployCommand(params: { @@ -144,6 +168,26 @@ export async function handleAppDeployCommand(params: { appName: iosResult.appName, launchTarget: iosResult.launchTarget, }; + } else if (device.platform === 'harmonyos') { + const harmonyResult = await deployOps.harmonyos(device, app, appPath); + const bundleName = harmonyResult.bundleName; + result = bundleName + ? { + app, + appPath, + platform: 'harmonyos', + appId: bundleName, + bundleName, + appName: harmonyResult.appName, + launchTarget: harmonyResult.launchTarget, + } + : { + app, + appPath, + platform: 'harmonyos', + appName: harmonyResult.appName, + launchTarget: harmonyResult.launchTarget, + }; } else { const androidResult = await deployOps.android(device, app, appPath); const pkg = androidResult.package; diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index 578c4d634..5c4ba41d0 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -15,6 +15,7 @@ import { import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { listAndroidApps } from '../../platforms/android/app-lifecycle.ts'; +import { listHarmonyApps } from '../../platforms/harmonyos/app-lifecycle.ts'; import { listIosApps } from '../../platforms/ios/apps.ts'; import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; import { errorResponse } from './response.ts'; @@ -121,6 +122,20 @@ export async function handleSessionInventoryCommands(params: { }; } + if (device.platform === 'harmonyos') { + const apps = await listHarmonyApps(device, appsFilter); + return { + ok: true, + data: { + apps: apps.map((app) => { + const label = app.name && app.name !== app.id ? `${app.name} (${app.id})` : app.id; + const suffix = app.activity ? ` → ${app.activity}` : ''; + return `${label}${suffix}`; + }), + }, + }; + } + const apps = await listAndroidApps(device, appsFilter); return { ok: true, diff --git a/src/daemon/network-log.ts b/src/daemon/network-log.ts index a20ffe253..ee7130192 100644 --- a/src/daemon/network-log.ts +++ b/src/daemon/network-log.ts @@ -19,7 +19,7 @@ const ANDROID_PACKET_SCAN_RADIUS = 12; const NETWORK_LOG_MEMORY_PATH = ''; export type NetworkIncludeMode = 'summary' | 'headers' | 'body' | 'all'; -export type NetworkLogBackend = 'ios-simulator' | 'ios-device' | 'android' | 'macos'; +export type NetworkLogBackend = 'ios-simulator' | 'ios-device' | 'android' | 'macos' | 'harmonyos'; export type NetworkEntry = { method?: string; diff --git a/src/daemon/request-lock-policy.ts b/src/daemon/request-lock-policy.ts index 05fccfb9c..863d6a0e4 100644 --- a/src/daemon/request-lock-policy.ts +++ b/src/daemon/request-lock-policy.ts @@ -192,6 +192,7 @@ function targetSelectorsConflict( switch (lockPlatform) { case 'android': case 'ios': + case 'harmonyos': return target === 'desktop'; case 'macos': case 'linux': @@ -233,6 +234,8 @@ function freshSessionSelectorKeysForPlatform( return ['udid', 'serial', 'iosSimulatorDeviceSet', 'androidDeviceAllowlist']; case 'linux': return ['udid', 'serial', 'iosSimulatorDeviceSet', 'androidDeviceAllowlist']; + case 'harmonyos': + return ['udid', 'serial', 'iosSimulatorDeviceSet', 'androidDeviceAllowlist']; default: return assertNever(lockPlatform); } diff --git a/src/platforms/harmonyos/__tests__/app-parsers.test.ts b/src/platforms/harmonyos/__tests__/app-parsers.test.ts new file mode 100644 index 000000000..b12675e74 --- /dev/null +++ b/src/platforms/harmonyos/__tests__/app-parsers.test.ts @@ -0,0 +1,93 @@ +import { describe, it } from 'vitest'; +import assert from 'node:assert/strict'; +import { + lookupWukongLaunchAbility, + parseHarmonyBundleList, + parseHarmonyForegroundAbility, + parseWukongAppInfo, +} from '../app-parsers.ts'; + +describe('HarmonyOS App Parsers', () => { + describe('parseHarmonyBundleList', () => { + it('parses empty output', () => { + const result = parseHarmonyBundleList(''); + assert.deepEqual(result, []); + }); + + it('parses bundle names from bm dump output', () => { + const output = ` +Bundle Name: com.huawei.hmos.settings +Bundle Name: com.huawei.hmos.camera +Bundle Name: com.example.myapp +`; + const result = parseHarmonyBundleList(output); + assert.deepEqual(result, [ + 'com.huawei.hmos.settings', + 'com.huawei.hmos.camera', + 'com.example.myapp', + ]); + }); + + it('handles malformed output gracefully', () => { + const output = 'some random output without bundle names'; + const result = parseHarmonyBundleList(output); + assert.deepEqual(result, []); + }); + }); + + describe('parseWukongAppInfo', () => { + it('parses bundle and ability pairs from wukong appinfo', () => { + const output = ` +I/O error : failed to load "/system/usr/ohos_locale_config/supported_locales.xml": Permission denied +BundleName: com.sdu.didi.hmos.psnger +AbilityName: EntryAbility +BundleName: com.ss.dcar.auto +AbilityName: DcarAbility +`; + const map = parseWukongAppInfo(output); + assert.strictEqual(map.get('com.sdu.didi.hmos.psnger'), 'EntryAbility'); + assert.strictEqual(map.get('com.ss.dcar.auto'), 'DcarAbility'); + }); + + it('keeps the first ability when a bundle appears more than once', () => { + const output = ` +BundleName: com.ohos.contacts +AbilityName: com.ohos.contacts.EntryAbility +BundleName: com.ohos.contacts +AbilityName: com.ohos.contacts.MainAbility +`; + assert.strictEqual( + lookupWukongLaunchAbility(output, 'com.ohos.contacts'), + 'com.ohos.contacts.EntryAbility', + ); + }); + }); + + describe('parseHarmonyForegroundAbility', () => { + it('returns null for empty output', () => { + const result = parseHarmonyForegroundAbility(''); + assert.strictEqual(result, null); + }); + + it('parses foreground ability info', () => { + const output = ` +# app name [com.huawei.hmos.settings] + main name [MainAbility] + app state #FOREGROUND +`; + const result = parseHarmonyForegroundAbility(output); + assert.ok(result); + assert.strictEqual(result?.bundleName, 'com.huawei.hmos.settings'); + assert.strictEqual(result?.abilityName, 'MainAbility'); + }); + + it('returns null when no foreground ability', () => { + const output = ` +# app name [com.huawei.hmos.settings] + app state #BACKGROUND +`; + const result = parseHarmonyForegroundAbility(output); + assert.strictEqual(result, null); + }); + }); +}); diff --git a/src/platforms/harmonyos/__tests__/app-storage.test.ts b/src/platforms/harmonyos/__tests__/app-storage.test.ts new file mode 100644 index 000000000..b9348b1ab --- /dev/null +++ b/src/platforms/harmonyos/__tests__/app-storage.test.ts @@ -0,0 +1,32 @@ +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import { harmonyDeviceForSerial } from '../hdc.ts'; +import { clearHarmonyAppStorage } from '../app-lifecycle.ts'; +import * as hdc from '../hdc.ts'; + +const DEVICE = harmonyDeviceForSerial('22M0223824043030'); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test('clearHarmonyAppStorage force-stops then bm clean data and cache', async () => { + const runHarmonyHdc = vi.spyOn(hdc, 'runHarmonyHdc'); + runHarmonyHdc.mockResolvedValue({ exitCode: 0, stdout: 'ok', stderr: '' }); + + const result = await clearHarmonyAppStorage(DEVICE, 'com.sdu.didi.hmos.psnger'); + + assert.deepEqual(result, { + bundleId: 'com.sdu.didi.hmos.psnger', + clearedData: true, + clearedCache: true, + }); + assert.deepEqual( + runHarmonyHdc.mock.calls.map(([_, args]) => args), + [ + ['shell', 'aa', 'force-stop', 'com.sdu.didi.hmos.psnger'], + ['shell', 'bm', 'clean', '-n', 'com.sdu.didi.hmos.psnger', '-d'], + ['shell', 'bm', 'clean', '-n', 'com.sdu.didi.hmos.psnger', '-c'], + ], + ); +}); diff --git a/src/platforms/harmonyos/__tests__/arkui-hierarchy.test.ts b/src/platforms/harmonyos/__tests__/arkui-hierarchy.test.ts new file mode 100644 index 000000000..ac4697752 --- /dev/null +++ b/src/platforms/harmonyos/__tests__/arkui-hierarchy.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'vitest'; +import { buildArkUiSnapshot, type ArkUiTree } from '../arkui-hierarchy.ts'; + +describe('HarmonyOS ArkUI hierarchy', () => { + it('filters interactiveOnly output to interactive context', () => { + const tree: ArkUiTree = [ + { + attributes: { type: 'root' }, + children: [ + { + attributes: { type: 'Text', text: 'Title only' }, + }, + { + attributes: { type: 'List', scrollable: 'true', id: 'feed' }, + children: [ + { + attributes: { type: 'Text', text: 'Item title' }, + }, + { + attributes: { type: 'Button', text: 'Open', clickable: 'true' }, + }, + ], + }, + { + attributes: { type: 'Text', text: 'Footer only' }, + }, + ], + }, + ]; + + const result = buildArkUiSnapshot(tree, 100, { interactiveOnly: true }); + const labels = result.nodes.map((node) => node.label).filter(Boolean); + const identifiers = result.nodes.map((node) => node.identifier).filter(Boolean); + + assert.deepEqual(labels, ['Item title', 'Open']); + assert.deepEqual(identifiers, ['feed']); + }); + + it('when a focused modal exists, interactiveOnly scopes to it', () => { + const tree: ArkUiTree = [ + { + attributes: { type: 'root' }, + children: [ + { + attributes: { type: 'Column' }, + children: [ + { attributes: { type: 'Text', text: '微信登录' } }, + { attributes: { type: 'Text', text: '其他登录方式' } }, + { attributes: { type: 'Button', text: '背景按钮', clickable: 'true' } }, + ], + }, + { + attributes: { type: 'NavDestination', focused: 'true' }, + children: [ + { attributes: { type: 'Text', text: '请阅读并同意以下条款' } }, + { attributes: { type: 'Button', text: '不同意', clickable: 'true' } }, + { attributes: { type: 'Button', text: '同意并继续', clickable: 'true' } }, + ], + }, + ], + }, + ]; + + const result = buildArkUiSnapshot(tree, 100, { interactiveOnly: true }); + const labels = result.nodes.map((node) => node.label).filter(Boolean); + + assert.ok(labels.includes('同意并继续')); + assert.ok(labels.includes('不同意')); + assert.ok(labels.includes('请阅读并同意以下条款')); + assert.ok(!labels.includes('背景按钮')); + assert.ok(!labels.includes('微信登录')); + assert.ok(!labels.includes('其他登录方式')); + }); +}); diff --git a/src/platforms/harmonyos/__tests__/devices.test.ts b/src/platforms/harmonyos/__tests__/devices.test.ts new file mode 100644 index 000000000..33091ffb3 --- /dev/null +++ b/src/platforms/harmonyos/__tests__/devices.test.ts @@ -0,0 +1,36 @@ +import { describe, it } from 'vitest'; +import assert from 'node:assert/strict'; +import { parseHarmonyDeviceList } from '../devices.ts'; + +describe('HarmonyOS Device Discovery', () => { + describe('parseHarmonyDeviceList', () => { + it('parses empty output correctly', () => { + const result = parseHarmonyDeviceList(''); + assert.deepEqual(result, []); + }); + + it('parses single device', () => { + const output = '192.168.1.100:5555\n'; + const result = parseHarmonyDeviceList(output); + assert.deepEqual(result, ['192.168.1.100:5555']); + }); + + it('parses multiple devices', () => { + const output = 'device1\ndevice2\ndevice3\n'; + const result = parseHarmonyDeviceList(output); + assert.deepEqual(result, ['device1', 'device2', 'device3']); + }); + + it('filters out [Empty] marker', () => { + const output = '[Empty]\n'; + const result = parseHarmonyDeviceList(output); + assert.deepEqual(result, []); + }); + + it('handles whitespace correctly', () => { + const output = ' device1 \n device2 \n\n \n'; + const result = parseHarmonyDeviceList(output); + assert.deepEqual(result, ['device1', 'device2']); + }); + }); +}); diff --git a/src/platforms/harmonyos/__tests__/launch-abilities.test.ts b/src/platforms/harmonyos/__tests__/launch-abilities.test.ts new file mode 100644 index 000000000..ca801acc5 --- /dev/null +++ b/src/platforms/harmonyos/__tests__/launch-abilities.test.ts @@ -0,0 +1,48 @@ +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import { harmonyDeviceForSerial } from '../hdc.ts'; +import { + clearHarmonyLaunchAbilityCache, + getHarmonyLaunchAbilities, + lookupHarmonyLaunchAbility, +} from '../launch-abilities.ts'; +import * as hdc from '../hdc.ts'; + +const DEVICE = harmonyDeviceForSerial('22M0223824043030'); + +afterEach(() => { + vi.restoreAllMocks(); + clearHarmonyLaunchAbilityCache(); +}); + +test('getHarmonyLaunchAbilities parses wukong appinfo and caches per device', async () => { + const runHarmonyHdc = vi.spyOn(hdc, 'runHarmonyHdc'); + runHarmonyHdc.mockResolvedValue({ + exitCode: 0, + stdout: ` +BundleName: com.sdu.didi.hmos.psnger +AbilityName: EntryAbility +BundleName: com.ss.dcar.auto +AbilityName: DcarAbility +`, + stderr: '', + }); + + const first = await getHarmonyLaunchAbilities(DEVICE); + const second = await getHarmonyLaunchAbilities(DEVICE); + + assert.equal(first.get('com.sdu.didi.hmos.psnger'), 'EntryAbility'); + assert.equal(first.get('com.ss.dcar.auto'), 'DcarAbility'); + assert.equal(runHarmonyHdc.mock.calls.length, 1); + assert.equal(second.get('com.sdu.didi.hmos.psnger'), 'EntryAbility'); +}); + +test('lookupHarmonyLaunchAbility returns null when bundle is missing', async () => { + vi.spyOn(hdc, 'runHarmonyHdc').mockResolvedValue({ + exitCode: 0, + stdout: 'BundleName: com.example.app\nAbilityName: EntryAbility\n', + stderr: '', + }); + + assert.equal(await lookupHarmonyLaunchAbility(DEVICE, 'com.missing.app'), null); +}); diff --git a/src/platforms/harmonyos/__tests__/uitest-preflight.test.ts b/src/platforms/harmonyos/__tests__/uitest-preflight.test.ts new file mode 100644 index 000000000..aec418dab --- /dev/null +++ b/src/platforms/harmonyos/__tests__/uitest-preflight.test.ts @@ -0,0 +1,20 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { + buildHarmonyUitestBlockedHint, + findStuckHarmonyUitestProcess, +} from '../uitest-preflight.ts'; + +test('findStuckHarmonyUitestProcess detects uiRecord record', () => { + const stuck = findStuckHarmonyUitestProcess([ + 'shell 6151 1984 uitest uiRecord record', + 'shell 4253 1984 snapshot_display -f /data/local/tmp/x.jpeg', + ]); + assert.match(stuck ?? '', /uiRecord record/); +}); + +test('buildHarmonyUitestBlockedHint mentions reboot', () => { + const hint = buildHarmonyUitestBlockedHint('shell 6151 uitest uiRecord record'); + assert.match(hint, /Reboot the device/i); + assert.match(hint, /uiRecord record/i); +}); diff --git a/src/platforms/harmonyos/alert.ts b/src/platforms/harmonyos/alert.ts new file mode 100644 index 000000000..6d7de4ac7 --- /dev/null +++ b/src/platforms/harmonyos/alert.ts @@ -0,0 +1,458 @@ +import type { AlertAction } from '../../alert-contract.ts'; +import { + ALERT_ACTION_RETRY_MS, + ALERT_POLL_INTERVAL_MS, + DEFAULT_ALERT_TIMEOUT_MS, +} from '../../alert-contract.ts'; +import { AppError } from '../../utils/errors.ts'; +import { withDiagnosticTimer } from '../../utils/diagnostics.ts'; +import { successText } from '../../utils/success-text.ts'; +import { sleep } from '../../utils/timeouts.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; +import { pressBackHarmony, pressHarmony } from './input-actions.ts'; +import { snapshotHarmony } from './snapshot.ts'; +import type { SnapshotNode } from '../../utils/snapshot.ts'; + +export type HarmonyAlertInfo = { + visible: boolean; + title?: string; + message?: string; + buttons: HarmonyAlertButton[]; +}; + +export type HarmonyAlertButton = { + label: string; + x: number; + y: number; +}; + +export type HarmonyAlertCandidate = { + alert: HarmonyAlertInfo; + nodes: SnapshotNode[]; +}; + +export type HarmonyAlertResult = + | { + kind: 'alertStatus'; + platform: 'harmonyos'; + action: 'get'; + alert: HarmonyAlertInfo | null; + message?: string; + } + | { + kind: 'alertWait'; + platform: 'harmonyos'; + action: 'wait'; + alert: HarmonyAlertInfo; + waitedMs: number; + message?: string; + } + | { + kind: 'alertHandled'; + platform: 'harmonyos'; + action: 'accept' | 'dismiss'; + handled: true; + alert: HarmonyAlertInfo; + button: string; + message?: string; + }; + +export async function handleHarmonyAlert( + device: DeviceInfo, + action: AlertAction, + options: { timeoutMs?: number } = {}, +): Promise { + if (action === 'wait') { + return await waitForHarmonyAlert(device, options.timeoutMs ?? DEFAULT_ALERT_TIMEOUT_MS); + } + if (action === 'get') { + const candidate = await readHarmonyAlertCandidate(device); + return buildHarmonyAlertStatusResponse(candidate?.alert ?? null); + } + return await handleHarmonyAlertAction(device, action); +} + +async function waitForHarmonyAlert( + device: DeviceInfo, + timeoutMs: number, +): Promise { + const start = Date.now(); + const candidate = await pollHarmonyAlertCandidate(device, timeoutMs); + if (!candidate) { + throw new AppError('COMMAND_FAILED', 'alert wait timed out'); + } + return { + kind: 'alertWait', + platform: 'harmonyos', + action: 'wait', + alert: candidate.alert, + waitedMs: Date.now() - start, + ...successText('Alert visible'), + }; +} + +async function handleHarmonyAlertAction( + device: DeviceInfo, + action: 'accept' | 'dismiss', +): Promise { + const candidate = await pollHarmonyAlertCandidate(device, ALERT_ACTION_RETRY_MS); + if (!candidate) { + throw new AppError('COMMAND_FAILED', 'alert not found', { + hint: 'If a sheet is visible in snapshot but alert reports no alert, it is likely app-owned UI. Use snapshot -i and press the visible label/ref.', + }); + } + + const button = chooseHarmonyAlertButton(candidate.alert.buttons, action); + if (button) { + await pressHarmony(device, button.x, button.y); + return buildHarmonyAlertHandledResponse(action, candidate.alert, button.label); + } + + if (action === 'dismiss') { + await pressBackHarmony(device); + return buildHarmonyAlertHandledResponse(action, candidate.alert, 'Back'); + } + + throw new AppError('COMMAND_FAILED', 'alert accept found an alert but no accept button', { + alert: candidate.alert, + hint: 'Inspect alert get --json for visible buttons, then use press by visible label/ref if needed.', + }); +} + +async function pollHarmonyAlertCandidate( + device: DeviceInfo, + timeoutMs: number, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const candidate = await readHarmonyAlertCandidate(device); + if (candidate) return candidate; + await sleep(ALERT_POLL_INTERVAL_MS); + } + return null; +} + +async function readHarmonyAlertCandidate( + device: DeviceInfo, +): Promise { + try { + const result = await withDiagnosticTimer( + 'snapshot_capture', + async () => await snapshotHarmony(device, {}), + { backend: 'harmonyos-arkui', purpose: 'alert' }, + ); + return findHarmonyAlertCandidate(result.nodes); + } catch { + return null; + } +} + +function findHarmonyAlertCandidate(nodes: SnapshotNode[]): HarmonyAlertCandidate | null { + // HarmonyOS alert detection: look for dialog/alert-like components + // Common alert patterns: Dialog, AlertDialog, Popup, Toast, Sheet + // System-specific patterns: "暂无可用打开方式", notification/privacy dialogs + const alertCandidates: SnapshotNode[] = []; + + // First pass: find dialog containers + for (const node of nodes) { + const type = node.type?.toLowerCase() ?? ''; + const label = node.label?.toLowerCase() ?? ''; + const value = node.value?.toLowerCase() ?? ''; + + // Check for dialog/alert UI patterns + if ( + type.includes('dialog') || + type.includes('alert') || + type.includes('popup') || + label.includes('dialog') || + label.includes('alert') || + type.includes('modal') || + type.includes('sheet') + ) { + alertCandidates.push(node); + } + + // Check for system-specific dialog content patterns + if ( + label.includes('暂无可用打开方式') || + value.includes('暂无可用打开方式') || + label.includes('打开方式') || + value.includes('打开方式') + ) { + alertCandidates.push(node); + } + + // Check for notification permission dialogs + if ( + label.includes('通知') || + value.includes('通知') || + label.includes('权限') || + value.includes('权限') + ) { + alertCandidates.push(node); + } + + // Check for privacy policy dialogs + if ( + label.includes('隐私') || + value.includes('隐私') || + label.includes('政策') || + value.includes('政策') || + label.includes('协议') || + value.includes('协议') + ) { + alertCandidates.push(node); + } + + // Check for update dialogs + if ( + label.includes('更新') || + value.includes('更新') || + label.includes('升级') || + value.includes('升级') + ) { + alertCandidates.push(node); + } + } + + if (alertCandidates.length === 0) return null; + + // Find buttons within alert candidates + const buttons: HarmonyAlertButton[] = []; + let title: string | undefined; + let message: string | undefined; + + for (const candidate of alertCandidates) { + // Look for text content as title/message + if (candidate.label && !title) { + title = candidate.label; + } + if (candidate.value && !message) { + message = candidate.value; + } + + // Look for button nodes + if (candidate.hittable && candidate.rect) { + const btnLabel = candidate.label ?? candidate.value ?? ''; + const btnLabelLower = btnLabel.toLowerCase(); + const isButton = + // English patterns + btnLabelLower.includes('ok') || + btnLabelLower.includes('cancel') || + btnLabelLower.includes('confirm') || + btnLabelLower.includes('dismiss') || + btnLabelLower.includes('yes') || + btnLabelLower.includes('no') || + btnLabelLower.includes('allow') || + btnLabelLower.includes('deny') || + btnLabelLower.includes('accept') || + btnLabelLower.includes('reject') || + btnLabelLower.includes('continue') || + btnLabelLower.includes('close') || + btnLabelLower.includes('got it') || + // Chinese patterns (common in HarmonyOS) + btnLabel.includes('确定') || + btnLabel.includes('确认') || + btnLabel.includes('取消') || + btnLabel.includes('是') || + btnLabel.includes('否') || + btnLabel.includes('允许') || + btnLabel.includes('不允许') || + btnLabel.includes('同意') || + btnLabel.includes('拒绝') || + btnLabel.includes('知道了') || + btnLabel.includes('同意并继续') || + btnLabel.includes('继续') || + btnLabel.includes('关闭') || + btnLabel.includes('稍后') || + btnLabel.includes('暂不') || + btnLabel.includes('下次再说') || + btnLabel.includes('跳过') || + candidate.type?.toLowerCase().includes('button'); + + if (isButton) { + const centerX = (candidate.rect.x ?? 0) + (candidate.rect.width ?? 0) / 2; + const centerY = (candidate.rect.y ?? 0) + (candidate.rect.height ?? 0) / 2; + buttons.push({ + label: btnLabel, + x: centerX, + y: centerY, + }); + } + } + } + + if (buttons.length === 0) return null; + + return { + alert: { + visible: true, + title, + message, + buttons, + }, + nodes: alertCandidates, + }; +} + +function chooseHarmonyAlertButton( + buttons: HarmonyAlertButton[], + action: 'accept' | 'dismiss', +): HarmonyAlertButton | null { + if (buttons.length === 0) return null; + + const acceptPatterns = [ + // English + 'ok', + 'confirm', + 'yes', + 'accept', + 'allow', + 'continue', + 'got it', + 'close', + 'agree', + // Chinese + '确定', + '确认', + '是', + '允许', + '同意', + '同意并继续', + '继续', + '知道了', + '关闭', + '跳过', + ]; + const dismissPatterns = [ + // English + 'cancel', + 'dismiss', + 'no', + 'reject', + 'deny', + 'later', + 'skip', + 'not now', + // Chinese + '取消', + '否', + '不允许', + '拒绝', + '暂不', + '稍后', + '下次再说', + '跳过', + ]; + + const patterns = action === 'accept' ? acceptPatterns : dismissPatterns; + + // First pass: exact match (preferred) + for (const button of buttons) { + const label = button.label.toLowerCase(); + for (const pattern of patterns) { + if (label === pattern || label.includes(pattern)) { + return button; + } + } + } + + // Fallback: first button for accept, last for dismiss + if (action === 'accept') { + return buttons[0] ?? null; + } + return buttons[buttons.length - 1] ?? null; +} + +function buildHarmonyAlertStatusResponse(alert: HarmonyAlertInfo | null): HarmonyAlertResult { + return { + kind: 'alertStatus', + platform: 'harmonyos', + action: 'get', + alert, + ...(alert ? successText('Alert visible') : successText('No alert visible')), + }; +} + +function buildHarmonyAlertHandledResponse( + action: 'accept' | 'dismiss', + alert: HarmonyAlertInfo, + button: string, +): HarmonyAlertResult { + return { + kind: 'alertHandled', + platform: 'harmonyos', + action, + handled: true, + alert, + button, + ...successText(`Alert ${action}ed`), + }; +} + +/** + * Auto-dismiss common HarmonyOS system dialogs that appear during app launch. + * This handles: + * - "暂无可用打开方式" (No available opening method) + * - Notification permission dialogs + * - Privacy policy dialogs + * - Update prompts + * + * @param device Device info + * @param maxAttempts Maximum number of attempts to dismiss dialogs + * @returns Number of dialogs dismissed + */ +export async function dismissHarmonySystemDialogs( + device: DeviceInfo, + maxAttempts: number = 3, +): Promise { + let dismissedCount = 0; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const candidate = await readHarmonyAlertCandidate(device); + if (!candidate) { + // No dialog visible + break; + } + + const { alert } = candidate; + const title = (alert.title ?? '').toLowerCase(); + const message = (alert.message ?? '').toLowerCase(); + + // Check for known system dialog patterns + const isSystemDialog = + title.includes('暂无可用打开方式') || + message.includes('暂无可用打开方式') || + title.includes('打开方式') || + message.includes('打开方式') || + title.includes('通知') || + message.includes('通知') || + title.includes('权限') || + message.includes('权限') || + title.includes('隐私') || + message.includes('隐私') || + title.includes('更新') || + message.includes('更新'); + + if (!isSystemDialog) { + // Not a system dialog, stop + break; + } + + // Try to dismiss by accepting (most system dialogs have "确定" or "知道了") + const button = chooseHarmonyAlertButton(alert.buttons, 'accept'); + if (button) { + await pressHarmony(device, button.x, button.y); + dismissedCount++; + // Small delay to let dialog dismiss + await new Promise((resolve) => setTimeout(resolve, 500)); + } else { + // No button found, try back + await pressBackHarmony(device); + dismissedCount++; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + + return dismissedCount; +} diff --git a/src/platforms/harmonyos/app-lifecycle.ts b/src/platforms/harmonyos/app-lifecycle.ts new file mode 100644 index 000000000..0c39266e4 --- /dev/null +++ b/src/platforms/harmonyos/app-lifecycle.ts @@ -0,0 +1,369 @@ +import type { DeviceInfo } from '../../utils/device.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { BackendAppInfo, BackendAppState } from '../../backend.ts'; +import type { AppsFilter } from '../../commands/app-inventory-contract.ts'; +import { runHarmonyHdc } from './hdc.ts'; +import { parseHarmonyBundleList, parseHarmonyForegroundAbility } from './app-parsers.ts'; +import { dismissHarmonySystemDialogs } from './alert.ts'; +import { lookupHarmonyLaunchAbility, getHarmonyLaunchAbilities } from './launch-abilities.ts'; + +export async function listHarmonyApps( + device: DeviceInfo, + _filter: AppsFilter, +): Promise { + const result = await runHarmonyHdc(device, ['shell', 'bm', 'dump', '-a'], { + allowFailure: false, + timeoutMs: 15_000, + }); + + const bundles = parseHarmonyBundleList(result.stdout); + const launchAbilities = await getHarmonyLaunchAbilities(device); + + return bundles.map((bundle) => { + const launchAbility = launchAbilities.get(bundle); + return { + id: bundle, + name: bundle.split('.').pop() ?? bundle, + bundleId: bundle, + ...(launchAbility ? { activity: launchAbility } : {}), + }; + }); +} + +export async function openHarmonyApp( + device: DeviceInfo, + bundleName: string, + abilityName?: string, + moduleName?: string, +): Promise { + // Check for screen lock first + await checkAndHandleScreenLock(device); + + const resolved = await resolveHarmonyApp(device, bundleName); + + // Strategy 1: Try with specified ability if provided + if (abilityName) { + const success = await tryOpenWithAbility(device, resolved, abilityName, moduleName); + if (success) { + await handlePostLaunchDialogs(device); + return; + } + } + + // Strategy 2: Resolve launch ability via wukong appinfo (DevEco / device catalog) + const wukongAbility = await lookupHarmonyLaunchAbility(device, resolved); + if (wukongAbility) { + const successWukong = await tryOpenWithAbility(device, resolved, wukongAbility, moduleName); + if (successWukong) { + await handlePostLaunchDialogs(device); + return; + } + } + + // Strategy 3: Try with MainAbility (common default) + const successMain = await tryOpenWithAbility(device, resolved, 'MainAbility', moduleName); + if (successMain) { + await handlePostLaunchDialogs(device); + return; + } + + // Strategy 4: Try EntryAbility (common for third-party apps) + const successEntry = await tryOpenWithAbility(device, resolved, 'EntryAbility', moduleName); + if (successEntry) { + await handlePostLaunchDialogs(device); + return; + } + + // Strategy 5: Try without specifying ability (let system decide) + const successDefault = await tryOpenWithoutAbility(device, resolved, moduleName); + if (successDefault) { + await handlePostLaunchDialogs(device); + return; + } + + throw new AppError('COMMAND_FAILED', `Failed to open app ${resolved} after multiple attempts`); +} + +async function tryOpenWithAbility( + device: DeviceInfo, + bundleName: string, + abilityName: string, + moduleName?: string, +): Promise { + // Try with the provided ability name + let success = await attemptStartAbility(device, bundleName, abilityName, moduleName); + if (success) return true; + + // If ability name doesn't include a dot, try with full qualified name + if (!abilityName.includes('.')) { + const fullAbilityName = `${bundleName}.${abilityName}`; + success = await attemptStartAbility(device, bundleName, fullAbilityName, moduleName); + if (success) return true; + } + + return false; +} + +async function attemptStartAbility( + device: DeviceInfo, + bundleName: string, + abilityName: string, + moduleName?: string, +): Promise { + const args = ['shell', 'aa', 'start', '-b', bundleName, '-a', abilityName]; + if (moduleName) { + args.push('-m', moduleName); + } + + try { + const result = await runHarmonyHdc(device, args, { allowFailure: true, timeoutMs: 10_000 }); + if (result.exitCode === 0) { + // Verify app is in foreground + await new Promise((resolve) => setTimeout(resolve, 1000)); + const state = await getHarmonyAppState(device); + if (state.state === 'foreground' && state.bundleId === bundleName) { + return true; + } + } + } catch { + // Ignore errors, will try next strategy + } + return false; +} + +async function tryOpenWithoutAbility( + device: DeviceInfo, + bundleName: string, + moduleName?: string, +): Promise { + const args = ['shell', 'aa', 'start', '-b', bundleName]; + if (moduleName) { + args.push('-m', moduleName); + } + + try { + const result = await runHarmonyHdc(device, args, { allowFailure: true, timeoutMs: 10_000 }); + if (result.exitCode === 0) { + // Verify app is in foreground + await new Promise((resolve) => setTimeout(resolve, 1000)); + const state = await getHarmonyAppState(device); + if (state.state === 'foreground' && state.bundleId === bundleName) { + return true; + } + } + } catch { + // Ignore errors + } + return false; +} + +async function handlePostLaunchDialogs(device: DeviceInfo): Promise { + // Small delay to let any system dialogs appear + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Auto-dismiss common system dialogs + await dismissHarmonySystemDialogs(device, 3); +} + +async function checkAndHandleScreenLock(device: DeviceInfo): Promise { + try { + // Check screen lock state via PowerManagerService + const result = await runHarmonyHdc( + device, + ['shell', 'hidumper', '-s', 'PowerManagerService', '-a', '-s'], + { allowFailure: true, timeoutMs: 5000 }, + ); + + if (result.exitCode === 0) { + const output = result.stdout.toLowerCase(); + if (output.includes('screen state: off') || output.includes('lock state: locked')) { + // Try to wake up screen + await runHarmonyHdc(device, ['shell', 'power-shell', 'wakeup'], { allowFailure: true }); + // Try to unlock (swipe up) + await runHarmonyHdc( + device, + ['shell', 'uitest', 'ui-input', 'swipe', '540', '2000', '540', '800', '300'], + { allowFailure: true }, + ); + // Wait for screen to wake + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Check again + const recheck = await runHarmonyHdc( + device, + ['shell', 'hidumper', '-s', 'PowerManagerService', '-a', '-s'], + { allowFailure: true, timeoutMs: 5000 }, + ); + if (recheck.stdout.toLowerCase().includes('lock state: locked')) { + throw new AppError( + 'COMMAND_FAILED', + 'Device screen is locked and could not be automatically unlocked. Please unlock the device manually.', + ); + } + } + } + } catch (error) { + if (error instanceof AppError && error.code === 'COMMAND_FAILED') { + throw error; + } + // If we can't check screen lock, proceed anyway + } +} + +export async function closeHarmonyApp(device: DeviceInfo, bundleName: string): Promise { + const resolved = await resolveHarmonyApp(device, bundleName); + + await runHarmonyHdc(device, ['shell', 'aa', 'force-stop', resolved], { + allowFailure: true, + }); +} + +export async function getHarmonyAppState(device: DeviceInfo): Promise { + try { + const result = await runHarmonyHdc(device, ['shell', 'aa', 'dump', '-l'], { + allowFailure: true, + timeoutMs: 10_000, + }); + + if (result.exitCode !== 0) { + return { state: 'unknown' }; + } + + const foreground = parseHarmonyForegroundAbility(result.stdout); + if (foreground) { + return { + bundleId: foreground.bundleName, + state: 'foreground', + }; + } + + return { state: 'unknown' }; + } catch { + return { state: 'unknown' }; + } +} + +async function resolveHarmonyApp(device: DeviceInfo, app: string): Promise { + // Known system app aliases + const aliases: Record = { + settings: 'com.huawei.hmos.settings', + 'com.huawei.hmos.settings': 'com.huawei.hmos.settings', + camera: 'com.huawei.hmos.camera', + browser: 'com.huawei.hmos.browser', + photos: 'com.huawei.hmos.photos', + files: 'com.huawei.hmos.filemanager', + notes: 'com.huawei.hmos.notes', + calculator: 'com.huawei.hmos.calculator', + clock: 'com.huawei.hmos.clock', + weather: 'com.huawei.hmos.weather', + calendar: 'com.huawei.hmos.calendar', + contacts: 'com.huawei.hmos.contacts', + phone: 'com.huawei.hmos.phone', + messages: 'com.huawei.hmos.mms', + appstore: 'com.huawei.hmos.appstore', + callsetting: 'com.huawei.hmos.callsetting', + communicationsetting: 'com.huawei.hmos.communicationsetting', + }; + + const lowerApp = app.toLowerCase(); + + // Check aliases first + if (aliases[lowerApp]) { + return aliases[lowerApp]; + } + + // If it looks like a full bundle name, use it directly + if (app.includes('.') && !app.includes(' ')) { + return app; + } + + // Try fuzzy match against installed bundles + try { + const result = await runHarmonyHdc(device, ['shell', 'bm', 'dump', '-a'], { + allowFailure: true, + timeoutMs: 10_000, + }); + + if (result.exitCode === 0) { + const bundles = parseHarmonyBundleList(result.stdout); + const lower = lowerApp; + + // Try substring match + const match = bundles.find( + (b) => b.toLowerCase().includes(lower) || b.toLowerCase().endsWith('.' + lower), + ); + + if (match) return match; + } + } catch { + // Fall through to error + } + + throw new AppError('APP_NOT_FOUND', `Could not resolve HarmonyOS app: ${app}`); +} + +export async function installHarmonyApp(device: DeviceInfo, hapPath: string): Promise { + const result = await runHarmonyHdc(device, ['install', hapPath], { + allowFailure: false, + timeoutMs: 120_000, + }); + + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', `Failed to install app: ${result.stderr}`); + } +} + +export async function reinstallHarmonyApp( + device: DeviceInfo, + bundleName: string, + hapPath: string, +): Promise<{ bundleName: string }> { + const resolved = await resolveHarmonyApp(device, bundleName).catch(() => bundleName); + await uninstallHarmonyApp(device, resolved); + await installHarmonyApp(device, hapPath); + return { bundleName: resolved }; +} + +export async function uninstallHarmonyApp(device: DeviceInfo, bundleName: string): Promise { + await runHarmonyHdc(device, ['uninstall', bundleName], { + allowFailure: true, + }); +} + +/** Wipe app data/cache via `bm clean` (resets first-run state such as privacy consent). */ +export async function clearHarmonyAppStorage( + device: DeviceInfo, + bundleName: string, + options?: { data?: boolean; cache?: boolean }, +): Promise<{ bundleId: string; clearedData: boolean; clearedCache: boolean }> { + const resolved = await resolveHarmonyApp(device, bundleName); + const clearData = options?.data !== false; + const clearCache = options?.cache !== false; + + await runHarmonyHdc(device, ['shell', 'aa', 'force-stop', resolved], { + allowFailure: true, + timeoutMs: 10_000, + }); + + if (clearData) { + const dataResult = await runHarmonyHdc(device, ['shell', 'bm', 'clean', '-n', resolved, '-d'], { + allowFailure: false, + timeoutMs: 30_000, + }); + if (dataResult.exitCode !== 0) { + throw new AppError( + 'COMMAND_FAILED', + `Failed to clear HarmonyOS app data for ${resolved}: ${dataResult.stderr}`, + ); + } + } + + if (clearCache) { + await runHarmonyHdc(device, ['shell', 'bm', 'clean', '-n', resolved, '-c'], { + allowFailure: true, + timeoutMs: 30_000, + }); + } + + return { bundleId: resolved, clearedData: clearData, clearedCache: clearCache }; +} diff --git a/src/platforms/harmonyos/app-parsers.ts b/src/platforms/harmonyos/app-parsers.ts new file mode 100644 index 000000000..e6c7290c1 --- /dev/null +++ b/src/platforms/harmonyos/app-parsers.ts @@ -0,0 +1,119 @@ +export function parseHarmonyBundleList(rawOutput: string): string[] { + const bundles: string[] = []; + for (const line of rawOutput.split('\n')) { + const trimmed = line.trim(); + // Match "Bundle Name: com.example.app" or just lines with bundle patterns + const match = trimmed.match(/Bundle\s+Name\s*[:=]\s*(\S+)/i); + if (match) { + bundles.push(match[1] ?? ''); + } else if (trimmed.includes('.') && !trimmed.startsWith('#') && trimmed.length > 0) { + // Fallback: if line contains dots (like a bundle ID) and isn't a comment + bundles.push(trimmed); + } + } + return bundles; +} + +export function parseHarmonyForegroundAbility(rawOutput: string): { + bundleName: string; + abilityName: string; +} | null { + const lines = rawOutput.split('\n'); + let currentBundle: string | null = null; + let currentAbility: string | null = null; + let currentAppState: string | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + + // Look for "app name [bundleName]" pattern + const appMatch = trimmed.match(/app\s+name\s+\[(\S+)\]/i); + if (appMatch) { + currentBundle = appMatch[1] ?? null; + } + + // Look for "bundle name [bundleName]" pattern + const bundleMatch = trimmed.match(/bundle\s+name\s+\[(\S+)\]/i); + if (bundleMatch) { + currentBundle = bundleMatch[1] ?? null; + } + + // Look for "main name [abilityName]" pattern + const abilityMatch = trimmed.match(/main\s+name\s+\[(\S+)\]/i); + if (abilityMatch) { + currentAbility = abilityMatch[1] ?? null; + } + + // Look for "app state #FOREGROUND" pattern + const stateMatch = trimmed.match(/app\s+state\s+#(\S+)/i); + if (stateMatch) { + currentAppState = stateMatch[1] ?? null; + } + + // When we find a complete entry, check if it's foreground + if (currentBundle && currentAbility && currentAppState) { + if (currentAppState === 'FOREGROUND') { + return { bundleName: currentBundle, abilityName: currentAbility }; + } + // Reset for next entry + currentBundle = null; + currentAbility = null; + currentAppState = null; + } + } + + return null; +} + +/** Parse `hdc shell wukong appinfo` bundle → launch ability pairs. */ +export function parseWukongAppInfo(rawOutput: string): Map { + const abilities = new Map(); + let pendingBundle: string | null = null; + + for (const line of rawOutput.split('\n')) { + const trimmed = line.trim(); + const bundleMatch = trimmed.match(/^BundleName:\s+(\S+)/i); + if (bundleMatch) { + pendingBundle = bundleMatch[1] ?? null; + continue; + } + + const abilityMatch = trimmed.match(/^AbilityName:\s+(\S+)/i); + if (abilityMatch && pendingBundle && !abilities.has(pendingBundle)) { + abilities.set(pendingBundle, abilityMatch[1] ?? ''); + pendingBundle = null; + } + } + + return abilities; +} + +export function lookupWukongLaunchAbility(rawOutput: string, bundleName: string): string | null { + return parseWukongAppInfo(rawOutput).get(bundleName) ?? null; +} + +export function parseHarmonyRunningAbilities(rawOutput: string): Array<{ + bundleName: string; + abilityName: string; +}> { + const abilities: Array<{ bundleName: string; abilityName: string }> = []; + const lines = rawOutput.split('\n'); + let currentBundle = ''; + + for (const line of lines) { + const trimmed = line.trim(); + const bundleMatch = trimmed.match(/bundle\s*[:=]\s*(\S+)/i); + if (bundleMatch) { + currentBundle = bundleMatch[1] ?? ''; + } + const abilityMatch = trimmed.match(/ability\s*[:=]\s*(\S+)/i); + if (abilityMatch && currentBundle) { + abilities.push({ + bundleName: currentBundle, + abilityName: abilityMatch[1] ?? '', + }); + } + } + + return abilities; +} diff --git a/src/platforms/harmonyos/arkui-hierarchy.ts b/src/platforms/harmonyos/arkui-hierarchy.ts new file mode 100644 index 000000000..ec7430eff --- /dev/null +++ b/src/platforms/harmonyos/arkui-hierarchy.ts @@ -0,0 +1,290 @@ +import type { Rect, RawSnapshotNode, SnapshotOptions } from '../../utils/snapshot.ts'; + +export type ArkUiAttributes = { + type?: string; + text?: string; + originalText?: string; + description?: string; + hint?: string; + accessibilityId?: string; + id?: string; + key?: string; + bounds?: string; + clickable?: string; + enabled?: string; + visible?: string; + scrollable?: string; + checkable?: string; + checked?: string; + selected?: string; + focused?: string; + longClickable?: string; + bundleName?: string; + abilityName?: string; + pagePath?: string; + [key: string]: string | undefined; +}; + +export type ArkUiNode = { + attributes: ArkUiAttributes; + children?: ArkUiNode[]; +}; + +export type ArkUiTree = ArkUiNode[]; + +export type ArkUiHierarchyResult = { + nodes: RawSnapshotNode[]; + truncated?: boolean; + rawNodeCount: number; + maxDepth: number; + bundleName?: string; + abilityName?: string; +}; + +export function parseArkUiTree(json: string): ArkUiTree { + const parsed = JSON.parse(json); + // ArkUI dumpLayout can output either an array or a single root object + if (Array.isArray(parsed)) { + return parsed as ArkUiTree; + } + // If it's a single object, wrap it in an array + if (parsed && typeof parsed === 'object' && parsed.attributes) { + return [parsed as ArkUiNode]; + } + throw new Error('ArkUI dumpLayout output must be a JSON array or root object'); +} + +export function buildArkUiSnapshot( + tree: ArkUiTree, + maxNodes: number, + options: SnapshotOptions, +): ArkUiHierarchyResult { + let nodeIndex = 0; + let rawNodeCount = 0; + let maxDepth = 0; + let truncated = false; + let bundleName: string | undefined; + let abilityName: string | undefined; + const interactiveDescendantMemo = new WeakMap(); + + const nodes: RawSnapshotNode[] = []; + + function walk( + node: ArkUiNode, + depth: number, + parentIndex: number | null, + ancestorInteractive: boolean, + ): void { + rawNodeCount++; + if (depth > maxDepth) maxDepth = depth; + + const attrs = node.attributes; + const type = attrs.type ?? ''; + const isRoot = type === 'root' || type === 'WindowScene'; + + // Extract bundle info from root-level nodes + if (attrs.bundleName) bundleName = attrs.bundleName; + if (attrs.abilityName) abilityName = attrs.abilityName; + + // Skip invisible nodes unless in raw mode + if (!options.raw && attrs.visible === 'false' && !isRoot) { + if (node.children) { + for (const child of node.children) { + walk(child, depth + 1, parentIndex, ancestorInteractive); + } + } + return; + } + + // Skip empty root/WindowScene wrapper nodes, walk their children directly + if (isRoot && node.children) { + for (const child of node.children) { + walk(child, depth + 1, parentIndex, ancestorInteractive); + } + return; + } + + const rect = parseArkUiBounds(attrs.bounds ?? ''); + const hittable = attrs.clickable === 'true' || attrs.longClickable === 'true'; + const scrollable = attrs.scrollable === 'true'; + const text = attrs.text || attrs.originalText || ''; + const description = attrs.description || ''; + const label = text || description || attrs.hint || ''; + const identifier = attrs.accessibilityId || attrs.id || attrs.key || ''; + const enabled = attrs.enabled !== 'false'; + const visible = attrs.visible !== 'false'; + const descendantInteractive = hasInteractiveDescendant(node, interactiveDescendantMemo); + + const shouldInclude = shouldIncludeNode( + { + type, + text, + description, + label, + identifier, + hittable, + visible, + scrollable, + ancestorInteractive, + descendantInteractive, + }, + options, + ); + + if (!shouldInclude) { + if (node.children) { + for (const child of node.children) { + walk(child, depth + 1, parentIndex, ancestorInteractive || hittable || scrollable); + } + } + return; + } + + const currentIndex = nodeIndex; + if (nodeIndex >= maxNodes) { + truncated = true; + return; + } + + nodes.push({ + index: currentIndex, + type, + label: label || undefined, + value: text || undefined, + identifier: identifier || undefined, + rect, + enabled, + hittable, + selected: attrs.selected === 'true' || attrs.checked === 'true', + focused: attrs.focused === 'true', + depth, + parentIndex: parentIndex ?? undefined, + bundleId: bundleName, + }); + + nodeIndex++; + + if (node.children) { + for (const child of node.children) { + walk(child, depth + 1, currentIndex, ancestorInteractive || hittable || scrollable); + } + } + } + + const scopeRoot = + options.raw || !options.interactiveOnly + ? null + : findBestFocusedInteractiveRoot(tree, interactiveDescendantMemo); + + const roots = scopeRoot ? [scopeRoot] : tree; + for (const rootNode of roots) { + // If we're scoping to a focused modal subtree, treat it as an interactive context + // so proxy label nodes inside the modal are kept for targeting/verification. + walk(rootNode, 0, null, scopeRoot !== null); + } + + return { + nodes, + truncated: truncated || nodeIndex >= maxNodes, + rawNodeCount, + maxDepth, + bundleName, + abilityName, + }; +} + +function shouldIncludeNode( + node: { + type: string; + text: string; + description: string; + label: string; + identifier: string; + hittable: boolean; + visible: boolean; + scrollable: boolean; + ancestorInteractive: boolean; + descendantInteractive: boolean; + }, + options: SnapshotOptions, +): boolean { + if (options.raw) return true; + + if (options.interactiveOnly) { + if (node.hittable) return true; + if (node.scrollable) return true; + // Keep proxy label/id nodes only when they are close to interactive controls. + if (node.label && node.label.length > 0 && node.ancestorInteractive) return true; + if (node.identifier && node.identifier.length > 0 && node.descendantInteractive) return true; + return false; + } + + if (options.compact) { + if (node.hittable) return true; + if (node.label && node.label.length > 0) return true; + if (node.identifier && node.identifier.length > 0) return true; + return false; + } + + // Default: include everything that is visible + return node.visible; +} + +function findBestFocusedInteractiveRoot( + tree: ArkUiTree, + interactiveDescendantMemo: WeakMap, +): ArkUiNode | null { + let best: { node: ArkUiNode; depth: number } | null = null; + const stack: Array<{ node: ArkUiNode; depth: number }> = tree.map((node) => ({ node, depth: 0 })); + while (stack.length > 0) { + const current = stack.pop(); + if (!current) break; + const { node, depth } = current; + const focused = node.attributes.focused === 'true'; + if (focused && hasInteractiveDescendant(node, interactiveDescendantMemo)) { + if (!best || depth > best.depth) best = { node, depth }; + } + for (const child of node.children ?? []) { + stack.push({ node: child, depth: depth + 1 }); + } + } + return best?.node ?? null; +} + +function hasInteractiveDescendant(node: ArkUiNode, memo: WeakMap): boolean { + const cached = memo.get(node); + if (cached !== undefined) return cached; + const attrs = node.attributes; + const selfInteractive = + attrs.clickable === 'true' || attrs.longClickable === 'true' || attrs.scrollable === 'true'; + if (selfInteractive) { + memo.set(node, true); + return true; + } + for (const child of node.children ?? []) { + if (hasInteractiveDescendant(child, memo)) { + memo.set(node, true); + return true; + } + } + memo.set(node, false); + return false; +} + +const BOUNDS_RE = /^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$/; + +export function parseArkUiBounds(bounds: string): Rect | undefined { + if (!bounds) return undefined; + const match = bounds.match(BOUNDS_RE); + if (!match) return undefined; + const x1 = Number(match[1]); + const y1 = Number(match[2]); + const x2 = Number(match[3]); + const y2 = Number(match[4]); + return { + x: x1, + y: y1, + width: x2 - x1, + height: y2 - y1, + }; +} diff --git a/src/platforms/harmonyos/clipboard.ts b/src/platforms/harmonyos/clipboard.ts new file mode 100644 index 000000000..9b865019f --- /dev/null +++ b/src/platforms/harmonyos/clipboard.ts @@ -0,0 +1,48 @@ +import type { DeviceInfo } from '../../utils/device.ts'; +import { AppError } from '../../utils/errors.ts'; +import { runHarmonyHdc } from './hdc.ts'; + +/** + * Read clipboard text from HarmonyOS device. + * HarmonyOS doesn't have a direct hdc clipboard command. + * This implementation tries multiple approaches. + */ +export async function readHarmonyClipboardText(device: DeviceInfo): Promise { + // Try using uitest clipboard API + const result = await runHarmonyHdc(device, ['shell', 'uitest', 'uiInput', 'getClipboard'], { + allowFailure: true, + timeoutMs: 10_000, + }); + + if (result.exitCode === 0 && result.stdout.trim()) { + return result.stdout.trim(); + } + + // Fallback: return empty string (clipboard access requires app-level implementation) + return ''; +} + +/** + * Write text to HarmonyOS device clipboard. + * HarmonyOS doesn't have a direct hdc clipboard command. + * This implementation tries uitest clipboard API. + */ +export async function writeHarmonyClipboardText(device: DeviceInfo, text: string): Promise { + // Try using uitest clipboard API + const result = await runHarmonyHdc(device, ['shell', 'uitest', 'uiInput', 'setClipboard', text], { + allowFailure: true, + timeoutMs: 10_000, + }); + + if (result.exitCode !== 0) { + // Clipboard operations may require specific permissions or app context + throw new AppError( + 'COMMAND_FAILED', + `Failed to write to HarmonyOS clipboard. Clipboard operations may require app-level implementation.`, + { + stderr: result.stderr, + text: text.slice(0, 100), + }, + ); + } +} diff --git a/src/platforms/harmonyos/devices.ts b/src/platforms/harmonyos/devices.ts new file mode 100644 index 000000000..7c25217ad --- /dev/null +++ b/src/platforms/harmonyos/devices.ts @@ -0,0 +1,80 @@ +import type { DeviceInfo, DeviceTarget } from '../../utils/device.ts'; +import { AppError } from '../../utils/errors.ts'; +import { runCmd } from '../../utils/exec.ts'; +import { harmonyDeviceForSerial } from './hdc.ts'; + +export type HarmonyDeviceDiscoveryOptions = { + signal?: AbortSignal; + timeoutMs?: number; +}; + +export async function listHarmonyDevices( + options?: HarmonyDeviceDiscoveryOptions, +): Promise { + const result = await runCmd('hdc', ['list', 'targets'], { + allowFailure: true, + signal: options?.signal, + timeoutMs: options?.timeoutMs ?? 30_000, + }); + + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', `hdc list targets failed: ${result.stderr}`); + } + + const serials = parseHarmonyDeviceList(result.stdout); + const devices: DeviceInfo[] = []; + + for (const serial of serials) { + const device = await probeHarmonyDevice(serial); + devices.push(device); + } + + return devices; +} + +export function parseHarmonyDeviceList(rawOutput: string): string[] { + return rawOutput + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && line !== '[Empty]'); +} + +async function probeHarmonyDevice(serial: string): Promise { + let target: DeviceTarget | undefined; + + try { + const typeResult = await runCmd( + 'hdc', + ['-t', serial, 'shell', 'param', 'get', 'const.build.characteristics'], + { allowFailure: true, timeoutMs: 10_000 }, + ); + if (typeResult.exitCode === 0) { + const characteristics = typeResult.stdout.trim().toLowerCase(); + if (characteristics.includes('tv')) { + target = 'tv'; + } + } + } catch { + // Default to mobile if probe fails + } + + return { + ...harmonyDeviceForSerial(serial), + // Keep hdc target serial as the canonical selector (matches `hdc list targets`). + name: serial, + target: target ?? 'mobile', + }; +} + +export async function isHarmonyDeviceBooted(serial: string): Promise { + try { + const result = await runCmd( + 'hdc', + ['-t', serial, 'shell', 'param', 'get', 'const.sys.boot_completed'], + { allowFailure: true, timeoutMs: 5_000 }, + ); + return result.exitCode === 0 && result.stdout.trim() === 'true'; + } catch { + return false; + } +} diff --git a/src/platforms/harmonyos/hdc-executor.ts b/src/platforms/harmonyos/hdc-executor.ts new file mode 100644 index 000000000..9408f55c9 --- /dev/null +++ b/src/platforms/harmonyos/hdc-executor.ts @@ -0,0 +1,124 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { DeviceInfo } from '../../utils/device.ts'; +import { + runCmd, + withCommandExecutorOverride, + withoutCommandExecutorOverride, + type CommandExecutorOverride, + type ExecOptions, + type ExecResult, +} from '../../utils/exec.ts'; + +export type HarmonyHdcExecutorOptions = Pick< + ExecOptions, + 'allowFailure' | 'timeoutMs' | 'binaryStdout' | 'stdin' | 'signal' +>; + +export type HarmonyHdcExecutorResult = Pick< + ExecResult, + 'exitCode' | 'stdout' | 'stderr' | 'stdoutBuffer' +>; + +/** + * Runs device-scoped hdc arguments after the device serial has been selected. + */ +export type HarmonyHdcExecutor = ( + args: string[], + options?: HarmonyHdcExecutorOptions, +) => Promise; + +export type HarmonyHdcProvider = { + exec: HarmonyHdcExecutor; +}; + +export type HarmonyHdcProviderScopeOptions = { + serial: string; +}; + +type HarmonyHdcProviderScope = { + provider: HarmonyHdcProvider; + serial: string; +}; + +const harmonyHdcProviderScope = new AsyncLocalStorage(); + +export function createDeviceHdcExecutor(device: DeviceInfo): HarmonyHdcExecutor { + return createSerialHdcExecutor(device.id); +} + +function createSerialHdcExecutor(serial: string): HarmonyHdcExecutor { + return async (args, options) => + await withoutCommandExecutorOverride( + async () => await runCmd('hdc', ['-t', serial, ...args], options), + ); +} + +export function createLocalHarmonyHdcProvider(device: DeviceInfo): HarmonyHdcProvider { + return { + exec: createDeviceHdcExecutor(device), + }; +} + +export function resolveHarmonyHdcExecutor( + device: DeviceInfo, + executor?: HarmonyHdcExecutor, +): HarmonyHdcExecutor { + const scoped = harmonyHdcProviderScope.getStore(); + if (executor) return executor; + if (scoped?.serial === device.id) return scoped.provider.exec; + return createDeviceHdcExecutor(device); +} + +export function resolveHarmonyHdcProvider( + device: DeviceInfo, + provider?: HarmonyHdcProvider | HarmonyHdcExecutor, +): HarmonyHdcProvider { + if (provider) return normalizeHarmonyHdcProvider(provider); + const scoped = harmonyHdcProviderScope.getStore(); + return scoped?.serial === device.id + ? normalizeHarmonyHdcProvider(scoped.provider) + : createLocalHarmonyHdcProvider(device); +} + +export async function withHarmonyHdcProvider( + provider: HarmonyHdcProvider | HarmonyHdcExecutor | undefined, + options: HarmonyHdcProviderScopeOptions, + fn: () => Promise, +): Promise { + if (!provider) return await fn(); + const normalized = typeof provider === 'function' ? { exec: provider } : provider; + const scope = { provider: normalized, serial: options.serial }; + const override = createHarmonyCommandExecutorOverride(scope); + return await harmonyHdcProviderScope.run( + scope, + async () => await withCommandExecutorOverride(override, fn), + ); +} + +function createHarmonyCommandExecutorOverride( + scope: HarmonyHdcProviderScope, +): CommandExecutorOverride { + return (cmd, args, options) => { + if (cmd !== 'hdc') return undefined; + const providerArgs = stripHdcSerialArgs(args, scope.serial); + if (!providerArgs) return undefined; + return withoutCommandExecutorOverride( + async () => await scope.provider.exec(providerArgs, options), + ); + }; +} + +function stripHdcSerialArgs(args: string[], expectedSerial: string): string[] | undefined { + if (args[0] !== '-t' || !args[1]) return undefined; + if (args[1] !== expectedSerial) return undefined; + return args.slice(2); +} + +function normalizeHarmonyHdcProvider( + provider: HarmonyHdcProvider | HarmonyHdcExecutor, +): HarmonyHdcProvider { + if (typeof provider === 'function') { + return { exec: provider }; + } + return provider; +} diff --git a/src/platforms/harmonyos/hdc.ts b/src/platforms/harmonyos/hdc.ts new file mode 100644 index 000000000..8c03b08cb --- /dev/null +++ b/src/platforms/harmonyos/hdc.ts @@ -0,0 +1,27 @@ +import { whichCmd } from '../../utils/exec.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; +import { resolveHarmonyHdcExecutor } from './hdc-executor.ts'; + +export async function runHarmonyHdc( + device: DeviceInfo, + args: string[], + options?: import('./hdc-executor.ts').HarmonyHdcExecutorOptions, +): Promise { + return await resolveHarmonyHdcExecutor(device)(args, options); +} + +export function harmonyDeviceForSerial(serial: string): DeviceInfo { + return { + platform: 'harmonyos', + id: serial, + name: serial, + kind: 'device', + booted: true, + }; +} + +export async function ensureHdc(): Promise { + const hdcAvailable = await whichCmd('hdc'); + if (!hdcAvailable) throw new AppError('TOOL_MISSING', 'hdc not found in PATH'); +} diff --git a/src/platforms/harmonyos/input-actions.ts b/src/platforms/harmonyos/input-actions.ts new file mode 100644 index 000000000..28044249d --- /dev/null +++ b/src/platforms/harmonyos/input-actions.ts @@ -0,0 +1,362 @@ +import type { DeviceInfo } from '../../utils/device.ts'; +import type { Point, Rect } from '../../utils/snapshot.ts'; +import { runHarmonyHdc } from './hdc.ts'; +import { buildScrollGesturePlan, type ScrollDirection } from '../../core/scroll-gesture.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { DeviceRotation } from '../../core/device-rotation.ts'; + +export async function pressHarmony(device: DeviceInfo, x: number, y: number): Promise { + await runHarmonyHdc(device, ['shell', 'uitest', 'uiInput', 'click', String(x), String(y)]); +} + +export async function doubleTapHarmony(device: DeviceInfo, x: number, y: number): Promise { + // Use uitest native doubleClick command + await runHarmonyHdc(device, ['shell', 'uitest', 'uiInput', 'doubleClick', String(x), String(y)]); +} + +export async function rotateHarmony(device: DeviceInfo, orientation: DeviceRotation): Promise { + // HarmonyOS rotation via hidumper setting to DisplayManagerService + // Orientation values: 0 = portrait, 1 = landscape-left, 2 = portrait-upside-down, 3 = landscape-right + const rotationValue = mapRotationToValue(orientation); + + // Try using hidumper to set rotation + const result = await runHarmonyHdc( + device, + ['shell', 'hidumper', '-s', 'DisplayManagerService', '-a', `SetScreenRotation ${rotationValue}`], + { allowFailure: true, timeoutMs: 10_000 }, + ); + + if (result.exitCode !== 0 || result.stderr.includes('error')) { + // Fallback: try param set + const paramResult = await runHarmonyHdc( + device, + ['shell', 'param', 'set', 'persist.sys.display.orientation', String(rotationValue)], + { allowFailure: true, timeoutMs: 10_000 }, + ); + + if (paramResult.exitCode !== 0) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'HarmonyOS screen rotation requires system-level permissions. Rotation control is not available via HDC.', + ); + } + } +} + +function mapRotationToValue(orientation: DeviceRotation): number { + switch (orientation) { + case 'portrait': + return 0; + case 'landscape-left': + return 1; + case 'portrait-upside-down': + return 2; + case 'landscape-right': + return 3; + default: + return 0; + } +} + +export async function getHarmonyKeyboardState( + device: DeviceInfo, +): Promise<{ visible: boolean; height?: number }> { + // Check keyboard visibility via WindowManagerService dump + const result = await runHarmonyHdc( + device, + ['shell', 'hidumper', '-s', 'WindowManagerService', '-a', '-a'], + { allowFailure: true, timeoutMs: 10_000 }, + ); + + if (result.exitCode !== 0) { + return { visible: false }; + } + + // Look for softKeyboard window with non-zero height + const lines = result.stdout.split('\n'); + for (const line of lines) { + if (line.includes('softKeyboard') || line.includes('Keyboard')) { + // Parse the window rect [ x y w h ] + const rectMatch = line.match(/\[\s*\d+\s+\d+\s+\d+\s+(\d+)\s*\]/); + if (rectMatch) { + const height = Number(rectMatch[1]); + if (height > 0) { + return { visible: true, height }; + } + } + } + } + + return { visible: false }; +} + +export async function dismissHarmonyKeyboard(device: DeviceInfo): Promise { + // Dismiss keyboard by pressing Back key + await pressBackHarmony(device); +} + +export async function longPressHarmony( + device: DeviceInfo, + x: number, + y: number, + durationMs: number = 1000, +): Promise { + // Long press = swipe to same point with duration + await runHarmonyHdc(device, [ + 'shell', + 'uitest', + 'uiInput', + 'swipe', + String(x), + String(y), + String(x), + String(y), + String(durationMs), + ]); +} + +export async function swipeHarmony( + device: DeviceInfo, + from: Point, + to: Point, + durationMs?: number, +): Promise { + const args = [ + 'shell', + 'uitest', + 'uiInput', + 'swipe', + String(from.x), + String(from.y), + String(to.x), + String(to.y), + ]; + if (durationMs !== undefined) { + args.push(String(durationMs)); + } + await runHarmonyHdc(device, args); +} + +export async function typeHarmony(device: DeviceInfo, text: string): Promise { + await runHarmonyHdc(device, ['shell', 'uitest', 'uiInput', 'text', text]); +} + +export async function fillHarmony( + device: DeviceInfo, + point: Point, + text: string, + delayMs: number = 100, +): Promise { + const attempts = [ + { clearCount: 20, chunkSize: 50 }, + { clearCount: 40, chunkSize: 25 }, + ] as const; + + for (let attemptIdx = 0; attemptIdx < attempts.length; attemptIdx++) { + const attempt = attempts[attemptIdx]!; + const clearCount = attempt.clearCount; + const chunkSize = attempt.chunkSize; + + // Focus the target + await pressHarmony(device, point.x, point.y); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + + // Clear existing text + await clearFocusedTextHarmony(device, clearCount); + + // Type in chunks if needed + if (text.length <= chunkSize) { + await typeHarmony(device, text); + } else { + for (let offset = 0; offset < text.length; offset += chunkSize) { + await typeHarmony(device, text.slice(offset, offset + chunkSize)); + await new Promise((resolve) => setTimeout(resolve, 30)); + } + } + + // Verify + const verification = await verifyHarmonyFilledText(device, point, text); + if (verification.ok) return; + + if (attemptIdx === attempts.length - 1) { + throw new AppError('COMMAND_FAILED', `Fill verification failed on HarmonyOS. Expected "${text}", got "${verification.actualText}".`, { + failureReason: 'fill_verification', + expectedText: text, + actualText: verification.actualText, + }); + } + } +} + +async function clearFocusedTextHarmony(device: DeviceInfo, count: number): Promise { + // Select all then delete + await runHarmonyHdc(device, ['shell', 'uitest', 'uiInput', 'keyEvent', 'Ctrl+A'], { + allowFailure: true, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + // Send delete keys to clear selected text + for (let i = 0; i < Math.ceil(count / 10); i++) { + await runHarmonyHdc( + device, + ['shell', 'uitest', 'uiInput', 'keyEvent', 'Delete'], + { allowFailure: true }, + ); + } + await new Promise((resolve) => setTimeout(resolve, 50)); +} + +async function verifyHarmonyFilledText( + device: DeviceInfo, + point: Point, + expectedText: string, +): Promise<{ ok: boolean; actualText: string }> { + const delays = [0, 150, 300]; + for (const delay of delays) { + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + const actualText = await readHarmonyTextAtPoint(device, point.x, point.y); + if (actualText === expectedText) { + return { ok: true, actualText }; + } + // Whitespace-normalized comparison + const normalizedActual = actualText.replace(/\s+/g, ' ').trim(); + const normalizedExpected = expectedText.replace(/\s+/g, ' ').trim(); + if (normalizedActual === normalizedExpected) { + return { ok: true, actualText }; + } + } + const actualText = await readHarmonyTextAtPoint(device, point.x, point.y); + return { ok: false, actualText }; +} + +export async function scrollHarmony( + device: DeviceInfo, + direction: ScrollDirection, + options?: { amount?: number; pixels?: number }, +): Promise> { + const size = await getHarmonyScreenSize(device); + const plan = buildScrollGesturePlan({ + direction, + amount: options?.amount, + pixels: options?.pixels, + referenceWidth: size.width, + referenceHeight: size.height, + }); + + await swipeHarmony(device, { x: plan.x1, y: plan.y1 }, { x: plan.x2, y: plan.y2 }, 300); + return plan; +} + +export async function getHarmonyScreenSize( + device: DeviceInfo, +): Promise<{ width: number; height: number }> { + // Method 1: Try to get display resolution via param get + const paramResult = await runHarmonyHdc( + device, + ['shell', 'param', 'get', 'const.display.resolution'], + { allowFailure: true, timeoutMs: 10_000 }, + ); + + if (paramResult.exitCode === 0 && paramResult.stdout.trim()) { + const match = paramResult.stdout.match(/(\d+)\s*[x*]\s*(\d+)/); + if (match) { + return { width: Number(match[1]), height: Number(match[2]) }; + } + } + + // Method 2: Try getWindowInfo for screen dimensions + const windowResult = await runHarmonyHdc( + device, + ['shell', 'uitest', 'getWindowInfo'], + { allowFailure: true, timeoutMs: 10_000 }, + ); + + if (windowResult.exitCode === 0 && windowResult.stdout.trim()) { + try { + const info = JSON.parse(windowResult.stdout); + if (info.width && info.height) { + return { width: info.width, height: info.height }; + } + // Some versions may have windowRect + if (info.windowRect) { + const rect = info.windowRect; + return { width: rect.right - rect.left, height: rect.bottom - rect.top }; + } + } catch { + // JSON parse failed, continue to next method + } + } + + // Method 3: Parse from snapshot dumpLayout root bounds + const { snapshotHarmony } = await import('./snapshot.ts'); + const snapshot = await snapshotHarmony(device, { raw: true, maxNodes: 1 }); + + // Find the root node with the largest bounds (typically full screen) + let maxWidth = 0; + let maxHeight = 0; + for (const node of snapshot.nodes ?? []) { + const rect = node.rect; + if (rect && rect.width > maxWidth && rect.height > maxHeight) { + maxWidth = rect.width; + maxHeight = rect.height; + } + } + + if (maxWidth > 0 && maxHeight > 0) { + return { width: maxWidth, height: maxHeight }; + } + + throw new AppError( + 'COMMAND_FAILED', + 'Unable to read HarmonyOS screen size. Please ensure device is connected and accessible.', + ); +} + +export async function pressBackHarmony(device: DeviceInfo): Promise { + await runHarmonyHdc(device, ['shell', 'uitest', 'uiInput', 'keyEvent', 'Back']); +} + +export async function pressHomeHarmony(device: DeviceInfo): Promise { + await runHarmonyHdc(device, ['shell', 'uitest', 'uiInput', 'keyEvent', 'Home']); +} + +export async function pressKeyHarmony(device: DeviceInfo, key: string): Promise { + await runHarmonyHdc(device, ['shell', 'uitest', 'uiInput', 'keyEvent', key]); +} + +export async function focusHarmony(device: DeviceInfo, x: number, y: number): Promise { + await pressHarmony(device, x, y); +} + +export async function readHarmonyTextAtPoint( + device: DeviceInfo, + x: number, + y: number, +): Promise { + const { snapshotHarmony } = await import('./snapshot.ts'); + const result = await snapshotHarmony(device, { raw: true, maxNodes: 500 }); + + let bestMatch: { text: string; area: number } | null = null; + + for (const node of result.nodes) { + const rect = node.rect; + if (!rect) continue; + if (!pointInRect(x, y, rect)) continue; + + const text = node.value ?? ''; + if (!text) continue; + + const area = rect.width * rect.height; + // Prefer focused nodes, then smallest containing node with text + if (!bestMatch || (node.focused && !bestMatch) || area < bestMatch.area) { + bestMatch = { text, area }; + } + } + + return bestMatch?.text ?? ''; +} + +function pointInRect(px: number, py: number, rect: Rect): boolean { + return px >= rect.x && px <= rect.x + rect.width && py >= rect.y && py <= rect.y + rect.height; +} diff --git a/src/platforms/harmonyos/launch-abilities.ts b/src/platforms/harmonyos/launch-abilities.ts new file mode 100644 index 000000000..0e702fd5f --- /dev/null +++ b/src/platforms/harmonyos/launch-abilities.ts @@ -0,0 +1,51 @@ +import type { DeviceInfo } from '../../utils/device.ts'; +import { parseWukongAppInfo } from './app-parsers.ts'; +import { runHarmonyHdc } from './hdc.ts'; + +const LAUNCH_ABILITY_CACHE_TTL_MS = 5 * 60 * 1000; + +type LaunchAbilityCacheEntry = { + fetchedAt: number; + abilities: Map; +}; + +const launchAbilityCache = new Map(); + +export function clearHarmonyLaunchAbilityCache(deviceId?: string): void { + if (deviceId) { + launchAbilityCache.delete(deviceId); + return; + } + launchAbilityCache.clear(); +} + +export async function getHarmonyLaunchAbilities(device: DeviceInfo): Promise> { + const cached = launchAbilityCache.get(device.id); + if (cached && Date.now() - cached.fetchedAt < LAUNCH_ABILITY_CACHE_TTL_MS) { + return cached.abilities; + } + + const result = await runHarmonyHdc(device, ['shell', 'wukong', 'appinfo'], { + allowFailure: true, + timeoutMs: 15_000, + }); + + if (result.exitCode !== 0) { + return cached?.abilities ?? new Map(); + } + + const abilities = parseWukongAppInfo(result.stdout); + launchAbilityCache.set(device.id, { + fetchedAt: Date.now(), + abilities, + }); + return abilities; +} + +export async function lookupHarmonyLaunchAbility( + device: DeviceInfo, + bundleName: string, +): Promise { + const abilities = await getHarmonyLaunchAbilities(device); + return abilities.get(bundleName) ?? null; +} diff --git a/src/platforms/harmonyos/logcat.ts b/src/platforms/harmonyos/logcat.ts new file mode 100644 index 000000000..d7f9367fc --- /dev/null +++ b/src/platforms/harmonyos/logcat.ts @@ -0,0 +1,43 @@ +import type { DeviceInfo } from '../../utils/device.ts'; +import { runHarmonyHdc } from './hdc.ts'; + +export type HarmonyLogOptions = { + level?: 'D' | 'I' | 'W' | 'E' | 'F'; + tag?: string; + clear?: boolean; + lines?: number; + signal?: AbortSignal; +}; + +export async function readHarmonyLogs( + device: DeviceInfo, + options?: HarmonyLogOptions, +): Promise { + if (options?.clear) { + await runHarmonyHdc(device, ['shell', 'hilog', '-c'], { allowFailure: true }); + } + + const args = ['shell', 'hilog']; + + if (options?.lines) { + args.push('-n', String(options.lines)); + } + if (options?.level) { + args.push('-L', options.level); + } + if (options?.tag) { + args.push('-t', options.tag); + } + + const result = await runHarmonyHdc(device, args, { + allowFailure: false, + timeoutMs: 10_000, + signal: options?.signal, + }); + + return result.stdout; +} + +export async function clearHarmonyLogs(device: DeviceInfo): Promise { + await runHarmonyHdc(device, ['shell', 'hilog', '-c'], { allowFailure: true }); +} diff --git a/src/platforms/harmonyos/notifications.ts b/src/platforms/harmonyos/notifications.ts new file mode 100644 index 000000000..be9db11b3 --- /dev/null +++ b/src/platforms/harmonyos/notifications.ts @@ -0,0 +1,69 @@ +import { AppError } from '../../utils/errors.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; +import { runHarmonyHdc } from './hdc.ts'; + +type HarmonyBroadcastPayload = { + action?: string; + ability?: string; + extras?: Record; +}; + +export async function pushHarmonyNotification( + device: DeviceInfo, + bundleName: string, + payload: HarmonyBroadcastPayload, +): Promise<{ action: string; extrasCount: number }> { + const action = + typeof payload.action === 'string' && payload.action.trim() + ? payload.action.trim() + : `${bundleName}.TEST_PUSH`; + + const args = ['shell', 'aa', 'send', '-a', action, '-b', bundleName]; + + const ability = typeof payload.ability === 'string' ? payload.ability.trim() : ''; + if (ability) { + args.push('--ability', ability); + } + + const rawExtras = payload.extras; + if ( + rawExtras !== undefined && + (typeof rawExtras !== 'object' || rawExtras === null || Array.isArray(rawExtras)) + ) { + throw new AppError('INVALID_ARGS', 'HarmonyOS push payload extras must be an object'); + } + + const extras = rawExtras ?? {}; + let extrasCount = 0; + for (const [key, rawValue] of Object.entries(extras)) { + if (!key) continue; + appendBroadcastExtra(args, key, rawValue); + extrasCount += 1; + } + + await runHarmonyHdc(device, args); + return { action, extrasCount }; +} + +function appendBroadcastExtra(args: string[], key: string, value: unknown): void { + if (typeof value === 'string') { + args.push('--es', key, value); + return; + } + if (typeof value === 'boolean') { + args.push('--ez', key, value ? 'true' : 'false'); + return; + } + if (typeof value === 'number' && Number.isFinite(value)) { + if (Number.isInteger(value)) { + args.push('--ei', key, String(value)); + return; + } + args.push('--ef', key, String(value)); + return; + } + throw new AppError( + 'INVALID_ARGS', + `Unsupported HarmonyOS broadcast extra type for "${key}". Use string, boolean, or number.`, + ); +} diff --git a/src/platforms/harmonyos/perf.ts b/src/platforms/harmonyos/perf.ts new file mode 100644 index 000000000..03d7ce212 --- /dev/null +++ b/src/platforms/harmonyos/perf.ts @@ -0,0 +1,51 @@ +import type { DeviceInfo } from '../../utils/device.ts'; +import { runHarmonyHdc } from './hdc.ts'; + +export type HarmonyPerfResult = { + cpu?: string; + memory?: string; + processes?: string; + raw?: Record; +}; + +export async function measureHarmonyPerf(device: DeviceInfo): Promise { + const result: HarmonyPerfResult = { raw: {} }; + + // CPU info + try { + const cpuResult = await runHarmonyHdc(device, ['shell', 'hidumper', '-c', 'cpudump'], { + allowFailure: true, + timeoutMs: 10_000, + }); + result.cpu = cpuResult.stdout; + result.raw!.cpu = cpuResult.stdout; + } catch { + // CPU dump not available + } + + // Memory info + try { + const memResult = await runHarmonyHdc(device, ['shell', 'hidumper', '-m'], { + allowFailure: true, + timeoutMs: 10_000, + }); + result.memory = memResult.stdout; + result.raw!.memory = memResult.stdout; + } catch { + // Memory dump not available + } + + // Process list + try { + const psResult = await runHarmonyHdc(device, ['shell', 'ps', '-ef'], { + allowFailure: true, + timeoutMs: 10_000, + }); + result.processes = psResult.stdout; + result.raw!.processes = psResult.stdout; + } catch { + // Process list not available + } + + return result; +} diff --git a/src/platforms/harmonyos/recording.ts b/src/platforms/harmonyos/recording.ts new file mode 100644 index 000000000..0a54521ed --- /dev/null +++ b/src/platforms/harmonyos/recording.ts @@ -0,0 +1,36 @@ +import type { DeviceInfo } from '../../utils/device.ts'; +import { AppError } from '../../utils/errors.ts'; +import { runHarmonyHdc } from './hdc.ts'; + +export async function startHarmonyRecording(device: DeviceInfo, remotePath: string): Promise { + // HarmonyOS screen recording via hdc shell screenrecord + // This command may not be available on all devices + await runHarmonyHdc(device, ['shell', 'screenrecord', '--output', remotePath], { + allowFailure: true, + timeoutMs: 5_000, + }); +} + +export async function stopHarmonyRecording( + device: DeviceInfo, + remotePath: string, + localPath: string, +): Promise { + // Stop recording (kill the screenrecord process) + await runHarmonyHdc(device, ['shell', 'aa', 'force-stop', 'com.ohos.screenrecorder'], { + allowFailure: true, + }); + + // Pull the recording file + try { + await runHarmonyHdc(device, ['file', 'recv', remotePath, localPath], { + allowFailure: false, + timeoutMs: 30_000, + }); + } catch { + throw new AppError('COMMAND_FAILED', 'Failed to retrieve recording file'); + } + + // Cleanup remote + await runHarmonyHdc(device, ['shell', 'rm', '-f', remotePath], { allowFailure: true }); +} diff --git a/src/platforms/harmonyos/screenshot.ts b/src/platforms/harmonyos/screenshot.ts new file mode 100644 index 000000000..29471abe3 --- /dev/null +++ b/src/platforms/harmonyos/screenshot.ts @@ -0,0 +1,38 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import type { DeviceInfo } from '../../utils/device.ts'; +import { AppError } from '../../utils/errors.ts'; +import { runHarmonyHdc } from './hdc.ts'; + +export async function screenshotHarmony(device: DeviceInfo, outPath: string): Promise { + const uuid = randomUUID(); + const remotePath = `/data/local/tmp/hd-screen-${uuid}.jpeg`; + + // Step 1: Capture screenshot on device + const captureResult = await runHarmonyHdc( + device, + ['shell', 'snapshot_display', '-f', remotePath], + { allowFailure: false, timeoutMs: 15_000 }, + ); + + if (captureResult.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', `snapshot_display failed: ${captureResult.stderr}`); + } + + // Step 2: Pull to local + await runHarmonyHdc(device, ['file', 'recv', remotePath, outPath], { + allowFailure: false, + timeoutMs: 15_000, + }); + + // Step 3: Cleanup remote file + await runHarmonyHdc(device, ['shell', 'rm', '-f', remotePath], { + allowFailure: true, + }); + + // Verify JPEG signature + const buffer = await fs.readFile(outPath); + if (buffer.length < 3 || buffer[0] !== 0xff || buffer[1] !== 0xd8 || buffer[2] !== 0xff) { + throw new AppError('COMMAND_FAILED', 'Screenshot data is not a valid JPEG'); + } +} diff --git a/src/platforms/harmonyos/settings.ts b/src/platforms/harmonyos/settings.ts new file mode 100644 index 000000000..681a526f3 --- /dev/null +++ b/src/platforms/harmonyos/settings.ts @@ -0,0 +1,231 @@ +import { AppError } from '../../utils/errors.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; +import { + parsePermissionAction, + parsePermissionTarget, + type SettingOptions, +} from '../permission-utils.ts'; +import { parseAppearanceAction } from '../appearance.ts'; +import { parseSettingState } from '../setting-state.ts'; +import { runHarmonyHdc } from './hdc.ts'; +import { clearHarmonyAppStorage } from './app-lifecycle.ts'; + +export async function setHarmonySetting( + device: DeviceInfo, + setting: string, + state: string, + appPackage?: string, + options?: SettingOptions, +): Promise | void> { + const normalized = setting.toLowerCase(); + switch (normalized) { + case 'wifi': { + const enabled = parseSettingState(state); + // HarmonyOS uses param to control network settings + await runHarmonyHdc(device, [ + 'shell', + 'param', + 'set', + 'persist.sys.wifi.enabled', + enabled ? 'true' : 'false', + ]); + return; + } + case 'airplane': { + const enabled = parseSettingState(state); + await runHarmonyHdc(device, [ + 'shell', + 'param', + 'set', + 'persist.sys.airplane_mode', + enabled ? 'true' : 'false', + ]); + return; + } + case 'location': { + const enabled = parseSettingState(state); + await runHarmonyHdc(device, [ + 'shell', + 'param', + 'set', + 'persist.sys.location.enabled', + enabled ? 'true' : 'false', + ]); + return; + } + case 'animations': { + const enabled = parseSettingState(state); + const scale = enabled ? '1' : '0'; + await runHarmonyHdc(device, ['shell', 'param', 'set', 'persist.sys.animation.scale', scale]); + return { scale }; + } + case 'appearance': { + const target = await resolveHarmonyAppearanceTarget(device, state); + await runHarmonyHdc(device, ['shell', 'param', 'set', 'persist.sys.appearance.mode', target]); + return; + } + case 'permission': { + if (!appPackage) { + throw new AppError('INVALID_ARGS', 'permission setting requires an active app in session'); + } + const action = parsePermissionAction(state); + const target = parseHarmonyPermissionTarget(options?.permissionTarget); + await setHarmonyPermission(device, appPackage, action, target); + return; + } + case 'bluetooth': { + const enabled = parseSettingState(state); + await runHarmonyHdc(device, [ + 'shell', + 'param', + 'set', + 'persist.sys.bluetooth.enabled', + enabled ? 'true' : 'false', + ]); + return; + } + case 'volume': { + const level = Math.max(0, Math.min(100, Number.parseInt(state, 10))); + if (!Number.isFinite(level)) { + throw new AppError('INVALID_ARGS', `Invalid volume level: ${state}. Use 0-100.`); + } + await runHarmonyHdc(device, [ + 'shell', + 'param', + 'set', + 'persist.sys.volume.media', + String(level), + ]); + return { level }; + } + case 'brightness': { + const level = Math.max(0, Math.min(100, Number.parseInt(state, 10))); + if (!Number.isFinite(level)) { + throw new AppError('INVALID_ARGS', `Invalid brightness level: ${state}. Use 0-100.`); + } + await runHarmonyHdc(device, [ + 'shell', + 'param', + 'set', + 'persist.sys.brightness', + String(level), + ]); + return { level }; + } + case 'clear-app-state': { + if (state.toLowerCase() !== 'clear') { + throw new AppError('INVALID_ARGS', 'settings clear-app-state only supports clear.'); + } + if (!appPackage) { + throw new AppError( + 'INVALID_ARGS', + 'settings clear-app-state requires an app id or an active app session.', + ); + } + const result = await clearHarmonyAppStorage(device, appPackage); + return { bundleId: result.bundleId, clearedData: result.clearedData, clearedCache: result.clearedCache }; + } + default: + throw new AppError('INVALID_ARGS', `Unsupported HarmonyOS setting: ${setting}`); + } +} + +async function resolveHarmonyAppearanceTarget(device: DeviceInfo, state: string): Promise { + const action = parseAppearanceAction(state); + if (action !== 'toggle') return action; + + const currentResult = await runHarmonyHdc( + device, + ['shell', 'param', 'get', 'persist.sys.appearance.mode'], + { allowFailure: true }, + ); + + if (currentResult.exitCode !== 0) { + // Default to dark if we can't read current state + return 'dark'; + } + + const current = currentResult.stdout.trim().toLowerCase(); + return current === 'dark' ? 'light' : 'dark'; +} + +function parseHarmonyPermissionTarget(permissionTarget: string | undefined): string { + const normalized = parsePermissionTarget(permissionTarget); + // HarmonyOS permission names differ from Android + const harmonyPermissions: Record = { + camera: 'ohos.permission.CAMERA', + microphone: 'ohos.permission.MICROPHONE', + photos: 'ohos.permission.READ_MEDIA', + contacts: 'ohos.permission.READ_CONTACTS', + notifications: 'ohos.permission.NOTIFICATION_CONTROLLER', + }; + + const permission = harmonyPermissions[normalized]; + if (!permission) { + throw new AppError( + 'INVALID_ARGS', + `Unsupported permission target on HarmonyOS: ${permissionTarget}. Use camera|microphone|photos|contacts|notifications.`, + ); + } + return permission; +} + +async function setHarmonyPermission( + device: DeviceInfo, + appPackage: string, + action: 'grant' | 'deny' | 'reset', + permission: string, +): Promise { + // HarmonyOS permission management uses different commands than Android + // This is a simplified implementation - actual HarmonyOS may need specific APIs + if (action === 'reset') { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'HarmonyOS permission reset is not yet implemented', + ); + } + + // Try using bm tool for permission management (if available) + const result = await runHarmonyHdc( + device, + ['shell', 'bm', 'grant-permission', '-n', appPackage, '-p', permission], + { allowFailure: true }, + ); + + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', `Failed to set HarmonyOS permission: ${result.stderr}`, { + appPackage, + permission, + action, + }); + } +} + +export async function getHarmonySetting(device: DeviceInfo, setting: string): Promise { + const normalized = setting.toLowerCase(); + const paramMap: Record = { + wifi: 'persist.sys.wifi.enabled', + airplane: 'persist.sys.airplane_mode', + location: 'persist.sys.location.enabled', + animations: 'persist.sys.animation.scale', + appearance: 'persist.sys.appearance.mode', + bluetooth: 'persist.sys.bluetooth.enabled', + volume: 'persist.sys.volume.media', + brightness: 'persist.sys.brightness', + }; + + const paramKey = paramMap[normalized]; + if (!paramKey) { + throw new AppError('INVALID_ARGS', `Unsupported HarmonyOS setting: ${setting}`); + } + + const result = await runHarmonyHdc(device, ['shell', 'param', 'get', paramKey], { + allowFailure: true, + }); + + if (result.exitCode !== 0) { + return ''; + } + + return result.stdout.trim(); +} diff --git a/src/platforms/harmonyos/snapshot.ts b/src/platforms/harmonyos/snapshot.ts new file mode 100644 index 000000000..ced1762c0 --- /dev/null +++ b/src/platforms/harmonyos/snapshot.ts @@ -0,0 +1,75 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import type { DeviceInfo } from '../../utils/device.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { SnapshotOptions } from '../../utils/snapshot.ts'; +import { attachRefs, type SnapshotNode } from '../../utils/snapshot.ts'; +import { runHarmonyHdc } from './hdc.ts'; +import { + parseArkUiTree, + buildArkUiSnapshot, + type ArkUiHierarchyResult, +} from './arkui-hierarchy.ts'; +import { ensureHarmonyUitestReady } from './uitest-preflight.ts'; + +const DEFAULT_MAX_NODES = 5000; + +export type HarmonySnapshotResult = ArkUiHierarchyResult & { + nodes: SnapshotNode[]; + backend: 'harmonyos-arkui'; +}; + +export async function snapshotHarmony( + device: DeviceInfo, + options: SnapshotOptions & { maxNodes?: number }, +): Promise { + const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES; + const uuid = randomUUID(); + const remotePath = `/data/local/tmp/hd-layout-${uuid}.json`; + const localDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hd-snapshot-')); + const localPath = path.join(localDir, `layout-${uuid}.json`); + + try { + await ensureHarmonyUitestReady(device); + + // Step 1: Dump layout on device + const dumpResult = await runHarmonyHdc( + device, + ['shell', 'uitest', 'dumpLayout', '-p', remotePath], + { allowFailure: false, timeoutMs: 30_000 }, + ); + + if (dumpResult.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', `uitest dumpLayout failed: ${dumpResult.stderr}`); + } + + // Step 2: Pull file to local + await runHarmonyHdc(device, ['file', 'recv', remotePath, localPath], { + allowFailure: false, + timeoutMs: 15_000, + }); + + // Step 3: Cleanup remote file + await runHarmonyHdc(device, ['shell', 'rm', '-f', remotePath], { + allowFailure: true, + }); + + // Step 4: Parse the JSON file + const jsonContent = await fs.readFile(localPath, 'utf-8'); + const tree = parseArkUiTree(jsonContent); + const result = buildArkUiSnapshot(tree, maxNodes, options); + + // Step 5: Attach refs (@e1, @e2, etc.) + const nodesWithRefs = attachRefs(result.nodes); + + return { + ...result, + nodes: nodesWithRefs, + backend: 'harmonyos-arkui', + }; + } finally { + await fs.rm(localDir, { recursive: true, force: true }); + } +} diff --git a/src/platforms/harmonyos/uitest-preflight.ts b/src/platforms/harmonyos/uitest-preflight.ts new file mode 100644 index 000000000..c64a1a1a9 --- /dev/null +++ b/src/platforms/harmonyos/uitest-preflight.ts @@ -0,0 +1,55 @@ +import type { DeviceInfo } from '../../utils/device.ts'; +import { AppError } from '../../utils/errors.ts'; +import { runHarmonyHdc } from './hdc.ts'; + +const UITEST_DAEMON_TOKEN = 'agent-device'; + +export async function listHarmonyUitestProcesses(device: DeviceInfo): Promise { + const result = await runHarmonyHdc(device, ['shell', 'ps', '-ef'], { + allowFailure: true, + timeoutMs: 10_000, + }); + if (result.exitCode !== 0) return []; + return result.stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => /uitest/i.test(line)); +} + +export function findStuckHarmonyUitestProcess(lines: readonly string[]): string | null { + for (const line of lines) { + if (/uitest\s+uiRecord\s+record/i.test(line)) { + return line; + } + } + return null; +} + +export function buildHarmonyUitestBlockedHint(stuckLine?: string): string { + const detail = stuckLine ? ` Detected: ${stuckLine.trim()}` : ''; + return ( + 'HarmonyOS uitest is blocked, so dumpLayout/snapshot cannot run.' + + ' Reboot the device to clear stuck uitest uiRecord sessions.' + + ' Avoid leaving `hdc shell uitest uiRecord record` running in the background.' + + detail + ); +} + +export async function ensureHarmonyUitestReady(device: DeviceInfo): Promise { + const lines = await listHarmonyUitestProcesses(device); + const stuck = findStuckHarmonyUitestProcess(lines); + if (stuck) { + throw new AppError( + 'COMMAND_FAILED', + 'HarmonyOS uitest is blocked by a stuck uiRecord session', + { + hint: buildHarmonyUitestBlockedHint(stuck), + }, + ); + } + + await runHarmonyHdc(device, ['shell', 'uitest', 'start-daemon', UITEST_DAEMON_TOKEN], { + allowFailure: true, + timeoutMs: 5_000, + }); +} diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 3a42b706c..1895434ef 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -30,7 +30,7 @@ export type CliFlags = RemoteConfigMetroOptions & sessionLock?: 'reject' | 'strip'; sessionLocked?: boolean; sessionLockConflicts?: 'reject' | 'strip'; - platform?: 'ios' | 'macos' | 'android' | 'linux' | 'apple'; + platform?: 'ios' | 'macos' | 'android' | 'harmonyos' | 'linux' | 'apple'; target?: 'mobile' | 'tv' | 'desktop'; device?: string; udid?: string; @@ -296,8 +296,8 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ key: 'platform', names: ['--platform'], type: 'enum', - enumValues: ['ios', 'macos', 'android', 'linux', 'apple'], - usageLabel: '--platform ios|macos|android|linux|apple', + enumValues: ['ios', 'macos', 'android', 'harmonyos', 'linux', 'apple'], + usageLabel: '--platform ios|macos|android|harmonyos|linux|apple', usageDescription: 'Platform to target (`apple` aliases the Apple automation backend)', }, { @@ -490,7 +490,7 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ names: ['--activity'], type: 'string', usageLabel: '--activity ', - usageDescription: 'Android app launch activity (package/Activity); not for URL opens', + usageDescription: 'App launch activity/ability (Android: package/Activity; HarmonyOS: ability name); not for URL opens', }, { key: 'launchConsole', diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 7ed782213..81d888672 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -24,6 +24,10 @@ const AGENT_WORKFLOWS = [ label: 'help physical-device', description: 'Connected phone/tablet setup and iOS signing prerequisites', }, + { + label: 'help harmonyos', + description: 'HarmonyOS device setup, app lifecycle, and platform notes', + }, { label: 'help remote', description: 'Remote/cloud config, tenants, leases, and local service tunnels', @@ -47,6 +51,7 @@ const AGENT_QUICKSTART_LINES = [ 'Text: fill \'id="field-email"\' "qa@example.com" replaces; type appends after press.', 'Clearing text: do not use fill ""; use a visible clear/reset control or report that clearing is unsupported.', 'Android IME capture: if fill says input was captured by the keyboard/IME, inspect keyboard state and switch/disable handwriting before retrying; do not loop fill/type.', + 'HarmonyOS: apps shows launchAbility (→ EntryAbility); use open --activity to launch. Pinch, rotate, and transform gestures are not supported.', 'Implicit default sessions are scoped to the current worktree; use --session only when intentionally sharing a named session.', 'Run mutating commands serially within one session; parallelize only read-only commands or separate sessions/devices.', 'Clipboard limits: iOS Allow Paste cannot be automated through XCUITest; prefill with clipboard write. Android non-ASCII should use fill/type, not raw adb input.', @@ -88,6 +93,7 @@ const EXAMPLE_LINES = [ 'agent-device fill @e3 "test@example.com"', 'agent-device replay ./session.ad', 'agent-device test ./suite --platform android', + 'agent-device open com.example.app --platform harmonyos --activity EntryAbility', ] as const; const HELP_TOPICS = { @@ -422,6 +428,7 @@ For simulator/emulator workflows, use help workflow. Discovery: agent-device devices --platform ios agent-device devices --platform android + agent-device devices --platform harmonyos Use --device only when multiple devices are present. iOS physical-device prerequisites: @@ -507,6 +514,55 @@ Rules: Prefer refs/selectors over raw coordinates. macOS snapshot rects are window-space; use current refs or overlay refs instead of guessing coordinates.`, }, + harmonyos: { + summary: 'HarmonyOS device setup, app lifecycle, and platform notes', + body: `agent-device help harmonyos + +Use this when targeting HarmonyOS (NEXT) physical devices or emulators. + +Prerequisites: + HDC (HarmonyOS Device Connector) must be installed and on PATH. + Verify with: hdc list targets + The device must have Developer Mode enabled and USB debugging authorized. + +Discovery: + agent-device devices --platform harmonyos + agent-device apps --platform harmonyos + +App lifecycle: + HarmonyOS apps require an Ability name to launch. The apps command shows the launchAbility suffix: + com.example.app (com.example.app) → EntryAbility + Use the displayed ability with --activity: + agent-device open com.example.app --platform harmonyos --activity EntryAbility + If no ability is shown, check with the app developer or use the default EntryAbility. + +Commands: + agent-device open --platform harmonyos --activity + agent-device close --platform harmonyos + agent-device snapshot -i --platform harmonyos + agent-device screenshot ./out.png --platform harmonyos + agent-device press @e3 --platform harmonyos + agent-device fill @e5 "text" --platform harmonyos + agent-device type "text" --platform harmonyos + agent-device swipe 300 800 300 200 --platform harmonyos + agent-device scroll down --platform harmonyos + agent-device back --platform harmonyos + agent-device home --platform harmonyos + agent-device rotate landscape --platform harmonyos + agent-device keyboard status --platform harmonyos + agent-device keyboard dismiss --platform harmonyos + agent-device keyboard enter --platform harmonyos + +Limitations: + Pinch, rotate gesture, and transform gestures are not supported on HarmonyOS. + app-switcher uses the Recent key; behavior depends on device firmware. + settings clear-app-state clears app data but may require manual unlock afterward. + +Rules: + Always pass --activity when opening a HarmonyOS app. + Use snapshot -i to discover refs, then target them by @ref or selector. + Re-snapshot after mutations just like on iOS/Android.`, + }, dogfood: { summary: 'Exploratory QA workflow with reproducible evidence', body: `agent-device help dogfood @@ -619,7 +675,7 @@ function buildCommandListUsage(commandName: string, schema: CommandSchema): stri function renderUsageText(): string { const header = `agent-device [args] [--json] -CLI to control iOS and Android devices for AI agents. +CLI to control iOS, Android, and HarmonyOS devices for AI agents. `; const commands = listCliCommandNames().map((name) => { diff --git a/src/utils/device.ts b/src/utils/device.ts index 60c7041ca..a48293cbb 100644 --- a/src/utils/device.ts +++ b/src/utils/device.ts @@ -1,7 +1,7 @@ import { AppError } from './errors.ts'; export type ApplePlatform = 'ios' | 'macos'; -export type Platform = ApplePlatform | 'android' | 'linux'; +export type Platform = ApplePlatform | 'android' | 'harmonyos' | 'linux'; export type PlatformSelector = Platform | 'apple'; export type DeviceKind = 'simulator' | 'emulator' | 'device'; export type DeviceTarget = 'mobile' | 'tv' | 'desktop'; diff --git a/src/utils/parsing.ts b/src/utils/parsing.ts index b1c290f24..e2084943d 100644 --- a/src/utils/parsing.ts +++ b/src/utils/parsing.ts @@ -112,7 +112,7 @@ function parseFiniteNumber(value: unknown): number | undefined { } function parsePlatform(value: unknown): Platform | undefined { - return value === 'ios' || value === 'macos' || value === 'android' || value === 'linux' + return value === 'ios' || value === 'macos' || value === 'android' || value === 'harmonyos' || value === 'linux' ? value : undefined; } diff --git a/src/utils/snapshot.ts b/src/utils/snapshot.ts index 4ba2b9acd..2f4c55e3d 100644 --- a/src/utils/snapshot.ts +++ b/src/utils/snapshot.ts @@ -61,7 +61,7 @@ export type SnapshotNode = RawSnapshotNode & { ref: string; }; -export type SnapshotBackend = 'xctest' | 'android' | 'macos-helper' | 'linux-atspi'; +export type SnapshotBackend = 'xctest' | 'android' | 'macos-helper' | 'linux-atspi' | 'harmonyos-arkui'; export type SnapshotState = { nodes: SnapshotNode[]; From 2d69c3efe50776defc926706752d330aa350c828 Mon Sep 17 00:00:00 2001 From: wangcz Date: Thu, 4 Jun 2026 09:41:43 +0800 Subject: [PATCH 3/6] 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 --- src/client-normalizers.ts | 8 +- src/client-types.ts | 6 ++ src/daemon/handlers/record-trace-recording.ts | 61 ++++++++++++++- src/daemon/types.ts | 5 ++ src/platforms/harmonyos/app-lifecycle.ts | 2 +- src/platforms/harmonyos/recording.ts | 76 +++++++++++++++---- src/platforms/harmonyos/settings.ts | 8 +- src/platforms/harmonyos/snapshot.ts | 3 +- src/remote-config-schema.ts | 2 +- src/utils/__tests__/args.test.ts | 2 +- 10 files changed, 149 insertions(+), 24 deletions(-) diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index e77b0089d..2284b4133 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -142,7 +142,7 @@ function buildClientDevicePlatformFields( platform: AgentDeviceDevice['platform'], id: string, simulatorSetPath?: string | null, -): Pick { +): Pick { return { ios: platform === 'ios' @@ -152,6 +152,7 @@ function buildClientDevicePlatformFields( } : undefined, android: platform === 'android' ? { serial: id } : undefined, + harmonyos: platform === 'harmonyos' ? { serial: id } : undefined, }; } @@ -181,6 +182,7 @@ export function normalizeOpenDevice( (platform !== 'ios' && platform !== 'macos' && platform !== 'android' && + platform !== 'harmonyos' && platform !== 'linux') || !id || !name @@ -204,6 +206,10 @@ export function normalizeOpenDevice( : undefined, android: platform === 'android' ? { serial: readOptionalString(value, 'serial') ?? id } : undefined, + harmonyos: + platform === 'harmonyos' + ? { serial: readOptionalString(value, 'serial') ?? id } + : undefined, }; } diff --git a/src/client-types.ts b/src/client-types.ts index ba56096b3..322aa2286 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -114,6 +114,9 @@ export type AgentDeviceDevice = { android?: { serial: string; }; + harmonyos?: { + serial: string; + }; }; export type AgentDeviceSessionDevice = { @@ -129,6 +132,9 @@ export type AgentDeviceSessionDevice = { android?: { serial: string; }; + harmonyos?: { + serial: string; + }; }; export type AgentDeviceSession = { diff --git a/src/daemon/handlers/record-trace-recording.ts b/src/daemon/handlers/record-trace-recording.ts index 846659b17..6c67a273e 100644 --- a/src/daemon/handlers/record-trace-recording.ts +++ b/src/daemon/handlers/record-trace-recording.ts @@ -41,6 +41,10 @@ import { stopIosSimulatorRecordingProcess, } from './record-trace-ios-simulator.ts'; import { resolveImplicitSessionScope, resolvePublicSessionName } from '../session-routing.ts'; +import { + startHarmonyRecording, + stopHarmonyRecording, +} from '../../platforms/harmonyos/recording.ts'; const IOS_DEVICE_RECORD_MIN_FPS = 1; const IOS_DEVICE_RECORD_MAX_FPS = 120; @@ -175,6 +179,35 @@ async function startIosSimulatorRecording(params: { }; } +// --- HarmonyOS start helper --- + +async function startHarmonyOsRecording(params: { + device: SessionState['device']; + recordingBase: RecordingBase; +}): Promise> { + const { device, recordingBase } = params; + const remotePath = `/data/local/tmp/agent-device-recording-${Date.now()}.mp4`; + + let startResult: { remotePid: string }; + try { + startResult = await startHarmonyRecording(device, remotePath); + } catch (error) { + return errorResponse( + 'COMMAND_FAILED', + error instanceof Error ? error.message : String(error), + ); + } + + const recording: Extract, { platform: 'harmonyos' }> = { + platform: 'harmonyos', + remotePath, + remotePid: startResult.remotePid, + ...recordingBase, + startedAt: Date.now(), + }; + return recording; +} + // --- Start recording orchestrator --- // fallow-ignore-next-line complexity @@ -276,6 +309,8 @@ async function startRecording(params: { recordingBase, resolvedOut, }); + } else if (device.platform === 'harmonyos') { + recording = await startHarmonyOsRecording({ device, recordingBase }); } else { recording = await startAndroidRecording({ device, recordingBase }); } @@ -427,6 +462,28 @@ function removeInvalidRecordingOutput(outPath: string): void { } } +async function stopHarmonyOsRecording(params: { + device: SessionState['device']; + recording: Extract, { platform: 'harmonyos' }>; + stopRequestedAt: number; +}): Promise { + const { device, recording, stopRequestedAt } = params; + try { + await stopHarmonyRecording({ + device, + remotePid: recording.remotePid, + remotePath: recording.remotePath, + localPath: recording.outPath, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const failure = buildRecordStopFailure(message, recording, stopRequestedAt); + removeInvalidRecordingOutput(recording.outPath); + return errorResponse('COMMAND_FAILED', failure.message); + } + return null; +} + async function stopRecording(params: { req: DaemonRequest; activeSession: SessionState; @@ -450,7 +507,9 @@ async function stopRecording(params: { ? await stopIosDeviceRecording({ req, activeSession, device, logPath, deps, recording }) : recording.platform === 'macos-runner' ? await stopMacOsRecording({ req, activeSession, device, logPath, deps, recording }) - : await stopNonRunnerRecording({ deps, device, recording, stopRequestedAt }); + : recording.platform === 'harmonyos' + ? await stopHarmonyOsRecording({ device, recording, stopRequestedAt }) + : await stopNonRunnerRecording({ deps, device, recording, stopRequestedAt }); if (stopError) { return stopError; } diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 2e65f0cf0..45bd92b8d 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -250,6 +250,11 @@ export type SessionState = { | (SessionRecordingBase & { platform: 'macos-runner'; remotePath?: string; + }) + | (SessionRecordingBase & { + platform: 'harmonyos'; + remotePath: string; + remotePid: string; }); /** Session-scoped app log stream; logs written to outPath for agent to grep */ appLog?: { diff --git a/src/platforms/harmonyos/app-lifecycle.ts b/src/platforms/harmonyos/app-lifecycle.ts index 0c39266e4..839d78eb0 100644 --- a/src/platforms/harmonyos/app-lifecycle.ts +++ b/src/platforms/harmonyos/app-lifecycle.ts @@ -182,7 +182,7 @@ async function checkAndHandleScreenLock(device: DeviceInfo): Promise { // Try to unlock (swipe up) await runHarmonyHdc( device, - ['shell', 'uitest', 'ui-input', 'swipe', '540', '2000', '540', '800', '300'], + ['shell', 'uitest', 'uiInput', 'swipe', '540', '2000', '540', '800', '300'], { allowFailure: true }, ); // Wait for screen to wake diff --git a/src/platforms/harmonyos/recording.ts b/src/platforms/harmonyos/recording.ts index 0a54521ed..faa0355be 100644 --- a/src/platforms/harmonyos/recording.ts +++ b/src/platforms/harmonyos/recording.ts @@ -2,24 +2,46 @@ import type { DeviceInfo } from '../../utils/device.ts'; import { AppError } from '../../utils/errors.ts'; import { runHarmonyHdc } from './hdc.ts'; -export async function startHarmonyRecording(device: DeviceInfo, remotePath: string): Promise { - // HarmonyOS screen recording via hdc shell screenrecord - // This command may not be available on all devices - await runHarmonyHdc(device, ['shell', 'screenrecord', '--output', remotePath], { - allowFailure: true, - timeoutMs: 5_000, - }); -} +const HARMONY_PROCESS_EXIT_POLL_MS = 250; +const HARMONY_PROCESS_EXIT_ATTEMPTS = 40; -export async function stopHarmonyRecording( +export async function startHarmonyRecording( device: DeviceInfo, remotePath: string, - localPath: string, -): Promise { - // Stop recording (kill the screenrecord process) - await runHarmonyHdc(device, ['shell', 'aa', 'force-stop', 'com.ohos.screenrecorder'], { - allowFailure: true, - }); +): Promise<{ remotePid: string }> { + const shellCmd = `screenrecord --output ${remotePath} >/dev/null 2>&1 & echo $!`; + const result = await runHarmonyHdc(device, ['shell', shellCmd], { allowFailure: true }); + + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', `Failed to start HarmonyOS recording: ${result.stderr}`); + } + + const remotePid = result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => /^\d+$/.test(line)) + .at(-1); + + if (!remotePid) { + throw new AppError('COMMAND_FAILED', 'Failed to get HarmonyOS screenrecord PID'); + } + + return { remotePid }; +} + +export async function stopHarmonyRecording(params: { + device: DeviceInfo; + remotePid: string; + remotePath: string; + localPath: string; +}): Promise { + const { device, remotePid, remotePath, localPath } = params; + + // Send SIGINT to gracefully stop screenrecord + await runHarmonyHdc(device, ['shell', 'kill', '-2', remotePid], { allowFailure: true }); + + // Wait for process to exit + await waitForHarmonyProcessExit(device, remotePid); // Pull the recording file try { @@ -28,9 +50,31 @@ export async function stopHarmonyRecording( timeoutMs: 30_000, }); } catch { - throw new AppError('COMMAND_FAILED', 'Failed to retrieve recording file'); + throw new AppError('COMMAND_FAILED', 'Failed to retrieve HarmonyOS recording file'); } // Cleanup remote await runHarmonyHdc(device, ['shell', 'rm', '-f', remotePath], { allowFailure: true }); } + +async function isHarmonyProcessRunning(device: DeviceInfo, pid: string): Promise { + const result = await runHarmonyHdc(device, ['shell', 'ps', '-o', 'pid=', '-p', pid], { + allowFailure: true, + }); + if (result.exitCode !== 0) { + return false; + } + return result.stdout + .split(/\s+/) + .map((value) => value.trim()) + .includes(pid); +} + +async function waitForHarmonyProcessExit(device: DeviceInfo, pid: string): Promise { + for (let attempt = 0; attempt < HARMONY_PROCESS_EXIT_ATTEMPTS; attempt += 1) { + if (!(await isHarmonyProcessRunning(device, pid))) { + return; + } + await new Promise((resolve) => setTimeout(resolve, HARMONY_PROCESS_EXIT_POLL_MS)); + } +} diff --git a/src/platforms/harmonyos/settings.ts b/src/platforms/harmonyos/settings.ts index 681a526f3..874648e15 100644 --- a/src/platforms/harmonyos/settings.ts +++ b/src/platforms/harmonyos/settings.ts @@ -178,6 +178,12 @@ async function setHarmonyPermission( ): Promise { // HarmonyOS permission management uses different commands than Android // This is a simplified implementation - actual HarmonyOS may need specific APIs + if (action === 'deny') { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'HarmonyOS permission deny is not yet implemented', + ); + } if (action === 'reset') { throw new AppError( 'UNSUPPORTED_OPERATION', @@ -185,7 +191,7 @@ async function setHarmonyPermission( ); } - // Try using bm tool for permission management (if available) + // Only grant is supported for now const result = await runHarmonyHdc( device, ['shell', 'bm', 'grant-permission', '-n', appPackage, '-p', permission], diff --git a/src/platforms/harmonyos/snapshot.ts b/src/platforms/harmonyos/snapshot.ts index ced1762c0..bc3c39c5b 100644 --- a/src/platforms/harmonyos/snapshot.ts +++ b/src/platforms/harmonyos/snapshot.ts @@ -4,8 +4,7 @@ import path from 'node:path'; import os from 'node:os'; import type { DeviceInfo } from '../../utils/device.ts'; import { AppError } from '../../utils/errors.ts'; -import type { SnapshotOptions } from '../../utils/snapshot.ts'; -import { attachRefs, type SnapshotNode } from '../../utils/snapshot.ts'; +import { attachRefs, type SnapshotNode, type SnapshotOptions } from '../../utils/snapshot.ts'; import { runHarmonyHdc } from './hdc.ts'; import { parseArkUiTree, diff --git a/src/remote-config-schema.ts b/src/remote-config-schema.ts index 6c221adc6..e1cf0d7cc 100644 --- a/src/remote-config-schema.ts +++ b/src/remote-config-schema.ts @@ -73,7 +73,7 @@ export const REMOTE_CONFIG_FIELD_SPECS = [ type: 'enum', enumValues: ['ios-simulator', 'ios-instance', 'android-instance'], }, - { key: 'platform', type: 'enum', enumValues: ['ios', 'macos', 'android', 'linux', 'apple'] }, + { key: 'platform', type: 'enum', enumValues: ['ios', 'macos', 'android', 'harmonyos', 'linux', 'apple'] }, { key: 'target', type: 'enum', enumValues: ['mobile', 'tv', 'desktop'] }, { key: 'device', type: 'string' }, { key: 'udid', type: 'string' }, diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 0ab267143..3835578cf 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1518,7 +1518,7 @@ test('command usage shows command and global flags separately', () => { assert.match(help, /Command flags:/); assert.match(help, /--pattern one-way\|ping-pong/); assert.match(help, /Global flags:/); - assert.match(help, /--platform ios\|macos\|android\|linux\|apple/); + assert.match(help, /--platform ios\|macos\|android\|harmonyos\|linux\|apple/); }); test('back command usage documents explicit mode flags', () => { From 3bd1a753b3995aae6777d6a8876aa42158437eee Mon Sep 17 00:00:00 2001 From: wangcz Date: Thu, 4 Jun 2026 10:14:08 +0800 Subject: [PATCH 4/6] fix: mark unimplemented HarmonyOS commands as unsupported and fix platform gaps - capabilities: remove harmonyos support from logs and perf (not yet implemented) - dispatch-interactions: explicitly reject read command on HarmonyOS - client-shared: add harmonyos serial to device identifiers - session-open-surface: include serial for harmonyos open results - client-output: add harmonyos appstate output formatting - boot-diagnostics: add HDC_TRANSPORT_UNAVAILABLE for harmonyos and extend platform type - post-gesture-stabilization: include harmonyos in supported platforms - interaction-outcome-policy: include harmonyos in supported platforms - replay/script-utils: include harmonyos platform in script args - maestro/runtime-targets: correctly map harmonyos platform instead of falling back to ios - maestro/command-mapper: allow openLink on harmonyos with appId --- src/client-shared.ts | 13 ++++++++++--- src/commands/client-output.ts | 5 +++++ src/compat/maestro/command-mapper.ts | 2 +- src/compat/maestro/runtime-targets.ts | 4 +++- src/core/capabilities.ts | 6 ++++-- src/core/dispatch-interactions.ts | 3 +++ src/daemon/handlers/session-open-surface.ts | 2 +- src/daemon/interaction-outcome-policy.ts | 6 +++++- src/daemon/post-gesture-stabilization.ts | 2 +- src/platforms/boot-diagnostics.ts | 10 ++++++++-- src/replay/script-utils.ts | 2 +- 11 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/client-shared.ts b/src/client-shared.ts index 8cd2675f3..31944bc1d 100644 --- a/src/client-shared.ts +++ b/src/client-shared.ts @@ -36,7 +36,11 @@ export function buildDeviceIdentifiers( return { deviceId: id, deviceName: name, - ...(platform === 'android' ? { serial: id } : platform === 'ios' ? { udid: id } : {}), + ...(platform === 'android' || platform === 'harmonyos' + ? { serial: id } + : platform === 'ios' + ? { udid: id } + : {}), }; } @@ -56,9 +60,12 @@ function serializeSessionDevice( ios_simulator_device_set: device.ios?.simulatorSetPath ?? null, } : {}), - ...(device.platform === 'android' && includeAndroidSerial + ...((device.platform === 'android' || device.platform === 'harmonyos') && includeAndroidSerial ? { - serial: device.android?.serial ?? device.id, + serial: + device.platform === 'android' + ? device.android?.serial ?? device.id + : device.harmonyos?.serial ?? device.id, } : {}), }; diff --git a/src/commands/client-output.ts b/src/commands/client-output.ts index d4732d8f1..74bcd5eaf 100644 --- a/src/commands/client-output.ts +++ b/src/commands/client-output.ts @@ -249,6 +249,11 @@ function formatAppState(data: AppStateCommandResult): string | null { if (data.activity) lines.push(`Activity: ${data.activity}`); return lines.join('\n'); } + if (data.platform === 'harmonyos') { + const lines = [`Foreground app: ${data.bundleId ?? 'unknown'}`]; + if (data.activity) lines.push(`Ability: ${data.activity}`); + return lines.join('\n'); + } return null; } diff --git a/src/compat/maestro/command-mapper.ts b/src/compat/maestro/command-mapper.ts index 23d53eda8..6c0d49eb6 100644 --- a/src/compat/maestro/command-mapper.ts +++ b/src/compat/maestro/command-mapper.ts @@ -154,7 +154,7 @@ function convertOpenLink( ): SessionAction { const rawLink = readOpenLink(value, name); const url = resolveMaestroString(rawLink, context); - if ((context.platform === 'ios' || context.platform === 'android') && config.appId) { + if ((context.platform === 'ios' || context.platform === 'android' || context.platform === 'harmonyos') && config.appId) { return action( 'open', [resolveMaestroString(requireAppId(config, name), context), url], diff --git a/src/compat/maestro/runtime-targets.ts b/src/compat/maestro/runtime-targets.ts index 09279b182..8c77b40cd 100644 --- a/src/compat/maestro/runtime-targets.ts +++ b/src/compat/maestro/runtime-targets.ts @@ -256,7 +256,9 @@ function filterReactNativeOverlayBlockedMatches( } export function readMaestroSelectorPlatform(flags: DaemonRequest['flags']): Platform { - return flags?.platform === 'android' ? 'android' : 'ios'; + if (flags?.platform === 'android') return 'android'; + if (flags?.platform === 'harmonyos') return 'harmonyos'; + return 'ios'; } export function extractMaestroVisibleTextQuery(selectorExpression: string): string | null { diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 59d4748ba..b3660c13c 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -180,8 +180,9 @@ const COMMAND_CAPABILITY_MATRIX: Record = { logs: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, - harmonyos: HARMONYOS_DEVICE, + harmonyos: {}, linux: LINUX_NONE, + supports: (device) => device.platform !== 'harmonyos', }, network: { apple: { simulator: true, device: true }, @@ -199,8 +200,9 @@ const COMMAND_CAPABILITY_MATRIX: Record = { perf: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, - harmonyos: HARMONYOS_DEVICE, + harmonyos: {}, linux: LINUX_NONE, + supports: (device) => device.platform !== 'harmonyos', }, pan: { apple: { simulator: true, device: true }, diff --git a/src/core/dispatch-interactions.ts b/src/core/dispatch-interactions.ts index ff9fa4417..665e795a0 100644 --- a/src/core/dispatch-interactions.ts +++ b/src/core/dispatch-interactions.ts @@ -879,6 +879,9 @@ export async function handleReadCommand( context: DispatchContext | undefined, ): Promise> { const { x, y } = readPoint(positionals, 'read requires x y'); + if (device.platform === 'harmonyos') { + throw new AppError('UNSUPPORTED_OPERATION', 'read is not yet supported on HarmonyOS'); + } if (device.platform === 'android') { const { readAndroidTextAtPoint } = await import('../platforms/android/input-actions.ts'); const text = await readAndroidTextAtPoint(device, x, y); diff --git a/src/daemon/handlers/session-open-surface.ts b/src/daemon/handlers/session-open-surface.ts index 6c8350c65..4ca09441f 100644 --- a/src/daemon/handlers/session-open-surface.ts +++ b/src/daemon/handlers/session-open-surface.ts @@ -45,7 +45,7 @@ export function buildOpenResult(params: { result.device = device.name; result.id = device.id; result.kind = device.kind; - if (device.platform === 'android') { + if (device.platform === 'android' || device.platform === 'harmonyos') { result.serial = device.id; } } diff --git a/src/daemon/interaction-outcome-policy.ts b/src/daemon/interaction-outcome-policy.ts index 1e7f8c6eb..cda81fa5a 100644 --- a/src/daemon/interaction-outcome-policy.ts +++ b/src/daemon/interaction-outcome-policy.ts @@ -187,7 +187,11 @@ export function areInteractionSurfaceSignaturesStable( } function supportsInteractionOutcomePolicy(session: SessionState): boolean { - return session.device.platform === 'ios' || session.device.platform === 'android'; + return ( + session.device.platform === 'ios' || + session.device.platform === 'android' || + session.device.platform === 'harmonyos' + ); } function retryCommandForTap(command: string): string | undefined { diff --git a/src/daemon/post-gesture-stabilization.ts b/src/daemon/post-gesture-stabilization.ts index c952eec26..1a9d9e4ae 100644 --- a/src/daemon/post-gesture-stabilization.ts +++ b/src/daemon/post-gesture-stabilization.ts @@ -79,5 +79,5 @@ function isPostGestureStabilizingAction(action: string): boolean { } function supportsPostGestureStabilization(platform: SessionState['device']['platform']): boolean { - return platform === 'ios' || platform === 'android'; + return platform === 'ios' || platform === 'android' || platform === 'harmonyos'; } diff --git a/src/platforms/boot-diagnostics.ts b/src/platforms/boot-diagnostics.ts index f074a8f70..d7e1041b2 100644 --- a/src/platforms/boot-diagnostics.ts +++ b/src/platforms/boot-diagnostics.ts @@ -6,6 +6,7 @@ export type BootFailureReason = | 'IOS_TOOL_MISSING' | 'ANDROID_BOOT_TIMEOUT' | 'ADB_TRANSPORT_UNAVAILABLE' + | 'HDC_TRANSPORT_UNAVAILABLE' | 'CI_RESOURCE_STARVATION_SUSPECTED' | 'BOOT_COMMAND_FAILED' | 'UNKNOWN'; @@ -16,11 +17,12 @@ const INFRASTRUCTURE_BOOT_FAILURE_REASONS = new Set([ 'IOS_TOOL_MISSING', 'ANDROID_BOOT_TIMEOUT', 'ADB_TRANSPORT_UNAVAILABLE', + 'HDC_TRANSPORT_UNAVAILABLE', 'CI_RESOURCE_STARVATION_SUSPECTED', ]); type BootDiagnosticContext = { - platform?: 'ios' | 'android'; + platform?: 'ios' | 'android' | 'harmonyos'; phase?: 'boot' | 'connect' | 'transport'; }; @@ -39,7 +41,11 @@ export function classifyBootFailure(input: { const platform = input.context?.platform; const phase = input.context?.phase; if (appErr?.code === 'TOOL_MISSING') { - return platform === 'android' ? 'ADB_TRANSPORT_UNAVAILABLE' : 'IOS_TOOL_MISSING'; + return platform === 'android' + ? 'ADB_TRANSPORT_UNAVAILABLE' + : platform === 'harmonyos' + ? 'HDC_TRANSPORT_UNAVAILABLE' + : 'IOS_TOOL_MISSING'; } const details = (appErr?.details ?? {}) as Record; const detailMessage = typeof details.message === 'string' ? details.message : undefined; diff --git a/src/replay/script-utils.ts b/src/replay/script-utils.ts index 541cfc647..42ab2d9f5 100644 --- a/src/replay/script-utils.ts +++ b/src/replay/script-utils.ts @@ -108,7 +108,7 @@ export function appendRuntimeHintFlags( | undefined, ): void { if (!flags) return; - if (flags.platform === 'ios' || flags.platform === 'android') { + if (flags.platform === 'ios' || flags.platform === 'android' || flags.platform === 'harmonyos') { parts.push('--platform', flags.platform); } if (typeof flags.metroHost === 'string' && flags.metroHost.length > 0) { From b55838b4189cbddbe13046cdeeb74a74cb624bd0 Mon Sep 17 00:00:00 2001 From: wangcz Date: Thu, 4 Jun 2026 10:28:55 +0800 Subject: [PATCH 5/6] fix: remove clipboard from HarmonyOS capabilities and correct test docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - capabilities: remove harmonyos clipboard support because uitest uiInput has no getClipboard/setClipboard commands - test docs: fix settings dark-mode → settings appearance dark - test docs: fix fling → gesture fling with correct args --- src/core/capabilities.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index b3660c13c..f78225ae8 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -126,11 +126,10 @@ const COMMAND_CAPABILITY_MATRIX: Record = { clipboard: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, - harmonyos: HARMONYOS_DEVICE, + harmonyos: {}, linux: LINUX_DEVICE, supports: (device) => device.platform === 'android' || - device.platform === 'harmonyos' || device.platform === 'linux' || device.platform === 'macos' || device.kind === 'simulator', From d66c8e0d95eaf8d1063de35bc7ace24212c3e0a9 Mon Sep 17 00:00:00 2001 From: wangcz Date: Thu, 4 Jun 2026 11:03:50 +0800 Subject: [PATCH 6/6] fix: add capability check to perf daemon handler The perf handler was missing an isCommandSupportedOnDevice check, so it returned empty metrics instead of UNSUPPORTED_OPERATION for platforms where perf is not supported (e.g. harmonyos). --- src/daemon/handlers/session-observability.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/daemon/handlers/session-observability.ts b/src/daemon/handlers/session-observability.ts index 93d4070c0..6dd17be2c 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -74,6 +74,9 @@ async function handlePerfCommand(params: ObservabilityParams): Promise