432 lines
15 KiB
TypeScript
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;
|
|
}
|