* Copyright (c) 2025 Huawei Technologies Co., Ltd.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import { AbsolutePath, DescriptiveError } from '../core';
import { Logger, RealFS } from '../io';
import { RealCliExecutor } from '../io/CliExecutor';
import { Command } from './types';
import fs from 'fs-extra';
import crypto from 'node:crypto';
import JSON5 from 'json5';
import inquirer from 'inquirer';
import path from 'path';
import {
getDefaultUserTerminal,
startServerInNewWindow,
findDevServerPort,
} from '@react-native-community/cli-tools';
import { launchHarmonySimulator } from '../simulator';
const COMMAND_NAME = 'run-harmony';
const LOOPBACK_IP = '127.0.0.1';
const DEFAULT_SIMULATOR_NAME = `${LOOPBACK_IP}:5555`;
export const commandRunHarmony: Command = {
name: COMMAND_NAME,
description: 'Builds your app and starts it on your phone.',
options: [
{
name: '--harmony-project-path <path>',
description: 'Specifies the root of your OpenHarmony project.',
default: './harmony',
},
{
name: '--module <string>',
description: 'Name of the OH module to run.',
default: 'entry',
},
{
name: '--build-mode <string>',
description: 'Debug or Release.',
default: 'debug',
},
{
name: '--product <string>',
description: 'OpenHarmony product defined in build-profile.json5.',
default: 'default',
},
{
name: '--ability <string>',
description: 'Name of the ability to start.',
default: 'EntryAbility',
},
{
name: '--simulator <string>',
description: 'The name of the simulator that are currently connected.',
default: 'undefined',
},
{
name: '--port <number>',
description: 'The port to use for the device.',
default: process.env.RCT_METRO_PORT || 8081,
},
{
name: '--no-packager',
description: 'Do not launch packager while running the app.',
},
],
func: async (_argv, _config, rawArgs: any) => {
const logger = new Logger();
try {
const DEVECO_SDK_HOME = process.env.DEVECO_SDK_HOME;
if (!DEVECO_SDK_HOME) {
throw new DescriptiveError({
whatHappened: 'DEVECO_SDK_HOME environment variable is not set',
whatCanUserDo: [
process.platform === 'darwin'
? 'On MacOS, the contents directory is typically located at: /Applications/DevEco-Studio.app/Contents/sdk. Set this path as the value of DEVECO_SDK_HOME'
: "Locate the installation directory of DevEco Studio, and set its 'sdk' subdirectory as the value of DEVECO_SDK_HOME environment variable.",
],
});
}
const fs = new RealFS();
const sdkPath = new AbsolutePath(DEVECO_SDK_HOME);
const sdkDirectoryNames = fs
.readDirentsSync(sdkPath)
.flatMap((dirent) => {
if (dirent.isDirectory()) {
return [dirent.name];
} else {
return [];
}
});
if (sdkDirectoryNames.length === 0) {
throw new DescriptiveError({
whatHappened: `${sdkPath.toString()} has no directories`,
unexpected: true,
});
}
const sdkToolchainsPath = sdkPath.copyWithNewSegment(
sdkDirectoryNames[0],
'openharmony',
'toolchains'
);
const cli = new RealCliExecutor();
const harmonyProjectPath = new AbsolutePath(rawArgs.harmonyProjectPath);
const productName: string = rawArgs.product;
const buildMode: string = rawArgs.buildMode;
const moduleName: string = rawArgs.module;
const abilityName: string = rawArgs.ability;
let simulatorName: string = rawArgs.simulator;
const defaultPort: number = rawArgs.port;
const packager: boolean = rawArgs.packager;
const devEcoStudioToolsPath = new AbsolutePath(
DEVECO_SDK_HOME
).copyWithNewSegment('..', 'tools');
if (!fs.existsSync(devEcoStudioToolsPath)) {
throw new DescriptiveError({
whatHappened: `${devEcoStudioToolsPath.toString()} doesn't exist`,
});
}
const bundleName = JSON5.parse(
fs.readTextSync(
harmonyProjectPath.copyWithNewSegment('AppScope', 'app.json5')
)
).app.bundleName;
let currentPort: number = defaultPort;
if (packager) {
const root: string = _config.root || '.';
const reactNativePath: string = _config.reactNativePath || path.dirname(
require.resolve('react-native', { paths: [root] }),
);
const { port: newPort, startPackager } = await findDevServerPort(
defaultPort,
root,
);
if (newPort !== 8081) {
await trySetMetroPort(harmonyProjectPath.copyWithNewSegment(
'entry', 'src', 'main', 'ets', 'pages', 'Index.ets').toString(), newPort);
}
if (startPackager) {
startServerInNewWindow(
newPort,
root,
reactNativePath,
getDefaultUserTerminal(),
);
}
currentPort = newPort;
}
const selectDevice = async (deviceAndSimulatorInfos: string) => {
const lines = deviceAndSimulatorInfos.trim().split('\n');
const availableDevices = lines.map((line) => {
const parts = line.split(/\s+/);
return {
name: parts[0],
method: parts[1],
state: parts[2],
locate: parts[3],
connectTool: parts[4]
};
});
if (simulatorName !== 'undefined') {
const isRequestedDeviceAvailable = availableDevices.some((deviceOrSimulator) => deviceOrSimulator.name === simulatorName);
if (!isRequestedDeviceAvailable) {
throw new DescriptiveError({
whatHappened: `Simulator with name "${simulatorName}" not found.`,
whatCanUserDo: [
'Please confirm whether the entered simulator name has been activated.',
]
});
}
return simulatorName;
}
const connectedDevices = availableDevices.filter((deviceOrSimulator) => deviceOrSimulator.state === 'Connected');
if (connectedDevices.length === 1) {
return connectedDevices[0].name;
}
if (connectedDevices.length > 1) {
const answers = await inquirer.prompt([
{
type: 'list',
name: 'selectedDeviceIndex',
message: 'Please select a device:',
choices: connectedDevices.map((deviceOrSimulator, index) => `${index + 1}. ${deviceOrSimulator.name}`)
}
])
return connectedDevices[parseInt(answers.selectedDeviceIndex) - 1].name;
}
try {
await launchHarmonySimulator({
devEcoStudioToolsPath,
sdkToolchainsPath,
cli,
logger,
env: process.env,
});
simulatorName = DEFAULT_SIMULATOR_NAME;
return simulatorName;
} catch (e) {
if (e instanceof DescriptiveError) {
throw new DescriptiveError({
whatHappened: `${e.getMessage()} \n No devices are connected. Failed to auto-launch simulator.`,
whatCanUserDo: [...e.getSuggestions()],
extraData: e.getRawData().extraData,
});
}
throw new DescriptiveError({
whatHappened: `No devices are connected in the current environment (auto-launch attempt failed).`,
whatCanUserDo: [
'Connect a HarmonyOS device via USB or start the simulator manually.',
'Re-run the command after a device is listed by: hdc list targets -v',
],
extraData: e,
});
}
};
const deviceAndSimulatorInfo: string = await cli.run(
sdkToolchainsPath.copyWithNewSegment('hdc').toString(),
{
args: ['list', 'targets', '-v'],
cwd: harmonyProjectPath,
}
);
const deviceOrSimulatorName = await selectDevice(deviceAndSimulatorInfo);
if (deviceOrSimulatorName.includes(`${LOOPBACK_IP}:`)) {
simulatorName = deviceOrSimulatorName;
}
const runJob = async (name: string, job: () => Promise<void>) => {
const stop = logger.start((s) => s.bold(name));
logger.info((s) => s.bold(`${name} started`));
try {
await job();
logger.succeed((s) => s.bold(`${name} finished`));
} catch (err) {
if (err instanceof DescriptiveError) {
throw err;
}
throw new DescriptiveError({
whatHappened: `${name} failed`,
extraData: err,
});
} finally {
stop();
}
};
await runJob('[1/3] installing dependencies', async () => {
let ohpmPath = devEcoStudioToolsPath
.copyWithNewSegment('ohpm', 'bin', 'ohpm');
if (process.platform === 'win32') {
ohpmPath = devEcoStudioToolsPath
.copyWithNewSegment('ohpm', 'bin', 'ohpm.bat');
}
await cli.run(
`"${ohpmPath.toString()}"`,
{
args: [
'install',
'--all',
],
cwd: harmonyProjectPath,
shell: true,
onArgsStringified: (commandWithArgs) => {
logger.debug((s) => s.bold(s.gray(commandWithArgs)));
},
onStdout: (msg) => {
logger.debug((s) => s.gray(msg.trimEnd()));
},
onStderr(msg) {
logger.debug(() => msg.trimEnd());
},
}
);
});
await runJob('[2/3] building app', async () => {
let nodePath = devEcoStudioToolsPath
.copyWithNewSegment('node', 'bin', 'node');
if (!fs.existsSync(nodePath)) {
nodePath = devEcoStudioToolsPath.copyWithNewSegment('node', 'node');
}
let hvigorPathRaw = devEcoStudioToolsPath.copyWithNewSegment('hvigor', 'bin', 'hvigorw.js').toString();
const hvigorPath = `"${hvigorPathRaw}"`;
await cli.run(
`"${nodePath.toString()}"`,
{
args: [
hvigorPath,
`-p`,
`module=${moduleName}@default`,
`-p`,
`product=${productName}`,
`-p`,
`buildMode=${buildMode}`,
`-p`,
`requiredDeviceType=phone`,
`assembleHap`,
],
cwd: harmonyProjectPath,
shell: true,
onArgsStringified: (commandWithArgs) => {
logger.debug((s) => s.bold(s.gray(commandWithArgs)));
},
onStdout: (msg) => {
logger.debug((s) => s.gray(msg.trimEnd()));
},
onStderr(msg) {
logger.debug(() => msg.trimEnd());
},
}
);
});
await runJob('[3/3] installing and opening app', async () => {
const tmpDirName = generateRandomString();
const ohosTmpDirPath = `data/local/tmp/${tmpDirName}`;
const exec = async (command: string, args: string[]) => {
const result = await cli.run(command, {
args: args,
shell: true,
onArgsStringified: (commandWithArgs) =>
logger.debug((s) => s.bold(s.gray(commandWithArgs))),
});
if (result.startsWith('[Fail]')) {
throw new DescriptiveError({ whatHappened: result });
}
if (result) {
logger.debug((s) => s.gray(result.trimEnd() + '\n'));
} else {
logger.debug(() => '');
}
return result;
};
const hdcPathStrRaw = sdkToolchainsPath
.copyWithNewSegment('hdc')
.toString();
const hdcPathStr = `"${hdcPathStrRaw.toString()}"`;
const tryRemoveForwardPorting = async () => {
let fportInfo: string = '';
await cli.run(
hdcPathStrRaw,
{
args: ['fport', 'ls'],
cwd: harmonyProjectPath,
onStdout: (msg) => {
fportInfo += msg;
},
onStderr: (msg) => {
logger.debug((s) => s.gray(msg.trimEnd()));
},
});
if (fportInfo.includes(`tcp:${currentPort} tcp:${currentPort}`)) {
await exec(hdcPathStr, [
'fport',
'rm',
`tcp:${currentPort}`,
`tcp:${currentPort}`
]);
}
};
await exec(hdcPathStr, ['-t', deviceOrSimulatorName, 'shell', 'aa', 'force-stop', bundleName]);
try {
await exec(hdcPathStr, ['-t', deviceOrSimulatorName, 'shell', 'mkdir', ohosTmpDirPath]);
let hapName = `${moduleName}-default-signed.hap`;
if (simulatorName !== 'undefined') {
hapName = `${moduleName}-default-unsigned.hap`;
}
await exec(hdcPathStr, [
'-t',
deviceOrSimulatorName,
'file',
'send',
harmonyProjectPath
.copyWithNewSegment(
moduleName,
'build',
'default',
'outputs',
'default',
hapName
)
.toString(),
ohosTmpDirPath,
]);
const installationResult = await exec(hdcPathStr, [
'-t',
deviceOrSimulatorName,
'shell',
'bm',
'install',
'-p',
ohosTmpDirPath,
]);
if (installationResult.includes('failed to install')) {
throw new DescriptiveError({
whatHappened: 'Installation failed.',
whatCanUserDo: [
`If an application with the same bundle name is already installed, try uninstalling it:\n${hdcPathStr} shell bm uninstall -n ${bundleName}`,
],
});
}
} finally {
await exec(hdcPathStr, ['-t', deviceOrSimulatorName, 'shell', 'rm', '-rf', ohosTmpDirPath]);
}
await exec(hdcPathStr, [
'-t',
deviceOrSimulatorName,
'shell',
'aa',
'start',
'-a',
abilityName,
'-b',
bundleName,
]);
await tryRemoveForwardPorting();
await exec(hdcPathStr, [
'-t',
deviceOrSimulatorName,
'rport',
`tcp:${currentPort}`,
`tcp:${currentPort}`
]);
});
} catch (err) {
if (err instanceof DescriptiveError) {
logger.descriptiveError(
new DescriptiveError({
whatHappened: err.getMessage(),
whatCanUserDo: [
...err.getSuggestions(),
'Try building and running the app from DevEco studio.',
],
extraData: err.getRawData().extraData,
})
);
} else {
throw err;
}
}
},
};
function generateRandomString(length: number = 32): string {
const chars: string = '0123456789abcdefghijklmnopqrstuvwxyz';
let result: string = '';
const randomValues = new Uint8Array(length);
crypto.getRandomValues(randomValues);
for (let i = 0; i < length; i++) {
const randomIndex = randomValues[i] % chars.length;
result += chars[randomIndex];
}
return result;
}
async function trySetMetroPort(filePath: string, port: number): Promise<void> {
let content = await fs.readFile(filePath, 'utf-8');
const reFromServerIp = /MetroJSBundleProvider\.fromServerIp\(\s*"([^"]+)"\s*,\s*(\d+)\s*\)/;
if (reFromServerIp.test(content)) {
content = content.replace(reFromServerIp, (_match: any, host: any) => {
return `MetroJSBundleProvider.fromServerIp("${host}", ${port})`;
});
await fs.writeFile(filePath, content, 'utf-8');
return;
}
const reNewProvider = /new\s+MetroJSBundleProvider\s*\(\s*\)/;
if (reNewProvider.test(content)) {
content = content.replace(reNewProvider, () => {
return `MetroJSBundleProvider.fromServerIp("localhost", ${port}),\n new MetroJSBundleProvider()`;
});
await fs.writeFile(filePath, content, 'utf-8');
return;
}
throw new DescriptiveError({
whatHappened: `Unable to set the specified port [${port}] for the Metro server automatically.`,
whatCanUserDo: [
`Please manually add "MetroJSBundleProvider.fromServerIp("localhost", ${port})" in the "new AnyJSBundleProvider" section of your ${filePath} file.
e.g:
new AnyJSBundleProvider([
MetroJSBundleProvider.fromServerIp("localhost", ${port}),
new MetroJSBundleProvider(),
new ResourceJSBundleProvider(this.rnohCoreContext.uiAbilityContext.resourceManager, 'hermes_bundle.hbc'),
])`
],
});
}