import iconv from "iconv-lite"; import {exec as _exec, spawn} from "node:child_process"; import net from "node:net"; import util from "node:util"; import {AppConfig} from "../../../src/config"; import { extraResolveBin, isLinux, isMac, isWin, platformArch, platformName, platformUUID, platformVersion, } from "../../lib/env"; import {IconvUtil, ShellUtil, StrUtil} from "../../lib/util"; import {Log} from "../log/index"; const exec = util.promisify(_exec); const outputStringConvert = (outputEncoding: "utf8" | "cp936", data: any) => { if (!data) { return ""; } if (outputEncoding === "utf8") { return data.toString(); } let dataEncoding = "binary"; if (Buffer.isBuffer(data)) { dataEncoding = IconvUtil.detect(data as any); if ("UTF-8" === dataEncoding) { return data.toString("utf8"); } } // dataEncoding UTF-8 cp936 // dataEncoding ISO-8859-1 cp936 // console.log('dataEncoding', dataEncoding, outputEncoding) return iconv.decode(Buffer.from(data, dataEncoding as any), outputEncoding); }; const shell = async ( command: string, option?: { cwd?: string; outputEncoding?: string; shell?: boolean; } ) => { option = Object.assign( { cwd: process.cwd(), outputEncoding: isWin ? "cp936" : "utf8", shell: true, }, option ); const result = await exec(command, { env: {...process.env}, shell: option.shell, encoding: "binary", cwd: option["cwd"], } as any); return { stdout: outputStringConvert(option.outputEncoding as any, result.stdout), stderr: outputStringConvert(option.outputEncoding as any, result.stderr), }; }; const spawnShell = async ( command: string | string[], option: { stdout?: (data: string, process: any) => void; stderr?: (data: string, process: any) => void; success?: (process: any) => void; error?: (msg: string, exitCode: number, process: any) => void; cwd?: string; outputEncoding?: string; env?: Record; shell?: boolean; } | null = null ): Promise<{ stop: () => Promise; send: (data: any) => void; result: () => Promise; }> => { option = Object.assign( { cwd: process.cwd(), outputEncoding: isWin ? "cp936" : "utf8", env: {}, shell: true, }, option ); let commandEntry = "", args = []; if (Array.isArray(command)) { commandEntry = command[0]; args = command.slice(1); } else { args = ShellUtil.parseCommandArgs(command); commandEntry = args.shift() as string; } Log.info("App.spawnShell", { commandEntry, args, option: { cwd: option["cwd"], outputEncoding: option["outputEncoding"], }, }); const spawnProcess = spawn(commandEntry, args, { env: {...process.env, ...option.env}, cwd: option["cwd"], shell: option.shell, encoding: "binary", } as any); let end = false; let isSuccess = false; let exitCode = -1; const stdoutList: string[] = []; const stderrList: string[] = []; spawnProcess.stdout?.on("data", data => { // console.log('App.spawnShell.stdout', data) let dataString = outputStringConvert(option.outputEncoding as any, data); Log.info("App.spawnShell.stdout", dataString); stdoutList.push(dataString); option.stdout?.(dataString, spawnProcess); }); spawnProcess.stderr?.on("data", data => { // console.log('App.spawnShell.stderr', data) let dataString = outputStringConvert(option.outputEncoding as any, data); Log.info("App.spawnShell.stderr", dataString); stderrList.push(dataString); option.stderr?.(dataString, spawnProcess); }); spawnProcess.on("exit", (code, signal) => { // console.log('App.spawnShell.exit', code) Log.info("App.spawnShell.exit", {code, signal}); exitCode = code; if (isWin) { if (0 === code || 1 === code) { isSuccess = true; } } else { if (null === code || 0 === code) { isSuccess = true; } } if (isSuccess) { option.success?.(spawnProcess); } else { option.error?.(`command ${command} failed with code ${code}`, exitCode, spawnProcess); } end = true; }); spawnProcess.on("error", err => { // console.log('App.spawnShell.error', err) Log.info("App.spawnShell.error", err); option.error?.(err.toString(), -1, spawnProcess); end = true; }); const waitForExit = async () => { if (end) { return; } await new Promise(resolve => { let settled = false; const finish = () => { if (settled) { return; } settled = true; clearTimeout(timeoutId); spawnProcess.off("exit", finish); spawnProcess.off("error", finish); resolve(); }; const timeoutId = setTimeout(finish, 5000); spawnProcess.once("exit", finish); spawnProcess.once("error", finish); }); }; return { stop: async () => { Log.info("App.spawnShell.stop"); if (end) { return; } if (isWin) { await new Promise(resolve => { _exec( `taskkill /pid ${spawnProcess.pid} /T /F`, { encoding: "binary", }, (err, stdout, stderr) => { if (stdout) { stdout = outputStringConvert(option.outputEncoding as any, stdout); } if (stderr) { stderr = outputStringConvert(option.outputEncoding as any, stderr); } Log.info("App.spawnShell.stop.taskkill", JSON.parse(JSON.stringify({err, stdout, stderr}))); resolve(); } ); }); } else { spawnProcess.kill("SIGINT"); } await waitForExit(); }, send: data => { Log.info("App.spawnShell.send", data); spawnProcess.stdin.write(data); }, result: async (): Promise => { if (end) { return stdoutList.join("") + stderrList.join(""); } return new Promise((resolve, reject) => { const watchEnd = () => { setTimeout(() => { if (!end) { watchEnd(); return; } if (isSuccess) { resolve(stdoutList.join("") + stderrList.join("")); } else { reject( [ `command ${command} failed with code ${exitCode} : `, stdoutList.join(""), stderrList.join(""), ].join("") ); } }, 10); }; watchEnd(); }); }, }; }; const spawnBinary = async ( binary: string, args: string[], option: { stdout?: (data: string, process: any) => void; stderr?: (data: string, process: any) => void; success?: (process: any) => void; error?: (msg: string, exitCode: number, process: any) => void; cwd?: string; outputEncoding?: string; env?: Record; shell?: boolean; } | null = null ): Promise => { try { args.unshift(extraResolveBin(binary)); } catch (error) { // 如果extraResolveBin抛出错误,通过error回调通知调用者 if (option?.error) { option.error(error.message, -1, null); } throw error; } const res = await Apps.spawnShell(args, { ...(option || {}), shell: false, }); return await res.result(); }; const availablePortLock: { [port: number]: { lockKey: string; lockTime: number; }; } = {}; /** * 获取一个可用的端口 * @param start 开始的端口 * @param lockKey 锁定的key,避免其他进程获取,默认会创建一个随机的key * @param lockTime 锁定时间,避免在本次获取后未启动服务导致其他进程重复获取 */ const availablePort = async (start: number, lockKey?: string, lockTime?: number): Promise => { lockKey = lockKey || StrUtil.randomString(8); lockTime = lockTime || 60; // expire lock const now = Date.now(); for (const port in availablePortLock) { const lockInfo = availablePortLock[port]; if (lockInfo.lockTime < now) { delete availablePortLock[port]; } } for (let i = start; i < 65535; i++) { const available = await isPortAvailable(i, "0.0.0.0"); const availableLocal = await isPortAvailable(i, "127.0.0.1"); // console.log('isPortAvailable', i, available, availableLocal) if (available && availableLocal) { const lockInfo = availablePortLock[i]; if (lockInfo) { if (lockInfo.lockKey === lockKey) { return i; } else { // other lockKey lock the port continue; } } availablePortLock[i] = { lockKey, lockTime: Date.now() + lockTime * 1000, }; return i; } } throw new Error("no available port"); }; const isPortAvailable = async (port: number, host?: string): Promise => { return new Promise(resolve => { const server = net.createServer(); server.listen(port, host); server.on("listening", () => { server.close(); resolve(true); }); server.on("error", () => { resolve(false); }); }); }; const fixExecutable = async (executable: string) => { if (isMac || isLinux) { // chmod +x executable await shell(`chmod +x "${executable}"`); } }; const getUserAgent = () => { let param = []; // URL-encode app name to handle Chinese characters in HTTP headers const appName = encodeURIComponent(AppConfig.name); param.push(`AppOpen/${appName}/${AppConfig.version}`); param.push(`Platform/${platformName()}/${platformArch()}/${platformVersion()}/${platformUUID()}`); return param.join(" "); }; export const Apps = { shell, spawnShell, spawnBinary, availablePort, isPortAvailable, fixExecutable, getUserAgent, }; export default Apps;