hololake-system-architecture/product-source/hololake-desktop/electron/main.ts

695 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* HoloLake Desktop · Electron 主进程
*
* 职责:
* 1. 创建应用窗口
* 2. 启动知识库后端服务器Express + Git 引擎)
* 3. 管理应用生命周期
*
* 架构:
* Electron Main Process
* ├── 启动 Express 服务器Git 引擎层,端口 3890
* ├── 创建 BrowserWindow加载 Vite 前端或打包后的 dist
* └── 数据存储:~/Library/Application Support/HoloLake Era/data/
*/
import { app, BrowserWindow, shell, dialog, ipcMain, safeStorage } from 'electron';
import path from 'path';
import { spawn, ChildProcess } from 'child_process';
import fs from 'fs';
import { importKnowledgeFolder } from './folder-import.js';
// ─── 配置 ───
const isDev = !app.isPackaged;
const SERVER_PORT = 3890;
const CLIENT_PORT = 5180;
// 数据目录macOS 标准位置
const DATA_DIR = path.join(app.getPath('userData'), 'data');
const KB_REPO_PATH = path.join(DATA_DIR, 'knowledge-base');
const MODEL_CONFIG_PATH = path.join(app.getPath('userData'), 'model-config.json');
const SERVER_AUTH_PATH = path.join(app.getPath('userData'), 'server-auth.json');
const SERVER_PROFILES_PATH = path.join(app.getPath('userData'), 'server-profiles.json');
const AGENT_STATE_PATH = path.join(app.getPath('userData'), 'agent-conversations.json');
const GIT_ASKPASS_PATH = path.join(app.getPath('userData'), 'hololake-git-askpass.sh');
interface ServerProfileDefinition {
id: string;
physicalNodeId: string;
name: string;
purpose: 'personal-fifth-domain' | 'enterprise-lighthouse';
sshAlias: string;
tunnelPort: number;
remoteForgejoPort: number;
lighthouseTunnelPort?: number;
remoteLighthousePort?: number;
channelTitle?: string;
channelSubtitle?: string;
}
const PUBLIC_SERVER_PROFILES: Record<string, ServerProfileDefinition> = {
'AW-GZ-001': {
id: 'AW-GZ-001',
physicalNodeId: 'GH-CVM-MAIN-PROD-01',
name: '企业灯塔服务器',
purpose: 'enterprise-lighthouse',
sshAlias: 'gh-enterprise-main',
tunnelPort: 13341,
remoteForgejoPort: 3341,
lighthouseTunnelPort: 18031,
remoteLighthousePort: 8031,
},
};
function readServerProfiles(): Record<string, ServerProfileDefinition> {
const profiles = { ...PUBLIC_SERVER_PROFILES };
try {
const localProfiles = JSON.parse(fs.readFileSync(SERVER_PROFILES_PATH, 'utf8')) as ServerProfileDefinition[];
if (!Array.isArray(localProfiles)) return profiles;
for (const profile of localProfiles) {
if (
!profile || typeof profile.id !== 'string' || typeof profile.physicalNodeId !== 'string'
|| typeof profile.name !== 'string' || profile.purpose !== 'personal-fifth-domain'
|| typeof profile.sshAlias !== 'string' || !Number.isInteger(profile.tunnelPort)
|| !Number.isInteger(profile.remoteForgejoPort)
) continue;
profiles[profile.id] = profile;
}
} catch {
// 新安装默认只知道企业公共灯塔;个人节点由本机私有配置登记,不进入安装包。
}
return profiles;
}
function getServerProfile(nodeId: string): ServerProfileDefinition {
const profile = readServerProfiles()[nodeId];
if (!profile) throw new Error('未登记的服务器节点');
return profile;
}
function defaultPersonalServerId(): string | undefined {
return Object.values(readServerProfiles()).find(profile => profile.purpose === 'personal-fifth-domain')?.id;
}
function forgejoBaseUrl(profile: ServerProfileDefinition): string {
return `http://127.0.0.1:${profile.tunnelPort}`;
}
function lighthouseBaseUrl(profile: ServerProfileDefinition): string {
if (!profile.lighthouseTunnelPort) throw new Error('该节点未登记灯塔服务');
return `http://127.0.0.1:${profile.lighthouseTunnelPort}`;
}
interface ModelConfigFile {
baseUrl: string;
model: string;
encryptedKey?: string;
verifiedAt?: string;
}
interface ServerAuthFile {
nodeId: string;
username: string;
encryptedToken: string;
}
function writeGitAskPass(): void {
const content = `#!/bin/sh
case "$1" in
*Username*) printf '%s\\n' "$HOLOLAKE_FORGEJO_USERNAME" ;;
*) printf '%s\\n' "$HOLOLAKE_FORGEJO_TOKEN" ;;
esac
`;
fs.mkdirSync(path.dirname(GIT_ASKPASS_PATH), { recursive: true });
fs.writeFileSync(GIT_ASKPASS_PATH, content, { mode: 0o700 });
}
function clearGitCredentialEnvironment(): void {
delete process.env.HOLOLAKE_FORGEJO_USERNAME;
delete process.env.HOLOLAKE_FORGEJO_TOKEN;
delete process.env.GIT_ASKPASS;
delete process.env.GIT_TERMINAL_PROMPT;
}
function applyStoredServerAuth(): { nodeId: string; username: string; token: string } | null {
try {
if (!safeStorage.isEncryptionAvailable()) return null;
const stored = JSON.parse(fs.readFileSync(SERVER_AUTH_PATH, 'utf8')) as ServerAuthFile;
getServerProfile(stored.nodeId);
if (!stored.encryptedToken) return null;
const token = safeStorage.decryptString(Buffer.from(stored.encryptedToken, 'base64'));
writeGitAskPass();
process.env.HOLOLAKE_FORGEJO_USERNAME = stored.username;
process.env.HOLOLAKE_FORGEJO_TOKEN = token;
process.env.GIT_ASKPASS = GIT_ASKPASS_PATH;
process.env.GIT_TERMINAL_PROMPT = '0';
return { nodeId: stored.nodeId, username: stored.username, token };
} catch {
clearGitCredentialEnvironment();
return null;
}
}
function saveServerAuth(nodeId: string, username: string, token: string): void {
if (!safeStorage.isEncryptionAvailable()) throw new Error('macOS 加密存储当前不可用');
fs.mkdirSync(path.dirname(SERVER_AUTH_PATH), { recursive: true });
const encryptedToken = safeStorage.encryptString(token).toString('base64');
fs.writeFileSync(
SERVER_AUTH_PATH,
JSON.stringify({ nodeId, username, encryptedToken } satisfies ServerAuthFile),
{ mode: 0o600 },
);
applyStoredServerAuth();
}
function readModelConfig(): ModelConfigFile {
try {
return JSON.parse(fs.readFileSync(MODEL_CONFIG_PATH, 'utf8')) as ModelConfigFile;
} catch {
return { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o' };
}
}
function applyModelConfig(): ModelConfigFile {
const config = readModelConfig();
process.env.HOLOLAKE_LLM_BASE = config.baseUrl;
process.env.HOLOLAKE_LLM_MODEL = config.model;
if (config.encryptedKey && safeStorage.isEncryptionAvailable()) {
process.env.HOLOLAKE_LLM_KEY = safeStorage.decryptString(Buffer.from(config.encryptedKey, 'base64'));
} else {
delete process.env.HOLOLAKE_LLM_KEY;
}
process.env.HOLOLAKE_LLM_VERIFIED = config.verifiedAt ? '1' : '0';
return config;
}
async function verifyModelConfig(): Promise<{ verifiedAt: string }> {
const config = readModelConfig();
if (!config.encryptedKey || !safeStorage.isEncryptionAvailable()) throw new Error('请先保存模型密钥');
const apiKey = safeStorage.decryptString(Buffer.from(config.encryptedKey, 'base64'));
const response = await fetch(`${config.baseUrl}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: config.model,
messages: [
{ role: 'system', content: 'This is a HoloLake model connectivity check. Reply with OK.' },
{ role: 'user', content: 'ping' },
],
max_tokens: 8,
temperature: 0,
}),
signal: AbortSignal.timeout(20000),
});
const data = await response.json().catch(() => null) as any;
if (!response.ok) throw new Error(data?.error?.message || `模型服务返回 HTTP ${response.status}`);
if (!data?.choices?.[0]?.message) throw new Error('模型服务没有返回兼容的响应');
const verifiedAt = new Date().toISOString();
fs.writeFileSync(MODEL_CONFIG_PATH, JSON.stringify({ ...config, verifiedAt }), { mode: 0o600 });
applyModelConfig();
return { verifiedAt };
}
// ─── 后端服务器 ───
let serverProcess: ChildProcess | null = null;
const serverTunnels = new Map<string, ChildProcess>();
async function isServerTunnelReady(profile: ServerProfileDefinition): Promise<boolean> {
try {
const checks: Promise<Response>[] = [
fetch(`${forgejoBaseUrl(profile)}/user/login`, { signal: AbortSignal.timeout(1200) }),
];
if (profile.lighthouseTunnelPort) {
checks.push(fetch(`${lighthouseBaseUrl(profile)}/health`, { signal: AbortSignal.timeout(1200) }));
}
const responses = await Promise.all(checks);
return responses.every(response => response.status === 200);
} catch {
return false;
}
}
async function ensureServerTunnel(profile: ServerProfileDefinition): Promise<void> {
if (await isServerTunnelReady(profile)) return;
const currentTunnel = serverTunnels.get(profile.id);
if (currentTunnel && currentTunnel.exitCode === null) {
throw new Error(`${profile.name}连接正在建立,请稍后重试`);
}
const forwards = [
'-L', `127.0.0.1:${profile.tunnelPort}:127.0.0.1:${profile.remoteForgejoPort}`,
];
if (profile.lighthouseTunnelPort && profile.remoteLighthousePort) {
forwards.push('-L', `127.0.0.1:${profile.lighthouseTunnelPort}:127.0.0.1:${profile.remoteLighthousePort}`);
}
const tunnel = spawn('/usr/bin/ssh', [
'-N',
...forwards,
'-o', 'BatchMode=yes',
'-o', 'ExitOnForwardFailure=yes',
'-o', 'ServerAliveInterval=30',
'-o', 'ServerAliveCountMax=3',
profile.sshAlias,
], {
stdio: 'ignore',
});
serverTunnels.set(profile.id, tunnel);
tunnel.once('exit', () => {
serverTunnels.delete(profile.id);
});
for (let attempt = 0; attempt < 20; attempt += 1) {
if (await isServerTunnelReady(profile)) return;
if (tunnel.exitCode !== null) break;
await new Promise(resolve => setTimeout(resolve, 250));
}
tunnel.kill('SIGTERM');
serverTunnels.delete(profile.id);
throw new Error(`无法通过本机登记密钥连接${profile.name}`);
}
function stopServerTunnels(): void {
for (const tunnel of serverTunnels.values()) tunnel.kill('SIGTERM');
serverTunnels.clear();
}
async function detachKnowledgeRemote(): Promise<void> {
try {
await fetch(`http://127.0.0.1:${SERVER_PORT}/api/forgejo/remote`, { method: 'DELETE' });
} catch {
// 本地知识库服务不可用时不阻断账号退出;下次登录仍会重新校验仓库权限。
}
}
async function forgejoRequest(
nodeId: string,
route: string,
init: RequestInit = {},
auth?: { username?: string; password?: string; token?: string },
): Promise<{ response: Response; data: any }> {
const profile = getServerProfile(nodeId);
await ensureServerTunnel(profile);
const headers = new Headers(init.headers);
headers.set('Accept', 'application/json');
if (init.body) headers.set('Content-Type', 'application/json');
if (auth?.token) headers.set('Authorization', `token ${auth.token}`);
if (auth?.username && auth.password) {
headers.set('Authorization', `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`);
}
const response = await fetch(`${forgejoBaseUrl(profile)}${route}`, {
...init,
headers,
signal: AbortSignal.timeout(10000),
});
const text = await response.text();
let data: any = null;
if (text) {
try { data = JSON.parse(text); } catch { data = { message: text }; }
}
return { response, data };
}
function startServer(): Promise<void> {
return new Promise((resolve, reject) => {
if (isDev) {
// 开发模式:用 spawn 启动外部 tsx
const serverScript = path.resolve(__dirname, '../../guanghu-knowledge-base/server/index.ts');
serverProcess = spawn('npx', ['tsx', serverScript], {
env: {
...process.env,
KB_PORT: String(SERVER_PORT),
KB_REPO_PATH,
HOLOLAKE_AGENT_STATE_PATH: AGENT_STATE_PATH,
NODE_ENV: 'development',
},
cwd: path.resolve(__dirname, '../..'),
stdio: ['pipe', 'pipe', 'pipe'],
});
serverProcess.stdout?.on('data', (data: Buffer) => {
const msg = data.toString().trim();
console.log(`[KB Server] ${msg}`);
if (msg.includes('已启动')) resolve();
});
serverProcess.stderr?.on('data', (data: Buffer) => {
console.error(`[KB Server Error] ${data.toString().trim()}`);
});
serverProcess.on('error', (err: Error) => {
console.error('服务器启动失败:', err);
reject(err);
});
serverProcess.on('exit', (code: number | null) => {
console.log(`[KB Server] 退出code=${code}`);
serverProcess = null;
});
setTimeout(() => resolve(), 5000);
} else {
// 生产模式:直接在主进程内加载 server-bundle
try {
process.env.KB_PORT = String(SERVER_PORT);
process.env.KB_REPO_PATH = KB_REPO_PATH;
process.env.HOLOLAKE_AGENT_STATE_PATH = AGENT_STATE_PATH;
const serverBundle = path.join(__dirname, 'server-bundle.cjs');
require(serverBundle);
console.log(`[KB Server] 内嵌启动,端口 ${SERVER_PORT}`);
resolve();
} catch (err) {
console.error('服务器内嵌启动失败:', err);
reject(err);
}
}
});
}
function stopServer(): void {
if (serverProcess) {
serverProcess.kill('SIGTERM');
serverProcess = null;
}
}
async function waitForServerReady(): Promise<void> {
for (let attempt = 0; attempt < 40; attempt += 1) {
try {
const response = await fetch(`http://127.0.0.1:${SERVER_PORT}/api/health`, {
signal: AbortSignal.timeout(800),
});
if (response.ok) return;
} catch {
// 服务仍在启动,继续读取真实健康端点,不提前打开一个离线界面。
}
await new Promise(resolve => setTimeout(resolve, 250));
}
throw new Error('HoloLake 本地服务未在规定时间内就绪');
}
// ─── 窗口 ───
let mainWindow: BrowserWindow | null = null;
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
height: 860,
minWidth: 800,
minHeight: 600,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 16, y: 16 },
backgroundColor: '#07111d',
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
},
});
if (isDev) {
mainWindow.loadURL(`http://localhost:${CLIENT_PORT}`);
mainWindow.webContents.openDevTools({ mode: 'detach' });
} else {
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
}
// 外部链接用系统浏览器打开
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http')) shell.openExternal(url);
return { action: 'deny' };
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
ipcMain.handle('get-data-path', () => DATA_DIR);
ipcMain.handle('knowledge:import-folder', async () => {
const options: Electron.OpenDialogOptions = {
title: '导入本地知识文件夹',
buttonLabel: '导入到 HoloLake',
properties: ['openDirectory'],
};
const selection = mainWindow
? await dialog.showOpenDialog(mainWindow, options)
: await dialog.showOpenDialog(options);
if (selection.canceled || !selection.filePaths[0]) {
return { cancelled: true, imported: 0, assets: 0, skipped: 0, failed: [] };
}
return importKnowledgeFolder(selection.filePaths[0], KB_REPO_PATH);
});
ipcMain.handle('agent:get-config', () => {
const config = readModelConfig();
return {
baseUrl: config.baseUrl,
model: config.model,
configured: Boolean(config.encryptedKey && safeStorage.isEncryptionAvailable()),
operational: Boolean(config.encryptedKey && config.verifiedAt && safeStorage.isEncryptionAvailable()),
verifiedAt: config.verifiedAt,
};
});
ipcMain.handle('agent:save-config', (_event, input: { baseUrl: string; model: string; apiKey?: string }) => {
const baseUrl = String(input.baseUrl || '').trim().replace(/\/$/, '');
const model = String(input.model || '').trim();
if (!/^https:\/\//i.test(baseUrl)) throw new Error('模型服务地址必须使用 HTTPS');
if (!model) throw new Error('模型名称不能为空');
if (!safeStorage.isEncryptionAvailable()) throw new Error('macOS 加密存储当前不可用');
const previous = readModelConfig();
const encryptedKey = input.apiKey
? safeStorage.encryptString(String(input.apiKey)).toString('base64')
: previous.encryptedKey;
if (!encryptedKey) throw new Error('请填写模型密钥');
const verificationStillValid = !input.apiKey && previous.baseUrl === baseUrl && previous.model === model;
fs.mkdirSync(path.dirname(MODEL_CONFIG_PATH), { recursive: true });
fs.writeFileSync(MODEL_CONFIG_PATH, JSON.stringify({
baseUrl,
model,
encryptedKey,
verifiedAt: verificationStillValid ? previous.verifiedAt : undefined,
}), { mode: 0o600 });
applyModelConfig();
return { baseUrl, model, configured: true, operational: verificationStillValid && Boolean(previous.verifiedAt) };
});
ipcMain.handle('agent:test-config', () => verifyModelConfig());
ipcMain.handle('server:list', async () => {
return Promise.all(Object.values(readServerProfiles()).map(async profile => ({
id: profile.id,
physicalNodeId: profile.physicalNodeId,
name: profile.name,
purpose: profile.purpose,
channelTitle: profile.channelTitle,
channelSubtitle: profile.channelSubtitle,
connected: await isServerTunnelReady(profile),
verified: true,
})));
});
ipcMain.handle('server:connect', async (_event, nodeId: string) => {
const profile = getServerProfile(nodeId);
await ensureServerTunnel(profile);
return {
id: profile.id,
physicalNodeId: profile.physicalNodeId,
name: profile.name,
purpose: profile.purpose,
channelTitle: profile.channelTitle,
channelSubtitle: profile.channelSubtitle,
connected: true,
verified: true,
};
});
ipcMain.handle('server:domain-registry', async () => {
const profile = getServerProfile('AW-GZ-001');
try {
await ensureServerTunnel(profile);
} catch {
// 公共灯塔不可达时返回真实离线状态,界面仍可浏览已登记的四域结构。
}
const connected = await isServerTunnelReady(profile);
let lighthouseStatus: any = null;
let lighthouseHealth: any = null;
if (connected) {
const [statusResponse, healthResponse] = await Promise.all([
fetch(`${lighthouseBaseUrl(profile)}/v1/status`, { signal: AbortSignal.timeout(3000) }),
fetch(`${lighthouseBaseUrl(profile)}/health`, { signal: AbortSignal.timeout(3000) }),
]);
if (statusResponse.ok) lighthouseStatus = await statusResponse.json();
if (healthResponse.ok) lighthouseHealth = await healthResponse.json();
}
const liveDomains = new Map((lighthouseStatus?.domains || []).map((domain: any) => [domain.id, domain]));
const domain = (serverId: string, fallback: Record<string, unknown>) => ({
...fallback,
serverId,
live: liveDomains.get(serverId) || null,
});
return {
nodeId: profile.id,
physicalNodeId: profile.physicalNodeId,
connected,
verified: Boolean(lighthouseStatus && lighthouseHealth),
codeChannel: connected ? 'reachable' : 'not-connected',
lighthouse: lighthouseHealth ? {
mode: lighthouseHealth.mode,
mapHash: lighthouseHealth.map_hash,
execution: lighthouseHealth.execution,
hostState: lighthouseStatus?.host_state,
observedAt: lighthouseStatus?.observed_at,
} : null,
domains: [
domain('DOMAIN-MAIN', { id: 'main', number: 'HLDP-DOMAIN-MAIN-001', name: '光湖主域', responsibility: '公共定义、版本发布、广播与模块生态状态', repository: 'domain-main' }),
domain('DOMAIN-SUB', { id: 'sub', number: 'HLDP-DOMAIN-SUB-001', name: '光湖分域', responsibility: '行业入口、模块目录与初始化频道路由', repository: 'domain-sub' }),
domain('DOMAIN-ZERO', { id: 'zero', number: 'HLDP-DOMAIN-ZERO-001', name: '光湖零域', responsibility: '模块试装、实验、质量验证与可回滚预览', repository: 'domain-zero' }),
domain('DOMAIN-ZS', { id: 'zero-sense', number: 'HLDP-DOMAIN-ZEROSENSE-001', name: '光湖零感域', responsibility: '团队身份、责任、权限、审计与个人节点发现', repository: 'domain-zero-sense' }),
],
};
});
ipcMain.handle('server:session', async (_event, requestedNodeId?: string) => {
const stored = applyStoredServerAuth();
const profiles = readServerProfiles();
const nodeId = requestedNodeId && profiles[requestedNodeId]
? requestedNodeId
: stored?.nodeId || defaultPersonalServerId() || '';
if (!stored || stored.nodeId !== nodeId) return { authenticated: false, nodeId };
try {
const { response, data } = await forgejoRequest(nodeId, '/api/v1/user', {}, { token: stored.token });
if (!response.ok) {
fs.rmSync(SERVER_AUTH_PATH, { force: true });
clearGitCredentialEnvironment();
return { authenticated: false, nodeId };
}
return { authenticated: true, nodeId, username: data.login };
} catch {
return { authenticated: false, nodeId };
}
});
ipcMain.handle('server:login', async (_event, input: { nodeId: string; username: string; password: string }) => {
const username = String(input.username || '').trim();
const password = String(input.password || '');
getServerProfile(input.nodeId);
if (!username || !password) throw new Error('请输入账号和密码');
const basicAuth = { username, password };
const identity = await forgejoRequest(input.nodeId, '/api/v1/user', {}, basicAuth);
if (!identity.response.ok) throw new Error('账号或密码不正确');
const tokenName = 'hololake-desktop';
const existing = await forgejoRequest(input.nodeId, `/api/v1/users/${encodeURIComponent(username)}/tokens`, {}, basicAuth);
if (existing.response.ok && Array.isArray(existing.data)) {
for (const token of existing.data) {
if (token?.name === tokenName && token?.id) {
await forgejoRequest(
input.nodeId,
`/api/v1/users/${encodeURIComponent(username)}/tokens/${token.id}`,
{ method: 'DELETE' },
basicAuth,
);
}
}
}
const created = await forgejoRequest(
input.nodeId,
`/api/v1/users/${encodeURIComponent(username)}/tokens`,
{ method: 'POST', body: JSON.stringify({ name: tokenName, scopes: ['all'] }) },
basicAuth,
);
if (created.response.status !== 201 || !created.data?.sha1) {
throw new Error(created.data?.message || '无法建立 HoloLake 登录令牌');
}
const previous = applyStoredServerAuth();
if (!previous || previous.nodeId !== input.nodeId || previous.username !== identity.data.login) {
await detachKnowledgeRemote();
}
saveServerAuth(input.nodeId, username, created.data.sha1);
return { authenticated: true, nodeId: input.nodeId, username: identity.data.login };
});
ipcMain.handle('server:logout', async () => {
const stored = applyStoredServerAuth();
await detachKnowledgeRemote();
fs.rmSync(SERVER_AUTH_PATH, { force: true });
clearGitCredentialEnvironment();
return { authenticated: false, nodeId: stored?.nodeId || defaultPersonalServerId() || '' };
});
ipcMain.handle('server:repositories', async () => {
const stored = applyStoredServerAuth();
if (!stored) throw new Error('请先登录服务器');
const { response, data } = await forgejoRequest(stored.nodeId, '/api/v1/user/repos?limit=100', {}, { token: stored.token });
if (!response.ok || !Array.isArray(data)) throw new Error(data?.message || '读取服务器仓库失败');
return data.map(repo => ({
name: repo.name,
fullName: repo.full_name,
private: repo.private,
defaultBranch: repo.default_branch || 'main',
}));
});
ipcMain.handle('server:create-repository', async (_event, input: { name: string; description?: string }) => {
const stored = applyStoredServerAuth();
if (!stored) throw new Error('请先登录服务器');
const name = String(input.name || '').trim();
if (!/^[A-Za-z0-9._-]{1,100}$/.test(name)) throw new Error('仓库名称只能使用字母、数字、点、短横线或下划线');
const { response, data } = await forgejoRequest(stored.nodeId, '/api/v1/user/repos', {
method: 'POST',
body: JSON.stringify({
name,
description: String(input.description || ''),
private: true,
auto_init: true,
default_branch: 'main',
}),
}, { token: stored.token });
if (response.status !== 201) throw new Error(data?.message || '创建仓库失败');
return {
name: data.name,
fullName: data.full_name,
private: data.private,
defaultBranch: data.default_branch || 'main',
};
});
ipcMain.handle('server:git-remote', async (_event, fullName: string) => {
if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(String(fullName || ''))) {
throw new Error('仓库路径无效');
}
const stored = applyStoredServerAuth();
if (!stored) throw new Error('请先登录服务器');
const [owner, repository] = String(fullName).split('/');
const access = await forgejoRequest(
stored.nodeId,
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}`,
{},
{ token: stored.token },
);
if (!access.response.ok || access.data?.full_name !== fullName) {
throw new Error('当前账号无权访问该仓库');
}
return `${forgejoBaseUrl(getServerProfile(stored.nodeId))}/${fullName}.git`;
});
// ─── 应用生命周期 ───
app.whenReady().then(async () => {
try {
applyModelConfig();
await startServer();
await waitForServerReady();
createWindow();
} catch (err) {
dialog.showErrorBox('启动失败', `知识库引擎启动失败:${err}`);
app.quit();
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
stopServer();
if (process.platform !== 'darwin') app.quit();
});
app.on('before-quit', () => {
stopServer();
stopServerTunnels();
});