695 lines
26 KiB
TypeScript
695 lines
26 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|