feat: add Electron support for headless exe service

- Add folder selection button in UI
- Add Electron main process with IPC handlers
- Support --port and --docs command line arguments
- Support --headless mode for headless service
- Add portable exe build configuration
This commit is contained in:
2026-03-18 15:05:37 +08:00
parent 5fd9274039
commit 3d703ba7ef
80 changed files with 169500 additions and 30 deletions

151
electron/main.mjs Normal file
View File

@@ -0,0 +1,151 @@
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
import path from 'path';
import { fileURLToPath } from 'url';
import { createServer } from 'http';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
let mainWindow = null;
let docsPath = '';
let port = 3001;
const DEFAULT_PORT = 3001;
function parseArgs() {
const args = { port: DEFAULT_PORT, docs: null, headless: false };
const portIndex = process.argv.indexOf('--port');
if (portIndex !== -1 && process.argv[portIndex + 1]) {
args.port = parseInt(process.argv[portIndex + 1], 10);
}
const docsIndex = process.argv.indexOf('--docs');
if (docsIndex !== -1 && process.argv[docsIndex + 1]) {
args.docs = process.argv[docsIndex + 1];
}
if (process.argv.includes('--headless')) {
args.headless = true;
}
return args;
}
const args = parseArgs();
port = args.port;
docsPath = args.docs || path.join(process.cwd(), 'src', 'docs');
function getDistPath() {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'app', 'dist');
}
return path.join(__dirname, '..', 'dist');
}
function loadDistServer() {
const distPath = getDistPath();
const indexPath = path.join(distPath, 'index.html');
const server = require('http').createServer((req, res) => {
let filePath = path.join(distPath, req.url === '/' ? 'index.html' : req.url);
if (!filePath.startsWith(distPath)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
const ext = path.extname(filePath);
const contentTypes = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.md': 'text/markdown',
};
const fs = require('fs');
if (fs.existsSync(filePath)) {
const contentType = contentTypes[ext] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': contentType });
res.end(fs.readFileSync(filePath));
} else {
res.writeHead(404);
res.end('Not Found');
}
});
server.listen(port, () => {
console.log(`XCSDD Server running on http://localhost:${port}`);
console.log(`Docs path: ${docsPath}`);
if (args.headless) {
console.log(`Headless mode: enabled`);
}
});
return server;
}
function createWindow() {
if (args.headless) {
loadDistServer();
return;
}
loadDistServer();
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.mjs'),
contextIsolation: true,
nodeIntegration: false,
},
});
mainWindow.loadURL(`http://localhost:${port}`);
mainWindow.on('closed', () => {
mainWindow = null;
});
}
ipcMain.handle('select-folder', async () => {
if (!mainWindow) return null;
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory'],
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
docsPath = result.filePaths[0];
mainWindow.webContents.send('docs-path-changed', docsPath);
return docsPath;
});
ipcMain.handle('get-docs-path', () => {
return docsPath;
});
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});

9
electron/preload.mjs Normal file
View File

@@ -0,0 +1,9 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
selectFolder: () => ipcRenderer.invoke('select-folder'),
getDocsPath: () => ipcRenderer.invoke('get-docs-path'),
onDocsPathChanged: (callback: (path: string) => void) => {
ipcRenderer.on('docs-path-changed', (_event, path) => callback(path));
},
});

4710
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,18 @@
{ {
"name": "xcengine-api-docs", "name": "xcengine-api-docs",
"version": "1.0.0", "version": "1.0.0",
"description": "XCSDD - API Docs & 3D Blueprint Viewer",
"author": "XCSDD",
"main": "electron/main.mjs",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"electron:dev": "vite build && electron .",
"electron:build": "vite build && electron-builder --win --x64",
"electron:start": "electron ."
}, },
"dependencies": { "dependencies": {
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
@@ -28,6 +34,8 @@
"@types/three": "^0.183.1", "@types/three": "^0.183.1",
"@vitejs/plugin-react": "^6.0.0", "@vitejs/plugin-react": "^6.0.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"electron": "^33.4.0",
"electron-builder": "^25.1.8",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
@@ -37,5 +45,23 @@
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.56.1", "typescript-eslint": "^8.56.1",
"vite": "^8.0.0" "vite": "^8.0.0"
},
"build": {
"appId": "com.xuanchi.xcsdd",
"productName": "XCSDD",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*",
"package.json"
],
"win": {
"target": "portable"
},
"portable": {
"artifactName": "XCSDD.exe"
}
} }
} }

BIN
release/XCSDD.exe Normal file

Binary file not shown.

22
release/builder-debug.yml Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,21 @@
Copyright (c) Electron contributors
Copyright (c) 2013-2020 GitHub Inc.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
{"file_format_version": "1.0.0", "ICD": {"library_path": ".\\vk_swiftshader.dll", "api_version": "1.0.5"}}

Binary file not shown.

View File

@@ -1,44 +1,87 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { FileText, Box } from 'lucide-react'; import { FileText, Box, FolderOpen } from 'lucide-react';
import { ApiDocViewer } from './components/ApiDocViewer'; import { ApiDocViewer } from './components/ApiDocViewer';
import BlueprintPage from './components/blueprint/BlueprintPage'; import BlueprintPage from './components/blueprint/BlueprintPage';
import { config } from './config'; import { config } from './config';
type Page = 'docs' | 'blueprint'; type Page = 'docs' | 'blueprint';
declare global {
interface Window {
electronAPI?: {
selectFolder: () => Promise<string | null>;
getDocsPath: () => Promise<string>;
onDocsPathChanged: (callback: (path: string) => void) => void;
};
}
}
function App() { function App() {
const [currentPage, setCurrentPage] = useState<Page>('docs'); const [currentPage, setCurrentPage] = useState<Page>('docs');
const [docsPath, setDocsPath] = useState<string>('');
document.title = config.projectName; useEffect(() => {
document.title = config.projectName;
if (window.electronAPI) {
window.electronAPI.getDocsPath().then(setDocsPath);
window.electronAPI.onDocsPathChanged(setDocsPath);
}
}, []);
const handleSelectFolder = async () => {
if (window.electronAPI) {
const path = await window.electronAPI.selectFolder();
if (path) {
setDocsPath(path);
}
}
};
return ( return (
<div className="h-screen flex flex-col"> <div className="h-screen flex flex-col">
<header className="h-12 bg-zinc-900 border-b border-zinc-800 flex items-center px-4 justify-between"> <header className="h-12 bg-zinc-900 border-b border-zinc-800 flex items-center px-4 justify-between">
<h1 className="text-sm font-medium text-white">{config.projectName}</h1> <div className="flex items-center gap-3">
<nav className="flex gap-1"> <h1 className="text-sm font-medium text-white">{config.projectName}</h1>
<button {docsPath && (
onClick={() => setCurrentPage('docs')} <span className="text-xs text-zinc-500">📁 {docsPath}</span>
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm ${ )}
currentPage === 'docs' </div>
? 'bg-zinc-800 text-white' <div className="flex items-center gap-2">
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/50' {window.electronAPI && (
}`} <button
> onClick={handleSelectFolder}
<FileText size={16} /> className="flex items-center gap-2 px-3 py-1.5 rounded text-sm text-zinc-400 hover:text-white hover:bg-zinc-800/50"
API title="选择文档文件夹"
</button> >
<button <FolderOpen size={16} />
onClick={() => setCurrentPage('blueprint')} </button>
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm ${ )}
currentPage === 'blueprint' <nav className="flex gap-1">
? 'bg-zinc-800 text-white' <button
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/50' onClick={() => setCurrentPage('docs')}
}`} className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm ${
> currentPage === 'docs'
<Box size={16} /> ? 'bg-zinc-800 text-white'
3D : 'text-zinc-400 hover:text-white hover:bg-zinc-800/50'
</button> }`}
</nav> >
<FileText size={16} />
API
</button>
<button
onClick={() => setCurrentPage('blueprint')}
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm ${
currentPage === 'blueprint'
? 'bg-zinc-800 text-white'
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/50'
}`}
>
<Box size={16} />
3D
</button>
</nav>
</div>
</header> </header>
<main className="flex-1 overflow-hidden"> <main className="flex-1 overflow-hidden">
{currentPage === 'docs' ? <ApiDocViewer /> : <BlueprintPage />} {currentPage === 'docs' ? <ApiDocViewer /> : <BlueprintPage />}