Files
NianToB/electron/utils/nianxx-play-service.ts
inman 0abc48189c
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: prepare Zhinian desktop pilot
2026-05-07 21:49:20 +08:00

432 lines
15 KiB
TypeScript

import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { app } from 'electron';
import { createServer } from 'node:net';
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { logger } from './logger';
const DEFAULT_NIANXX_PLAY_PORT = 3000;
const STARTUP_TIMEOUT_MS = 20_000;
const STARTUP_POLL_INTERVAL_MS = 500;
const HEALTH_PATH = '/api/desktop/health';
const RUNTIME_ENV_FILE_NAME = '.env.runtime';
type ProcessWithResourcesPath = NodeJS.Process & { resourcesPath?: string };
type NianxxPlayRuntimeKind = 'source' | 'standalone';
interface NianxxPlayRuntime {
kind: NianxxPlayRuntimeKind;
dir: string;
serverPath?: string;
}
interface NianxxPlayHealthPayload {
appId?: unknown;
ok?: unknown;
desktopManaged?: unknown;
}
export interface NianxxPlayServiceStatus {
success: boolean;
running: boolean;
starting: boolean;
managed: boolean;
baseUrl: string;
port: number;
projectDir?: string;
runtimeKind?: NianxxPlayRuntimeKind;
pid?: number;
error?: string;
}
let nianxxPlayProcess: ChildProcessWithoutNullStreams | null = null;
let lastServiceError: string | null = null;
let activePort: number | null = null;
function getConfiguredPort(): number {
const raw = process.env.NIANXX_PLAY_PORT?.trim();
if (!raw) return DEFAULT_NIANXX_PLAY_PORT;
const parsed = Number(raw);
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_NIANXX_PLAY_PORT;
}
function getBaseUrl(): string {
const explicitUrl = process.env.NIANXX_PLAY_URL?.trim();
if (explicitUrl) return explicitUrl.replace(/\/$/, '');
return `http://127.0.0.1:${activePort ?? getConfiguredPort()}`;
}
function allowExternalNianxxPlayRuntime(): boolean {
return Boolean(process.env.NIANXX_PLAY_URL?.trim());
}
function getNpmCommand(): string {
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
}
function getScriptName(): string {
return process.env.NIANXX_PLAY_SCRIPT?.trim() || (process.env.NODE_ENV === 'production' ? 'start' : 'dev');
}
function getResourcePathCandidates(): string[] {
const resourcesPath = (process as ProcessWithResourcesPath).resourcesPath;
return [
process.env.NIANXX_PLAY_DIR?.trim() || '',
join(process.cwd(), '..', 'NianxxPlay'),
join(process.cwd(), 'NianxxPlay'),
join(process.cwd(), 'build', 'apps', 'nianxx-play'),
resourcesPath ? join(resourcesPath, 'nianxx-play') : '',
resourcesPath ? join(resourcesPath, 'resources', 'nianxx-play') : '',
].filter(Boolean);
}
function createRuntimeCandidate(candidate: string): NianxxPlayRuntime | undefined {
const dir = resolve(candidate);
const directStandaloneServer = join(dir, 'server.js');
const nestedStandaloneServer = join(dir, 'standalone', 'server.js');
if (existsSync(directStandaloneServer)) {
return { kind: 'standalone', dir, serverPath: directStandaloneServer };
}
if (existsSync(nestedStandaloneServer)) {
return { kind: 'standalone', dir: join(dir, 'standalone'), serverPath: nestedStandaloneServer };
}
if (existsSync(join(dir, 'package.json'))) {
return { kind: 'source', dir };
}
return undefined;
}
function resolveNianxxPlayRuntime(): NianxxPlayRuntime | undefined {
for (const candidate of getResourcePathCandidates()) {
const runtime = createRuntimeCandidate(candidate);
if (runtime) return runtime;
}
return undefined;
}
async function canReachNianxxPlay(baseUrl = getBaseUrl()): Promise<boolean> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1_500);
try {
const response = await fetch(`${baseUrl}${HEALTH_PATH}`, {
method: 'GET',
signal: controller.signal,
});
if (!response.ok) return false;
const payload = (await response.json().catch(() => undefined)) as NianxxPlayHealthPayload | undefined;
const isNianxxPlay = Boolean(payload && payload.appId === 'nianxx-play' && payload.ok);
if (!isNianxxPlay) return false;
return payload?.desktopManaged === true || allowExternalNianxxPlayRuntime();
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
function createStatus(overrides: Partial<NianxxPlayServiceStatus> = {}): NianxxPlayServiceStatus {
const runtime = resolveNianxxPlayRuntime();
const baseUrl = getBaseUrl();
return {
success: true,
running: false,
starting: Boolean(nianxxPlayProcess && !nianxxPlayProcess.killed),
managed: Boolean(nianxxPlayProcess && !nianxxPlayProcess.killed),
baseUrl,
port: activePort ?? getConfiguredPort(),
projectDir: runtime?.dir,
runtimeKind: runtime?.kind,
pid: nianxxPlayProcess?.pid,
error: lastServiceError ?? undefined,
...overrides,
};
}
function attachProcessLogger(stream: NodeJS.ReadableStream, level: 'info' | 'warn'): void {
let buffer = '';
stream.on('data', (chunk) => {
buffer += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (level === 'warn') {
logger.warn(`[nianxx-play] ${trimmed}`);
} else {
logger.info(`[nianxx-play] ${trimmed}`);
}
}
});
}
async function waitUntilReachable(baseUrl: string): Promise<boolean> {
const startedAt = Date.now();
while (Date.now() - startedAt < STARTUP_TIMEOUT_MS) {
if (await canReachNianxxPlay(baseUrl)) {
return true;
}
await delay(STARTUP_POLL_INTERVAL_MS);
}
return false;
}
async function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolveAvailable) => {
const server = createServer();
server.once('error', () => resolveAvailable(false));
server.once('listening', () => {
server.close(() => resolveAvailable(true));
});
server.listen(port, '127.0.0.1');
});
}
async function findAvailablePort(preferredPort: number): Promise<number> {
if (await isPortAvailable(preferredPort)) return preferredPort;
return new Promise((resolvePort, reject) => {
const server = createServer();
server.once('error', reject);
server.once('listening', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : preferredPort;
server.close(() => resolvePort(port));
});
server.listen(0, '127.0.0.1');
});
}
function getRuntimeDataDirs() {
const userData = app.getPath('userData');
const runtimeRoot = join(userData, 'apps', 'nianxx-play');
const dataDir = join(runtimeRoot, 'data');
const uploadDir = join(runtimeRoot, 'uploads');
const resultDir = join(runtimeRoot, 'generated-results');
mkdirSync(dataDir, { recursive: true });
mkdirSync(uploadDir, { recursive: true });
mkdirSync(resultDir, { recursive: true });
return { runtimeRoot, dataDir, uploadDir, resultDir };
}
function hasDirectoryEntries(dir: string): boolean {
try {
return readdirSync(dir).length > 0;
} catch {
return false;
}
}
function copyFileIfMissing(sourcePath: string, targetPath: string): void {
if (!existsSync(sourcePath) || existsSync(targetPath)) return;
mkdirSync(dirname(targetPath), { recursive: true });
cpSync(sourcePath, targetPath, { dereference: true });
}
function readJsonFile<T>(filePath: string): T | null {
try {
return JSON.parse(readFileSync(filePath, 'utf8')) as T;
} catch {
return null;
}
}
function getArrayLength(record: Record<string, unknown> | null, key: string): number {
const value = record?.[key];
return Array.isArray(value) ? value.length : 0;
}
function migrateStateFile(sourcePath: string, targetPath: string): void {
if (!existsSync(sourcePath)) return;
mkdirSync(dirname(targetPath), { recursive: true });
if (!existsSync(targetPath)) {
cpSync(sourcePath, targetPath, { dereference: true });
return;
}
const sourceState = readJsonFile<Record<string, unknown>>(sourcePath);
const targetState = readJsonFile<Record<string, unknown>>(targetPath);
if (!sourceState || !targetState) return;
const targetProjects = getArrayLength(targetState, 'projects');
const sourceProjects = getArrayLength(sourceState, 'projects');
if (targetProjects === 0 && sourceProjects > 0) {
writeFileSync(targetPath, JSON.stringify(sourceState, null, 2), 'utf8');
logger.info(`[nianxx-play] Migrated ${sourceProjects} project record(s) from bundled/source runtime data`);
}
}
function copyDirectoryIfEmpty(sourceDir: string, targetDir: string): void {
if (!existsSync(sourceDir) || !statSync(sourceDir).isDirectory()) return;
if (hasDirectoryEntries(targetDir)) return;
mkdirSync(targetDir, { recursive: true });
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
}
function migrateExistingRuntimeData(runtime: NianxxPlayRuntime, dirs: ReturnType<typeof getRuntimeDataDirs>): void {
try {
migrateStateFile(join(runtime.dir, '.data', 'app-state.json'), join(dirs.dataDir, 'app-state.json'));
copyDirectoryIfEmpty(join(runtime.dir, 'public', 'uploads'), dirs.uploadDir);
copyDirectoryIfEmpty(join(runtime.dir, 'public', 'generated-results'), dirs.resultDir);
} catch (error) {
logger.warn('[nianxx-play] Failed to migrate existing local runtime data:', error);
}
}
function parseRuntimeEnvValue(raw: string): string {
const value = raw.trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
try {
return JSON.parse(value);
} catch {
return value.slice(1, -1);
}
}
return value;
}
function loadBundledRuntimeEnv(runtime: NianxxPlayRuntime): Record<string, string> {
const runtimeEnvPath = join(runtime.dir, RUNTIME_ENV_FILE_NAME);
if (!existsSync(runtimeEnvPath)) return {};
try {
const values: Record<string, string> = {};
const raw = readFileSync(runtimeEnvPath, 'utf8');
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
values[match[1]] = parseRuntimeEnvValue(match[2]);
}
logger.info(`[nianxx-play] Loaded bundled runtime env (${Object.keys(values).length} values)`);
return values;
} catch (error) {
logger.warn('[nianxx-play] Failed to load bundled runtime env:', error);
return {};
}
}
function createRuntimeEnv(port: number, runtime: NianxxPlayRuntime) {
const dirs = getRuntimeDataDirs();
migrateExistingRuntimeData(runtime, dirs);
const bundledRuntimeEnv = loadBundledRuntimeEnv(runtime);
return {
...process.env,
...bundledRuntimeEnv,
PORT: String(port),
HOSTNAME: '127.0.0.1',
NEXT_TELEMETRY_DISABLED: '1',
NIANXXPLAY_RUNTIME_DIR: dirs.runtimeRoot,
NIANXXPLAY_DATA_DIR: dirs.dataDir,
NIANXXPLAY_UPLOAD_DIR: dirs.uploadDir,
NIANXXPLAY_RESULT_DIR: dirs.resultDir,
NIANXXPLAY_PUBLIC_BASE_URL: `http://127.0.0.1:${port}`,
NIANXXPLAY_DESKTOP_MANAGED: '1',
};
}
function spawnSourceRuntime(runtime: NianxxPlayRuntime, port: number): ChildProcessWithoutNullStreams {
const scriptName = getScriptName();
logger.info(`[nianxx-play] Starting source service: npm run ${scriptName} (cwd=${runtime.dir}, port=${port})`);
return spawn(getNpmCommand(), ['run', scriptName], {
cwd: runtime.dir,
env: createRuntimeEnv(port, runtime),
stdio: ['ignore', 'pipe', 'pipe'],
shell: false,
});
}
function spawnStandaloneRuntime(runtime: NianxxPlayRuntime, port: number): ChildProcessWithoutNullStreams {
if (!runtime.serverPath) {
throw new Error('NianxxPlay standalone server path is missing.');
}
logger.info(`[nianxx-play] Starting bundled service: ${runtime.serverPath} (port=${port})`);
return spawn(process.execPath, [runtime.serverPath], {
cwd: runtime.dir,
env: {
...createRuntimeEnv(port, runtime),
ELECTRON_RUN_AS_NODE: '1',
NODE_ENV: 'production',
},
stdio: ['ignore', 'pipe', 'pipe'],
shell: false,
});
}
function attachLifecycleHandlers(): void {
if (!nianxxPlayProcess) return;
attachProcessLogger(nianxxPlayProcess.stdout, 'info');
attachProcessLogger(nianxxPlayProcess.stderr, 'warn');
nianxxPlayProcess.once('exit', (code, signal) => {
const reason = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`;
logger.warn(`[nianxx-play] Service exited with ${reason}`);
if (code && code !== 0) {
lastServiceError = `NianxxPlay exited with ${reason}`;
}
nianxxPlayProcess = null;
activePort = null;
});
nianxxPlayProcess.once('error', (error) => {
lastServiceError = error.message;
logger.warn('[nianxx-play] Failed to start service:', error);
nianxxPlayProcess = null;
activePort = null;
});
}
export async function getNianxxPlayServiceStatus(): Promise<NianxxPlayServiceStatus> {
const running = await canReachNianxxPlay();
return createStatus({
running,
error: running ? undefined : (lastServiceError ?? undefined),
});
}
export async function ensureNianxxPlayServiceStarted(): Promise<NianxxPlayServiceStatus> {
const baseUrl = getBaseUrl();
if (await canReachNianxxPlay(baseUrl)) {
lastServiceError = null;
return createStatus({ running: true, starting: false, managed: Boolean(nianxxPlayProcess), error: undefined });
}
const runtime = resolveNianxxPlayRuntime();
if (!runtime) {
lastServiceError = 'NianxxPlay runtime was not found.';
return createStatus({ success: false, running: false, starting: false, error: lastServiceError });
}
if (!nianxxPlayProcess || nianxxPlayProcess.killed) {
const port = await findAvailablePort(getConfiguredPort());
activePort = port;
nianxxPlayProcess = runtime.kind === 'standalone'
? spawnStandaloneRuntime(runtime, port)
: spawnSourceRuntime(runtime, port);
attachLifecycleHandlers();
}
const running = await waitUntilReachable(getBaseUrl());
if (!running) {
lastServiceError = `NianxxPlay did not become ready within ${Math.round(STARTUP_TIMEOUT_MS / 1000)}s.`;
} else {
lastServiceError = null;
}
return createStatus({
running,
starting: !running && Boolean(nianxxPlayProcess && !nianxxPlayProcess.killed),
managed: Boolean(nianxxPlayProcess && !nianxxPlayProcess.killed),
error: running ? undefined : (lastServiceError ?? undefined),
});
}
export function stopNianxxPlayService(): void {
if (!nianxxPlayProcess || nianxxPlayProcess.killed) return;
logger.info('[nianxx-play] Stopping service');
nianxxPlayProcess.kill();
nianxxPlayProcess = null;
activePort = null;
}