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 { 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 { 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 { 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 { 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 { 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(filePath: string): T | null { try { return JSON.parse(readFileSync(filePath, 'utf8')) as T; } catch { return null; } } function getArrayLength(record: Record | 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>(sourcePath); const targetState = readJsonFile>(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): 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 { const runtimeEnvPath = join(runtime.dir, RUNTIME_ENV_FILE_NAME); if (!existsSync(runtimeEnvPath)) return {}; try { const values: Record = {}; 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 { const running = await canReachNianxxPlay(); return createStatus({ running, error: running ? undefined : (lastServiceError ?? undefined), }); } export async function ensureNianxxPlayServiceStarted(): Promise { 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; }