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

759 lines
28 KiB
TypeScript
Raw Normal View History

/**
* 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';
import { DomainAccessOrchestrator } from '../../guanghu-knowledge-base/server/domain-access-orchestrator.js';
import { TrustedSignerSnapshotLoader } from '../../guanghu-knowledge-base/server/trusted-signer-snapshot.js';
2026-08-10 07:01:36 +08:00
import { NodeRegistrationSnapshotLoader } from '../../guanghu-knowledge-base/server/node-registration-snapshot.js';
2026-08-10 07:28:11 +08:00
import { HttpNodeRegistrationClaimSource } from '../../guanghu-knowledge-base/server/node-registration-client.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 };
}
const trustedSignerSnapshots = new TrustedSignerSnapshotLoader({
async fetchJson(url: string): Promise<unknown> {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error(`trusted_signer_source_http_${response.status}`);
return response.json();
},
});
2026-08-10 07:01:36 +08:00
const nodeRegistrationSnapshots = new NodeRegistrationSnapshotLoader({
async fetchJson(url: string): Promise<unknown> {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error(`node_registration_source_http_${response.status}`);
return response.json();
},
});
2026-08-10 07:28:11 +08:00
const nodeRegistrationClaims = new HttpNodeRegistrationClaimSource();
const domainAccessOrchestrator = new DomainAccessOrchestrator(
trustedSignerSnapshots,
2026-08-10 07:01:36 +08:00
nodeRegistrationSnapshots,
{
async read() {
const stored = applyStoredServerAuth();
2026-08-10 07:01:36 +08:00
if (!stored) return { accountId: '', accountVerified: false, nodeId: '' };
try {
2026-08-10 07:01:36 +08:00
const { response, data } = await forgejoRequest(stored.nodeId, '/api/v1/user', {}, { token: stored.token });
// Forgejo proves only the account part. It cannot submit a node-registration
// boolean; a separately anchored signed claim is required below.
return {
accountId: response.ok && typeof data?.login === 'string' ? data.login : '',
accountVerified: response.ok,
nodeId: stored.nodeId,
};
} catch {
2026-08-10 07:01:36 +08:00
return { accountId: '', accountVerified: false, nodeId: stored.nodeId };
}
},
},
2026-08-10 07:28:11 +08:00
nodeRegistrationClaims,
{
async read() {
// 当前灯塔尚未发布签名运行体交接端点。保持关闭,不从账号身份推导运行体权限。
return null;
},
},
);
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:domain-access', async (_event, input: { domainId?: unknown; nodeType?: unknown } = {}) => {
const domainId = String(input.domainId ?? 'DOM-FIFTH-0001');
const nodeType = input.nodeType === 'cloud-resident' ? 'cloud-resident' : input.nodeType === 'local-terminal' ? 'local-terminal' : null;
if (!nodeType) throw new Error('domain_access_node_type_invalid');
return domainAccessOrchestrator.domainAccess(domainId, nodeType);
});
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();
});