* Copyright (c) 2025 Huawei Device Co., Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as vscode from 'vscode';
import * as os from 'os';
import { exec, execSync, spawn } from 'child_process';
import { debounce } from 'lodash';
import path from 'path';
import * as fs from 'fs';
import * as ini from 'ini';
import { OS, RemotePlatform } from './types';
import {resolveAndWriteEnvVars} from "./envResolver";
import {
resolveCjmpSdkRoot,
resolveKeelsCommand,
} from './cjmpSdk';
export type PlatformType = 'android' | 'ohos' | string;
export type Category = 'mobile' | string;
export const TimeDiff = 180000;
const PLATFORM_MAPPING: Record<string, { category: Category; platform: string }> = {
android: { category: 'mobile', platform: 'Android' },
ohos: { category: 'mobile', platform: 'HarmonyOS' },
ios: { category: 'mobile', platform: 'iOS' },
};
export async function setCjmpSdkRoot(
sdkRoot: string,
configurationTarget: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global,
): Promise<void> {
try {
if (!sdkRoot) {
vscode.window.showErrorMessage('CJMP SDK root not set. Operation cancelled.');
return;
}
const config = vscode.workspace.getConfiguration();
await config.update('CJMP.sdkPath', sdkRoot, configurationTarget);
} catch (error) {
vscode.window.showErrorMessage(
`Failed to set CJMP SDK root: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
export interface Device {
name: string;
id: string;
isSupported: boolean;
targetPlatform: string;
emulator: boolean;
sdk?: string;
category: Category;
platform: string;
}
interface KeelsDevicesEnvelope {
devices?: unknown[];
diagnostics?: unknown[];
}
* Execute "keels devices --machine" to obtain the device list in JSON format.
*/
export class KeelsDeviceManager {
private statusBarItem: vscode.StatusBarItem;
public currentDevice: Device | null = null;
private context: vscode.ExtensionContext;
private deviceMonitoringInterval: ReturnType<typeof setInterval> | undefined;
private readonly isCjmpProject: boolean;
constructor(context: vscode.ExtensionContext, isCjmpProject: boolean = true) {
this.context = context;
this.isCjmpProject = isCjmpProject;
this.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
this.statusBarItem.command = 'cjmp.selectDevice';
this.statusBarItem.tooltip = 'Select CJMP Device';
if (this.isCjmpProject) {
this.loadPersistedDevice();
this.deviceMonitoringInterval = this.startDeviceMonitoring();
}
}
private startDeviceMonitoring(): ReturnType<typeof setInterval> {
return this.startPolling();
}
private startPolling(intervalMs: number = 10000): ReturnType<typeof setInterval> {
return setInterval(() => this.debouncedRefreshDevices(), intervalMs);
}
private debouncedRefreshDevices = debounce(() => this.refreshDevices(), 500);
public async getKeelsDevices(): Promise<Device[]> {
return new Promise((resolve) => {
const keelsCommand = resolveKeelsCommand({ reportErrors: false });
if (!keelsCommand) {
resolve([]);
return;
}
const isWindows = process.platform === 'win32';
const command = isWindows
? `${keelsCommand.executablePath} devices --machine`
: `${keelsCommand.executablePath} devices --machine`.replace(/\\/g, '/');
exec(command, {
shell: isWindows ? 'cmd.exe' : '/bin/bash',
env: keelsCommand.environment,
}, (error, stdout) => {
if (error) {
resolve([]);
} else {
try {
const rawData = this.extractDeviceRecords(stdout);
const devices = rawData.map((item: any) => {
const { category, platformName } = this.parsePlatform(item.targetPlatform || '');
return {
name: item.name || 'Unknown Device',
id: item.id || '',
isSupported: !!item.isSupported,
targetPlatform: item.targetPlatform || 'unknown',
emulator: !!item.emulator,
sdk: item.sdk,
category,
platform: platformName,
} as Device;
});
resolve(devices);
} catch (e) {
resolve([]);
}
}
});
});
}
private parsePlatform(targetPlatform: string) {
const [basePlatform, ...architectureParts] = targetPlatform.split(/[-_]/);
const mapping = PLATFORM_MAPPING[basePlatform.toLowerCase()] || {
category: 'unknown' as Category,
platform: basePlatform,
};
return {
category: mapping.category,
platformName: mapping.platform,
};
}
private extractDeviceRecords(stdout: string): any[] {
const parsed = JSON.parse(stdout) as unknown;
if (Array.isArray(parsed)) {
return parsed;
}
const envelope = parsed as KeelsDevicesEnvelope;
if (Array.isArray(envelope.devices)) {
return envelope.devices;
}
throw new Error('Unsupported keels devices payload');
}
private async refreshDevices() {
const devices = await this.getKeelsDevices();
if (devices.length === 0) {
this.statusBarItem.text = '$(alert) No Devices';
this.showStatusBarItem();
return;
}
const currentDeviceExists =
this.currentDevice && devices.some((d) => d.id === this.currentDevice?.id);
if (!currentDeviceExists) {
const savedDeviceId = this.context.workspaceState.get<string>('keels.selectedDevice');
const savedDevice = devices.find((d) => d.id === savedDeviceId);
this.setCurrentDevice(savedDevice || devices[0]);
}
}
private loadPersistedDevice() {
const savedDeviceId = this.context.workspaceState.get<string>('keels.selectedDevice');
if (savedDeviceId) {
this.statusBarItem.text = '$(loading~spin) Loading...';
this.showStatusBarItem();
}
}
private saveCurrentDevice(device: Device) {
this.context.workspaceState.update('keels.selectedDevice', device.id);
}
private setCurrentDevice(device: Device) {
this.currentDevice = device;
this.statusBarItem.text = `$(device-mobile) ${device.name} (${device.targetPlatform})`;
this.showStatusBarItem();
this.saveCurrentDevice(device);
}
public async showDevicePicker(): Promise<Device | undefined> {
const devices = await this.getKeelsDevices();
if (devices.length === 0) {
vscode.window.showInformationMessage('No CJMP devices found.');
return;
}
const picked = await vscode.window.showQuickPick(
devices.map((d) => ({
label: this.labelForDevice(d, { withIcon: true }),
description: `${d.id} - ${d.category || d.platform || ''}`,
detail: d === this.currentDevice ? 'Current Device' : undefined,
device: d,
})),
{ placeHolder: 'Select a CJMP device' },
);
if (picked) {
this.setCurrentDevice(picked.device);
}
return picked?.device;
}
public labelForDevice(device: Device, { withIcon = false }: { withIcon?: boolean } = {}): any {
let icon;
switch (device.category) {
case 'mobile':
icon = '$(device-mobile) ';
break;
default:
icon = undefined;
}
const name = device.name;
return withIcon ? `${icon ?? ''}${name}` : name;
}
private showStatusBarItem() {
if (this.isCjmpProject) {
this.statusBarItem.show();
}
}
public dispose() {
if (this.deviceMonitoringInterval !== undefined) {
clearInterval(this.deviceMonitoringInterval);
}
this.statusBarItem.dispose();
}
}
export function getIDEType(): string {
return vscode.env.appName;
}
export function getWorkSpacePath() {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
vscode.window.showErrorMessage('No workspace folder found.');
return;
}
return workspaceFolder.uri;
}
export async function getBundleName() {
const projectName = await getProjectName();
const orgName = await getOrgName();
if (!orgName) {
return '';
}
if (!projectName) {
return '';
}
return `${orgName}.${projectName}`;
}
export async function getProjectName() {
let projectConf = getProjectConf();
if (!projectConf) {
return;
}
const fileContent = fs.readFileSync(projectConf, 'utf-8');
const config = ini.parse(fileContent);
return config.project.name;
}
export async function getOrgName() {
let projectConf = getProjectConf();
if (!projectConf) {
return;
}
const fileContent = fs.readFileSync(projectConf, 'utf-8');
const config = ini.parse(fileContent);
return config.project.organization;
}
export async function getPackageName() {
let projectConf = getProjectConf();
if (!projectConf) {
return;
}
const fileContent = fs.readFileSync(projectConf, 'utf-8');
const config = ini.parse(fileContent);
return config.project.packageName;
}
export function getExampleDirectory(): string | undefined {
const projectConf = getProjectConf();
if (!projectConf) {
return;
}
const fileContent = fs.readFileSync(projectConf, 'utf-8');
const config = ini.parse(fileContent);
return config.project.exampleDir;
}
export async function getLibPath(remotePlatform: string | undefined) {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
vscode.window.showErrorMessage('No workspace folder found.');
return;
}
const workspaceRoot = workspaceFolder.uri.fsPath;
const projectType = getProjectType();
const exampleDirectory = projectType === 'library' ? getExampleDirectory() : undefined;
const platformProjectRoot = exampleDirectory
? path.join(workspaceRoot, exampleDirectory)
: workspaceRoot;
const hosLibraryPath = path.join(
platformProjectRoot,
'hos',
'entry',
'build',
'default',
'intermediates',
'libs',
'default',
'arm64-v8a',
);
const androidLibraryCandidates = [
path.join(
platformProjectRoot,
'android',
'app',
'build',
'intermediates',
'merged_native_libs',
'debug',
'out',
'lib',
'arm64-v8a',
),
path.join(
platformProjectRoot,
'android',
'app',
'build',
'intermediates',
'merged_native_libs',
'debug',
'mergeDebugNativeLibs',
'out',
'lib',
'arm64-v8a',
),
];
let androidLibraryPaths = androidLibraryCandidates
.filter((candidatePath, index, paths) => fs.existsSync(candidatePath) && paths.indexOf(candidatePath) === index);
const iosLibraryCandidates = getOs() === 'mac'
? [
path.join(platformProjectRoot, 'build', 'aarch64-apple-ios', 'debug'),
...(projectType === 'library' && platformProjectRoot !== workspaceRoot
? [path.join(workspaceRoot, 'build', 'aarch64-apple-ios', 'debug')]
: []),
]
: [];
const iosLibraryPaths = iosLibraryCandidates.filter(
(candidatePath, index, paths) =>
fs.existsSync(candidatePath) && paths.indexOf(candidatePath) === index,
);
if (projectType === 'logic-module') {
const packageName = await getPackageName();
const logicModuleCandidates = [
path.join(
workspaceRoot,
'android',
packageName,
'build',
'intermediates',
'merged_native_libs',
'debug',
'out',
'lib',
'arm64-v8a',
),
path.join(
workspaceRoot,
'android',
packageName,
'build',
'intermediates',
'merged_native_libs',
'debug',
'mergeDebugNativeLibs',
'out',
'lib',
'arm64-v8a',
),
];
androidLibraryPaths.push(
...logicModuleCandidates
.filter((candidatePath, index, paths) => fs.existsSync(candidatePath) && paths.indexOf(candidatePath) === index)
);
androidLibraryPaths = androidLibraryPaths.filter(
(candidatePath, index, paths) => paths.indexOf(candidatePath) === index,
);
}
switch (remotePlatform) {
case RemotePlatform.OHOS:
return fs.existsSync(hosLibraryPath) ? [hosLibraryPath] : [];
case RemotePlatform.ANDROID:
return androidLibraryPaths;
case RemotePlatform.IOS:
return iosLibraryPaths;
default:
return [];
}
}
export const cjmpDoctorWithSpawn = async (): Promise<void> => {
return new Promise((resolve, reject) => {
try {
const keelsCommand = resolveKeelsCommand();
if (!keelsCommand) {
resolve();
return;
}
const command = `${keelsCommand.executablePath} doctor -v`;
const outputChannel = vscode.window.createOutputChannel('Keels Doctor');
outputChannel.show(true);
outputChannel.append('Running keels doctor -v ...\n');
const doctorProcess = process.platform === 'win32'
? spawn('cmd', ['/c', command], { env: keelsCommand.environment })
: spawn(getDefaultShell(), ['-c', command], { env: keelsCommand.environment });
doctorProcess.stdout.on('data', (data) => {
const message = data.toString();
outputChannel.append(message);
console.log(message);
});
doctorProcess.stderr.on('data', (data) => {
const errorMessage = data.toString();
outputChannel.append(errorMessage);
console.log(errorMessage);
});
doctorProcess.on('close', async (code) => {
if (code === 0) {
vscode.window.showInformationMessage('Keels doctor command executed successfully!');
resolve();
} else {
vscode.window.showErrorMessage(
`Failed to execute "keels doctor -v". Exit code: ${code}`,
);
reject(new Error(`Failed to execute "keels doctor -v". Exit code: ${code}`));
}
});
doctorProcess.on('error', (error) => {
vscode.window.showErrorMessage(`Failed to execute command: ${error.message}`);
outputChannel.appendLine(`Error: ${error.message}`);
reject(error);
});
} catch (error) {
vscode.window.showErrorMessage(
`Error running keels doctor: ${error instanceof Error ? error.message : String(error)}`,
);
reject(error);
}
});
};
export function getAdbPath(): string {
const isWindows = process.platform === 'win32';
const adbExe = isWindows ? 'adb.exe' : 'adb';
try {
const cmd = isWindows ? `where.exe ${adbExe}` : `which ${adbExe}`;
const pathResult = execSync(cmd, { encoding: 'utf-8' }).split('\n')[0];
if (pathResult?.trim()) {
return pathResult.trim();
}
} catch {
vscode.window.showErrorMessage('Failed to find the environment variable for adb');
}
const sdkRoot = process.env.ANDROID_SDK_ROOT;
if (sdkRoot) {
const sdkPath = path.join(sdkRoot, 'platform-tools', adbExe);
if (fs.existsSync(sdkPath)) {
return sdkPath;
}
}
vscode.window.showErrorMessage(
`Could not find ${adbExe} executable. Please try either:\n` +
`1. Add adb to the system PATH environment variable.\n` +
`2. Set ANDROID_SDK_ROOT environment variable to your SDK root directory.`,
);
return '';
}
export function getDefaultShell(): string {
const defaultShell = process.platform === "darwin" ? "/bin/zsh" : "/bin/bash";
return process.env.SHELL ?? defaultShell;
}
export function getProjectConf(): string | undefined {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
return '';
}
let projectConf = vscode.Uri.joinPath(workspaceFolder.uri, 'project.conf');
if (!fs.existsSync(projectConf.fsPath)) {
return '';
}
return projectConf.fsPath;
}
export function getProjectType(): string | undefined {
let projectConf = getProjectConf();
if (!projectConf) {
return;
}
const fileContent = fs.readFileSync(projectConf, 'utf-8');
const config = ini.parse(fileContent);
return config.project.type;
}
let stripAnsi: (str: string) => string;
export async function activateStripAnsi() {
stripAnsi = (await import('strip-ansi')).default;
}
const _orig = (vscode.window.createOutputChannel) as any;
vscode.window.createOutputChannel = function (
name: string,
options?: string | { log: true }
) {
const ch = _orig(name, options);
if (!('append' in ch)) return ch;
const _append = ch.append.bind(ch);
const _appendLine = ch.appendLine.bind(ch);
ch.append = (val: string) => _append(stripAnsi(val));
ch.appendLine = (val: string) => _appendLine(stripAnsi(val));
return ch;
};
export function checkIsValid(val: unknown): boolean {
if (val === null || val === undefined) {
return false;
}
if (typeof val === 'string' && val === '') {
return false;
}
if (typeof val === 'number' && val === 0) {
return false;
}
if (typeof val === 'boolean') {
return val;
}
return true;
}
export function getOs(): OS {
switch (os.platform()) {
case 'win32':
return 'win';
default:
return 'mac';
}
}
export async function configDefaultEnvToSettings(): Promise<void> {
const sdkRoot = resolveCjmpSdkRoot({ reportErrors: false });
if (!sdkRoot) {
return;
}
await resolveAndWriteEnvVars(sdkRoot);
}
function isInsideWorkspace(filePath: string, workspaceFolders: readonly vscode.WorkspaceFolder[]): boolean {
return workspaceFolders.some((folder) => {
const relative = path.relative(folder.uri.fsPath, filePath);
return !relative.startsWith('..') && !path.isAbsolute(relative);
});
}
export async function waitForCommand(command: string, timeoutMs: number = 30000, intervalMs: number = 100): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const commands = await vscode.commands.getCommands(true);
if (commands.includes(command)) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
return false;
}
export async function configureCjpmTomlPath(): Promise<void> {
let workspaceFolders = vscode.workspace.workspaceFolders;
if (!workspaceFolders || workspaceFolders.length === 0) {
return;
}
let projectRoot = workspaceFolders[0].uri.fsPath;
let config = vscode.workspace.getConfiguration();
const existingPath = config.inspect<string>('Cangjie.Root.Cjpm.Path')?.workspaceValue;
if (existingPath && fs.existsSync(existingPath) && isInsideWorkspace(existingPath, workspaceFolders)) {
return;
}
let cjpmTomlPath = path.join(projectRoot, 'tests', 'cjpm.toml');
if (!fs.existsSync(cjpmTomlPath)) {
cjpmTomlPath = path.join(projectRoot, 'lib', 'cjpm.toml');
}
if (!fs.existsSync(cjpmTomlPath)) {
return;
}
try {
await config.update('Cangjie.Root.Cjpm.Path', cjpmTomlPath, vscode.ConfigurationTarget.Workspace,);
if (await waitForCommand('cangjie.lsp.reLaunch')) {
await vscode.commands.executeCommand('cangjie.lsp.reLaunch');
}
} catch {
}
}
export function getDevecoCangjiePath(): string | undefined {
const devecoCangjiePath = process.env.DEVECO_CANGJIE_PATH;
if (!devecoCangjiePath || !fs.existsSync(devecoCangjiePath)) {
vscode.window.showErrorMessage(
"DEVECO_CANGJIE_PATH is not set or the path does not exist. Please configure it and restart VS Code."
);
return undefined;
}
return devecoCangjiePath;
}
export function getOhosVersion(sdkPath?: string): string | undefined {
const root = sdkPath ?? getDevecoCangjiePath();
if (!root) {
return undefined;
}
const legacyPkg = path.join(root, "uni-package.json");
const pkgPath = fs.existsSync(legacyPkg)
? legacyPkg
: path.join(root, "oh-uni-package.json");
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
return typeof pkg.version === "string" ? pkg.version : undefined;
} catch {
return undefined;
}
}