feat: prepare Zhinian desktop pilot
This commit is contained in:
431
electron/utils/nianxx-play-service.ts
Normal file
431
electron/utils/nianxx-play-service.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user