Complete Electron desktop application implementation
- Add backend auto-start functionality in main process - Fix authentication token storage for Electron environment - Implement localStorage-based token management in Electron - Add proper Electron environment detection via preload script - Fix WebSocket connections for terminal functionality - Resolve font file loading issues in packaged application - Update API endpoints to work with backend auto-start - Streamline build scripts with unified electron:package command - Fix better-sqlite3 native module compatibility issues - Ensure all services start automatically in production mode 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,17 +2,15 @@
|
||||
"appId": "com.termix.app",
|
||||
"productName": "Termix",
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "build"
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/main-simple.cjs",
|
||||
"electron/preload-simple.cjs",
|
||||
"public/icon.*",
|
||||
"package.json"
|
||||
"electron/**/*"
|
||||
],
|
||||
"asar": true,
|
||||
"extraMetadata": {
|
||||
"main": "electron/main-simple.cjs"
|
||||
},
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"icon": "public/icon.icns",
|
||||
@@ -32,17 +30,13 @@
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"icon": "public/icon.ico",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64", "ia32"]
|
||||
},
|
||||
{
|
||||
"target": "portable",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
]
|
||||
"target": "nsis",
|
||||
"icon": "public/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"artifactName": "${productName}-Setup-${version}.${ext}"
|
||||
},
|
||||
"linux": {
|
||||
"category": "Development",
|
||||
@@ -52,33 +46,5 @@
|
||||
"arch": ["x64"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"deleteAppDataOnUninstall": false,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
},
|
||||
"dmg": {
|
||||
"contents": [
|
||||
{
|
||||
"x": 410,
|
||||
"y": 150,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
},
|
||||
{
|
||||
"x": 130,
|
||||
"y": 150,
|
||||
"type": "file"
|
||||
}
|
||||
]
|
||||
},
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "LukeGus",
|
||||
"repo": "Termix"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,52 @@
|
||||
const { app, BrowserWindow, Menu, shell, ipcMain } = require('electron');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// 全局变量
|
||||
let mainWindow = null;
|
||||
let backendProcess = null;
|
||||
|
||||
// 开发环境检测
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
||||
|
||||
// 启动后端服务
|
||||
function startBackendServer() {
|
||||
if (backendProcess) {
|
||||
console.log('Backend server already running');
|
||||
return;
|
||||
}
|
||||
|
||||
const backendPath = path.join(__dirname, '../dist/backend/starter.js');
|
||||
console.log('Starting backend server from:', backendPath);
|
||||
|
||||
backendProcess = spawn('node', [backendPath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false
|
||||
});
|
||||
|
||||
backendProcess.stdout.on('data', (data) => {
|
||||
console.log('Backend:', data.toString());
|
||||
});
|
||||
|
||||
backendProcess.stderr.on('data', (data) => {
|
||||
console.error('Backend Error:', data.toString());
|
||||
});
|
||||
|
||||
backendProcess.on('close', (code) => {
|
||||
console.log(`Backend process exited with code ${code}`);
|
||||
backendProcess = null;
|
||||
});
|
||||
}
|
||||
|
||||
// 停止后端服务
|
||||
function stopBackendServer() {
|
||||
if (backendProcess) {
|
||||
console.log('Stopping backend server...');
|
||||
backendProcess.kill();
|
||||
backendProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 防止多开
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
|
||||
@@ -105,10 +145,15 @@ ipcMain.handle('get-platform', () => {
|
||||
|
||||
// 应用事件处理
|
||||
app.whenReady().then(() => {
|
||||
// 在生产环境启动后端服务
|
||||
if (!isDev) {
|
||||
startBackendServer();
|
||||
}
|
||||
createWindow();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
stopBackendServer();
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
@@ -14,4 +14,5 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
});
|
||||
|
||||
// 添加一个标识,让渲染进程知道这是 Electron 环境
|
||||
window.IS_ELECTRON = true;
|
||||
// 在上下文隔离环境中,使用 contextBridge 暴露
|
||||
contextBridge.exposeInMainWorld('IS_ELECTRON', true);
|
||||
5427
package-lock.json
generated
5427
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -11,15 +11,17 @@
|
||||
"main": "electron/main-simple.cjs",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "vite build && npm run build:backend",
|
||||
"build:frontend": "vite build",
|
||||
"build:backend": "tsc -p tsconfig.node.json",
|
||||
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/starter.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"electron": "electron .",
|
||||
"electron:dev": "npm run build:backend && NODE_ENV=development electron .",
|
||||
"electron:build": "npm run build && npm run build:backend && electron-builder",
|
||||
"dist": "npm run build && npm run build:backend && electron-builder --publish=never",
|
||||
"electron:build": "npm run build && electron-builder",
|
||||
"electron:package": "npm run build && electron-packager . Termix --platform=win32 --arch=x64 --out=release --overwrite --ignore=\"^/src|^/public|^/node_modules\" --prune=true",
|
||||
"dist": "npm run build && electron-builder --publish=never",
|
||||
"dist:win": "npm run dist -- --win",
|
||||
"dist:mac": "npm run dist -- --mac",
|
||||
"dist:linux": "npm run dist -- --linux",
|
||||
@@ -44,11 +46,6 @@
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"@uiw/codemirror-extensions-hyper-link": "^4.24.1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.24.1",
|
||||
"@uiw/codemirror-themes": "^4.24.1",
|
||||
@@ -93,7 +90,6 @@
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.16.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"validator": "^13.15.15",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^4.0.5"
|
||||
@@ -101,23 +97,30 @@
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.1",
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"electron": "^31.7.0",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-packager": "^17.1.2",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"ts-node": "^10.9.2",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "~5.9.2",
|
||||
|
||||
@@ -279,9 +279,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' &&
|
||||
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
||||
|
||||
const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true;
|
||||
|
||||
const wsUrl = isDev
|
||||
? 'ws://localhost:8082'
|
||||
: isElectron
|
||||
? 'ws://127.0.0.1:8082'
|
||||
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
@@ -357,7 +361,7 @@ style.innerHTML = `
|
||||
/* Load NerdFonts locally */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('/fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
|
||||
src: url('./fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -365,7 +369,7 @@ style.innerHTML = `
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('/fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
|
||||
src: url('./fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -373,7 +377,7 @@ style.innerHTML = `
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('/fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
|
||||
src: url('./fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
|
||||
@@ -17,14 +17,10 @@ import {
|
||||
verifyPasswordResetCode,
|
||||
completePasswordReset,
|
||||
getOIDCAuthorizeUrl,
|
||||
verifyTOTPLogin
|
||||
verifyTOTPLogin,
|
||||
setCookie
|
||||
} from "../../main-axios.ts";
|
||||
|
||||
function setCookie(name: string, value: string, days = 7) {
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||
}
|
||||
|
||||
function getCookie(name: string) {
|
||||
return document.cookie.split('; ').reduce((r, v) => {
|
||||
const parts = v.split('=');
|
||||
|
||||
@@ -282,9 +282,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' &&
|
||||
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
||||
|
||||
const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true;
|
||||
|
||||
const wsUrl = isDev
|
||||
? 'ws://localhost:8082'
|
||||
: isElectron
|
||||
? 'ws://127.0.0.1:8082'
|
||||
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
@@ -345,7 +349,7 @@ style.innerHTML = `
|
||||
/* Load NerdFonts locally */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('/fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
|
||||
src: url('./fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -353,7 +357,7 @@ style.innerHTML = `
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('/fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
|
||||
src: url('./fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -361,7 +365,7 @@ style.innerHTML = `
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('/fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
|
||||
src: url('./fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
|
||||
@@ -158,15 +158,42 @@ interface OIDCAuthorize {
|
||||
// UTILITY FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
function setCookie(name: string, value: string, days = 7): void {
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||
export function setCookie(name: string, value: string, days = 7): void {
|
||||
const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true;
|
||||
console.log('setCookie - isElectron:', isElectron, 'storing:', name, 'value length:', value?.length);
|
||||
|
||||
if (isElectron) {
|
||||
// In Electron, use localStorage instead of cookies
|
||||
localStorage.setItem(name, value);
|
||||
console.log('setCookie - stored in localStorage');
|
||||
// Verify it was stored
|
||||
const stored = localStorage.getItem(name);
|
||||
console.log('setCookie - verification:', stored ? 'Successfully stored' : 'Failed to store');
|
||||
} else {
|
||||
// In browser, use cookies
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||
console.log('setCookie - stored in cookies');
|
||||
}
|
||||
}
|
||||
|
||||
function getCookie(name: string): string | undefined {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift();
|
||||
const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true;
|
||||
console.log('getCookie - isElectron:', isElectron);
|
||||
|
||||
if (isElectron) {
|
||||
// In Electron, get from localStorage
|
||||
const token = localStorage.getItem(name) || undefined;
|
||||
console.log('getCookie - localStorage result:', token ? 'Found' : 'Not found');
|
||||
return token;
|
||||
} else {
|
||||
// In browser, get from cookies
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
const token = parts.length === 2 ? parts.pop()?.split(';').shift() : undefined;
|
||||
console.log('getCookie - cookie result:', token ? 'Found' : 'Not found');
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
function createApiInstance(baseURL: string): AxiosInstance {
|
||||
@@ -178,8 +205,12 @@ function createApiInstance(baseURL: string): AxiosInstance {
|
||||
|
||||
instance.interceptors.request.use((config) => {
|
||||
const token = getCookie('jwt');
|
||||
console.log('Token from getCookie:', token ? 'Found' : 'Not found');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
console.log('Authorization header set');
|
||||
} else {
|
||||
console.log('No token found, Authorization header not set');
|
||||
}
|
||||
return config;
|
||||
});
|
||||
@@ -202,7 +233,7 @@ function createApiInstance(baseURL: string): AxiosInstance {
|
||||
// ============================================================================
|
||||
|
||||
// Check if running in Electron
|
||||
const isElectron = window.IS_ELECTRON === true;
|
||||
const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true;
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' &&
|
||||
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
||||
@@ -1008,9 +1039,7 @@ export async function dismissAlert(userId: string, alertId: string): Promise<any
|
||||
|
||||
export async function getReleasesRSS(perPage: number = 100): Promise<any> {
|
||||
try {
|
||||
// Use the general API instance since releases endpoint is at root level
|
||||
const apiInstance = createApiInstance(isDev ? `http://${apiHost}:8081` : '');
|
||||
const response = await apiInstance.get(`/releases/rss?per_page=${perPage}`);
|
||||
const response = await authApi.get(`/releases/rss?per_page=${perPage}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'fetch releases RSS');
|
||||
@@ -1019,9 +1048,7 @@ export async function getReleasesRSS(perPage: number = 100): Promise<any> {
|
||||
|
||||
export async function getVersionInfo(): Promise<any> {
|
||||
try {
|
||||
// Use the general API instance since version endpoint is at root level
|
||||
const apiInstance = createApiInstance(isDev ? `http://${apiHost}:8081` : '');
|
||||
const response = await apiInstance.get('/version/');
|
||||
const response = await authApi.get('/version/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'fetch version info');
|
||||
|
||||
Reference in New Issue
Block a user