f9d8142068
启动客户端时先检查平台登录态,未登录展示登录页,登录后进入启动器首页 独立客户端登录使用平台 auth 接口并对 refresh 做并发去重 Tauri 开发启动改为短命令 agc:serve,并先拉起配套 SpacetimeDB 与 api-server AGC Vite 代理读取 dev-stack 状态并暴露本地 marker,复用时校验 API target 恢复启动器项目目录输入、最近项目刷新移除和非空文件夹提醒交互 补充启动器和登录 gate 测试,并更新 AI 游戏创作 App 开发文档
284 lines
7.7 KiB
JavaScript
284 lines
7.7 KiB
JavaScript
import { spawn } from 'node:child_process';
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import http from 'node:http';
|
|
import { resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
const repoRoot = resolve(appRoot, '../..');
|
|
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
|
|
const viteHost = '127.0.0.1';
|
|
const vitePort = 3080;
|
|
const viteUrl = `http://${viteHost}:${vitePort}/`;
|
|
const viteMarkerUrl = `${viteUrl}__agc_dev_server.json`;
|
|
const defaultApiTarget = process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082';
|
|
const backendDatabase = 'genarrative-game-creator-dev';
|
|
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
|
|
function readJson(path) {
|
|
if (!existsSync(path)) {
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function httpGetText(url, timeout = 1000) {
|
|
return new Promise((resolveRequest) => {
|
|
const request = http.get(url, { timeout }, (response) => {
|
|
let body = '';
|
|
response.setEncoding('utf8');
|
|
response.on('data', (chunk) => {
|
|
if (body.length < 4096) {
|
|
body += chunk;
|
|
}
|
|
});
|
|
response.on('end', () => {
|
|
resolveRequest({
|
|
statusCode: response.statusCode ?? 0,
|
|
body,
|
|
});
|
|
});
|
|
});
|
|
request.on('timeout', () => {
|
|
request.destroy();
|
|
resolveRequest(null);
|
|
});
|
|
request.on('error', () => resolveRequest(null));
|
|
});
|
|
}
|
|
|
|
async function isHttpReady(url) {
|
|
const response = await httpGetText(url);
|
|
return Boolean(response && response.statusCode >= 200 && response.statusCode < 300);
|
|
}
|
|
|
|
function readBackendTargets({ requireAgcDatabase = false } = {}) {
|
|
const state = readJson(devStackStatePath);
|
|
const apiServer = state?.services?.['api-server'];
|
|
const spacetime = state?.services?.spacetime;
|
|
const isActive = (service) =>
|
|
service && ['running', 'reused', 'starting'].includes(service.status ?? '');
|
|
const database = typeof state?.database === 'string' ? state.database : '';
|
|
const hasMatchingDatabase = database === backendDatabase;
|
|
const canReuseState = !requireAgcDatabase || hasMatchingDatabase;
|
|
const apiUrl =
|
|
canReuseState && isActive(apiServer) && apiServer.url
|
|
? apiServer.url
|
|
: requireAgcDatabase
|
|
? ''
|
|
: defaultApiTarget;
|
|
const spacetimeUrl =
|
|
canReuseState && isActive(spacetime) && spacetime.url
|
|
? spacetime.url
|
|
: requireAgcDatabase
|
|
? ''
|
|
: 'http://127.0.0.1:3101';
|
|
return {
|
|
apiUrl,
|
|
spacetimeUrl,
|
|
database,
|
|
hasMatchingDatabase,
|
|
};
|
|
}
|
|
|
|
async function isBackendReady() {
|
|
const { apiUrl, spacetimeUrl, hasMatchingDatabase } = readBackendTargets({
|
|
requireAgcDatabase: true,
|
|
});
|
|
return (
|
|
hasMatchingDatabase &&
|
|
Boolean(apiUrl) &&
|
|
Boolean(spacetimeUrl) &&
|
|
(await isHttpReady(`${apiUrl}/healthz`)) &&
|
|
(await isHttpReady(`${spacetimeUrl}/v1/ping`))
|
|
);
|
|
}
|
|
|
|
async function readExistingViteServer() {
|
|
return httpGetText(viteUrl);
|
|
}
|
|
|
|
function isAiGameCreatorServer(response) {
|
|
return (
|
|
response &&
|
|
response.statusCode >= 200 &&
|
|
response.statusCode < 500 &&
|
|
response.body.includes('<title>AI 游戏创作</title>') &&
|
|
response.body.includes('/src/main.tsx')
|
|
);
|
|
}
|
|
|
|
async function isExistingViteProxyReady() {
|
|
const response = await httpGetText(`${viteUrl}api/auth/me`, 2000);
|
|
return Boolean(
|
|
response &&
|
|
response.statusCode >= 200 &&
|
|
response.statusCode < 500 &&
|
|
!response.body.includes('<title>AI 游戏创作</title>') &&
|
|
!response.body.includes('/src/main.tsx'),
|
|
);
|
|
}
|
|
|
|
async function readExistingViteMarker() {
|
|
const response = await httpGetText(viteMarkerUrl, 2000);
|
|
if (!response || response.statusCode !== 200) {
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(response.body);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function isExistingVitePairedWithBackend(apiTarget) {
|
|
const marker = await readExistingViteMarker();
|
|
return Boolean(
|
|
marker &&
|
|
marker.schemaVersion === 1 &&
|
|
marker.app === 'ai-game-creator-shell' &&
|
|
marker.apiTarget === apiTarget,
|
|
);
|
|
}
|
|
|
|
function spawnChild(command, args, options) {
|
|
return spawn(command, args, {
|
|
...options,
|
|
shell: true,
|
|
stdio: 'inherit',
|
|
});
|
|
}
|
|
|
|
function stopChild(child, signal = 'SIGTERM') {
|
|
if (!child || child.exitCode != null || child.signalCode != null) {
|
|
return;
|
|
}
|
|
try {
|
|
child.kill(signal);
|
|
} catch {
|
|
// ignore cleanup races
|
|
}
|
|
}
|
|
|
|
async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
|
|
const startedAt = Date.now();
|
|
let backendExit = null;
|
|
backendChild?.on('exit', (code, signal) => {
|
|
backendExit = signal ? `signal=${signal}` : `code=${code ?? 0}`;
|
|
});
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
if (await isBackendReady()) {
|
|
return readBackendTargets();
|
|
}
|
|
if (backendExit) {
|
|
throw new Error(`配套后端启动失败: ${backendExit}`);
|
|
}
|
|
await new Promise((resolveWait) => setTimeout(resolveWait, 1000));
|
|
}
|
|
throw new Error('等待配套后端和数据库启动超时');
|
|
}
|
|
|
|
async function ensureBackend() {
|
|
if (await isBackendReady()) {
|
|
const targets = readBackendTargets();
|
|
console.log(`[ai-game-creator-shell] reuse backend ${targets.apiUrl}`);
|
|
return { backendChild: null, targets };
|
|
}
|
|
|
|
console.log('[ai-game-creator-shell] starting backend stack');
|
|
const backendChild = spawnChild(
|
|
npm,
|
|
[
|
|
'--prefix',
|
|
'../..',
|
|
'run',
|
|
'agc:backend',
|
|
'--',
|
|
'--database',
|
|
backendDatabase,
|
|
'--no-interactive',
|
|
],
|
|
{ cwd: appRoot },
|
|
);
|
|
const targets = await waitForBackendReady(backendChild);
|
|
console.log(`[ai-game-creator-shell] backend ready ${targets.apiUrl}`);
|
|
return { backendChild, targets };
|
|
}
|
|
|
|
async function startVite(apiTarget) {
|
|
const { apiUrl } = readBackendTargets();
|
|
if (apiUrl !== apiTarget) {
|
|
throw new Error(
|
|
`dev-stack state API target ${apiUrl} does not match paired backend ${apiTarget}.`,
|
|
);
|
|
}
|
|
|
|
const existing = await readExistingViteServer();
|
|
if (existing) {
|
|
if (
|
|
isAiGameCreatorServer(existing) &&
|
|
(await isExistingVitePairedWithBackend(apiTarget)) &&
|
|
(await isExistingViteProxyReady())
|
|
) {
|
|
console.log(`[ai-game-creator-shell] reuse existing Vite dev server ${viteUrl}`);
|
|
return null;
|
|
}
|
|
if (isAiGameCreatorServer(existing)) {
|
|
throw new Error(
|
|
`${viteUrl} is already running, but its /api proxy is not connected to the paired backend. Stop it before starting Tauri dev.`,
|
|
);
|
|
}
|
|
throw new Error(
|
|
`${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`,
|
|
);
|
|
}
|
|
|
|
return spawnChild(
|
|
npm,
|
|
['--prefix', '../..', 'exec', 'vite', '--', '--config', 'vite.config.ts'],
|
|
{ cwd: appRoot },
|
|
);
|
|
}
|
|
|
|
let backendChild = null;
|
|
let viteChild = null;
|
|
|
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
process.on(signal, () => {
|
|
stopChild(viteChild, signal);
|
|
stopChild(backendChild, signal);
|
|
});
|
|
}
|
|
|
|
try {
|
|
const backend = await ensureBackend();
|
|
backendChild = backend.backendChild;
|
|
viteChild = await startVite(backend.targets.apiUrl);
|
|
|
|
const children = [backendChild, viteChild].filter(Boolean);
|
|
if (children.length === 0) {
|
|
process.exit(0);
|
|
}
|
|
|
|
await new Promise((resolveExit) => {
|
|
for (const child of children) {
|
|
child.on('exit', (code, signal) => {
|
|
stopChild(viteChild);
|
|
stopChild(backendChild);
|
|
resolveExit(signal ? 1 : code ?? 0);
|
|
});
|
|
}
|
|
}).then((code) => process.exit(code));
|
|
} catch (error) {
|
|
stopChild(viteChild);
|
|
stopChild(backendChild);
|
|
console.error(
|
|
`[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
process.exit(1);
|
|
}
|