You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
559 lines
15 KiB
559 lines
15 KiB
|
2 months ago
|
const { app, BrowserWindow, shell } = require('electron');
|
||
|
|
const path = require('path');
|
||
|
|
const fs = require('fs');
|
||
|
|
const { spawn } = require('child_process');
|
||
|
|
const net = require('net');
|
||
|
|
const http = require('http');
|
||
|
|
|
||
|
|
let mainWindow = null;
|
||
|
|
let backendProcess = null;
|
||
|
|
let logFilePath = null;
|
||
|
|
let backendStartError = null;
|
||
|
|
|
||
|
|
const isWindows = process.platform === 'win32';
|
||
|
|
const appRootDev = path.resolve(__dirname, '..', '..');
|
||
|
|
|
||
|
|
function resolveEnvExamplePath() {
|
||
|
|
if (app.isPackaged) {
|
||
|
|
return path.join(process.resourcesPath, '.env.example');
|
||
|
|
}
|
||
|
|
return path.join(appRootDev, '.env.example');
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveAppDir() {
|
||
|
|
if (app.isPackaged) {
|
||
|
|
// exe 所在目录
|
||
|
|
return path.dirname(app.getPath('exe'));
|
||
|
|
}
|
||
|
|
return app.getPath('userData');
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveBackendPath() {
|
||
|
|
if (process.env.DSA_BACKEND_PATH) {
|
||
|
|
return process.env.DSA_BACKEND_PATH;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (app.isPackaged) {
|
||
|
|
const backendDir = path.join(process.resourcesPath, 'backend');
|
||
|
|
const exeName = isWindows ? 'stock_analysis.exe' : 'stock_analysis';
|
||
|
|
const oneDirPath = path.join(backendDir, 'stock_analysis', exeName);
|
||
|
|
if (fs.existsSync(oneDirPath)) {
|
||
|
|
return oneDirPath;
|
||
|
|
}
|
||
|
|
return path.join(backendDir, exeName);
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function initLogging() {
|
||
|
|
const appDir = app.isPackaged ? path.dirname(app.getPath('exe')) : app.getPath('userData');
|
||
|
|
logFilePath = path.join(appDir, 'logs', 'desktop.log');
|
||
|
|
|
||
|
|
// 确保日志目录存在
|
||
|
|
const logDir = path.dirname(logFilePath);
|
||
|
|
if (!fs.existsSync(logDir)) {
|
||
|
|
fs.mkdirSync(logDir, { recursive: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
logLine('Desktop app starting');
|
||
|
|
}
|
||
|
|
|
||
|
|
function logLine(message) {
|
||
|
|
const timestamp = new Date().toISOString();
|
||
|
|
const line = `[${timestamp}] ${message}\n`;
|
||
|
|
try {
|
||
|
|
if (logFilePath) {
|
||
|
|
fs.appendFileSync(logFilePath, line, 'utf-8');
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error(error);
|
||
|
|
}
|
||
|
|
console.log(line.trim());
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatCommand(command, args = []) {
|
||
|
|
return [command, ...args]
|
||
|
|
.map((part) => {
|
||
|
|
const value = String(part);
|
||
|
|
return value.includes(' ') ? `"${value}"` : value;
|
||
|
|
})
|
||
|
|
.join(' ');
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolvePythonPath() {
|
||
|
|
return process.env.DSA_PYTHON || 'python';
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureEnvFile(envPath) {
|
||
|
|
if (fs.existsSync(envPath)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const envExample = resolveEnvExamplePath();
|
||
|
|
if (fs.existsSync(envExample)) {
|
||
|
|
fs.copyFileSync(envExample, envPath);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
fs.writeFileSync(envPath, '# Configure your API keys and stock list here.\n', 'utf-8');
|
||
|
|
}
|
||
|
|
|
||
|
|
function findAvailablePort(startPort = 8000, endPort = 8100) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const tryPort = (port) => {
|
||
|
|
if (port > endPort) {
|
||
|
|
reject(new Error('No available port'));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const server = net.createServer();
|
||
|
|
server.once('error', () => {
|
||
|
|
tryPort(port + 1);
|
||
|
|
});
|
||
|
|
server.once('listening', () => {
|
||
|
|
server.close(() => resolve(port));
|
||
|
|
});
|
||
|
|
server.listen(port, '127.0.0.1');
|
||
|
|
};
|
||
|
|
|
||
|
|
tryPort(startPort);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function waitForHealth(
|
||
|
|
url,
|
||
|
|
timeoutMs = 60000,
|
||
|
|
intervalMs = 250,
|
||
|
|
requestTimeoutMs = 1500,
|
||
|
|
shouldAbort = null,
|
||
|
|
onProgress = null
|
||
|
|
) {
|
||
|
|
const start = Date.now();
|
||
|
|
let attempts = 0;
|
||
|
|
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
let settled = false;
|
||
|
|
let retryTimer = null;
|
||
|
|
let activeRequest = null;
|
||
|
|
|
||
|
|
const emitProgress = (payload) => {
|
||
|
|
if (typeof onProgress !== 'function') {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
onProgress(payload);
|
||
|
|
} catch (_error) {
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const finish = (error, result) => {
|
||
|
|
if (settled) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
settled = true;
|
||
|
|
|
||
|
|
if (retryTimer) {
|
||
|
|
clearTimeout(retryTimer);
|
||
|
|
retryTimer = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (activeRequest && !activeRequest.destroyed) {
|
||
|
|
activeRequest.destroy();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (error) {
|
||
|
|
emitProgress({
|
||
|
|
type: 'final_error',
|
||
|
|
elapsedMs: Date.now() - start,
|
||
|
|
attempts,
|
||
|
|
message: error.message,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (error) {
|
||
|
|
reject(error);
|
||
|
|
} else {
|
||
|
|
resolve(result);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const scheduleNext = () => {
|
||
|
|
if (settled) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
retryTimer = setTimeout(attempt, intervalMs);
|
||
|
|
};
|
||
|
|
|
||
|
|
const attempt = () => {
|
||
|
|
if (settled) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (typeof shouldAbort === 'function') {
|
||
|
|
const abortReason = shouldAbort();
|
||
|
|
if (abortReason) {
|
||
|
|
emitProgress({
|
||
|
|
type: 'aborted',
|
||
|
|
elapsedMs: Date.now() - start,
|
||
|
|
attempts,
|
||
|
|
reason: abortReason,
|
||
|
|
});
|
||
|
|
finish(new Error(`Health check aborted: ${abortReason}`));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const elapsedMs = Date.now() - start;
|
||
|
|
if (elapsedMs > timeoutMs) {
|
||
|
|
emitProgress({
|
||
|
|
type: 'total_timeout',
|
||
|
|
elapsedMs,
|
||
|
|
attempts,
|
||
|
|
timeoutMs,
|
||
|
|
});
|
||
|
|
finish(new Error(`Health check timeout after ${elapsedMs}ms`));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
attempts += 1;
|
||
|
|
emitProgress({
|
||
|
|
type: 'probe_start',
|
||
|
|
elapsedMs,
|
||
|
|
attempts,
|
||
|
|
});
|
||
|
|
|
||
|
|
activeRequest = http.get(url, (res) => {
|
||
|
|
if (settled) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
res.resume();
|
||
|
|
if (res.statusCode === 200) {
|
||
|
|
const readyElapsedMs = Date.now() - start;
|
||
|
|
emitProgress({
|
||
|
|
type: 'ready',
|
||
|
|
elapsedMs: readyElapsedMs,
|
||
|
|
attempts,
|
||
|
|
});
|
||
|
|
finish(null, { elapsedMs: readyElapsedMs, attempts });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
emitProgress({
|
||
|
|
type: 'probe_status',
|
||
|
|
elapsedMs: Date.now() - start,
|
||
|
|
attempts,
|
||
|
|
statusCode: res.statusCode,
|
||
|
|
});
|
||
|
|
scheduleNext();
|
||
|
|
});
|
||
|
|
|
||
|
|
activeRequest.setTimeout(requestTimeoutMs, () => {
|
||
|
|
emitProgress({
|
||
|
|
type: 'probe_timeout',
|
||
|
|
elapsedMs: Date.now() - start,
|
||
|
|
attempts,
|
||
|
|
requestTimeoutMs,
|
||
|
|
});
|
||
|
|
activeRequest.destroy(new Error(`Health probe request timeout after ${requestTimeoutMs}ms`));
|
||
|
|
});
|
||
|
|
|
||
|
|
activeRequest.on('error', (error) => {
|
||
|
|
if (settled) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
emitProgress({
|
||
|
|
type: 'probe_error',
|
||
|
|
elapsedMs: Date.now() - start,
|
||
|
|
attempts,
|
||
|
|
errorCode: error.code || 'unknown',
|
||
|
|
errorMessage: error.message,
|
||
|
|
});
|
||
|
|
scheduleNext();
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
attempt();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function startBackend({ port, envFile, dbPath, logDir }) {
|
||
|
|
const backendPath = resolveBackendPath();
|
||
|
|
backendStartError = null;
|
||
|
|
const launchStartedAt = Date.now();
|
||
|
|
|
||
|
|
const env = {
|
||
|
|
...process.env,
|
||
|
|
DSA_DESKTOP_MODE: 'true',
|
||
|
|
ENV_FILE: envFile,
|
||
|
|
DATABASE_PATH: dbPath,
|
||
|
|
LOG_DIR: logDir,
|
||
|
|
PYTHONUTF8: '1',
|
||
|
|
SCHEDULE_ENABLED: 'false',
|
||
|
|
WEBUI_ENABLED: 'false',
|
||
|
|
BOT_ENABLED: 'false',
|
||
|
|
DINGTALK_STREAM_ENABLED: 'false',
|
||
|
|
FEISHU_STREAM_ENABLED: 'false',
|
||
|
|
};
|
||
|
|
|
||
|
|
const args = ['--serve-only', '--host', '127.0.0.1', '--port', String(port)];
|
||
|
|
let launchMode = '';
|
||
|
|
let launchCommand = '';
|
||
|
|
let launchCwd = '';
|
||
|
|
|
||
|
|
if (backendPath) {
|
||
|
|
if (!fs.existsSync(backendPath)) {
|
||
|
|
throw new Error(`Backend executable not found: ${backendPath}`);
|
||
|
|
}
|
||
|
|
launchMode = 'packaged';
|
||
|
|
launchCommand = formatCommand(backendPath, args);
|
||
|
|
launchCwd = path.dirname(backendPath);
|
||
|
|
backendProcess = spawn(backendPath, args, {
|
||
|
|
env,
|
||
|
|
cwd: launchCwd,
|
||
|
|
stdio: 'pipe',
|
||
|
|
windowsHide: true,
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
const pythonPath = resolvePythonPath();
|
||
|
|
const scriptPath = path.join(appRootDev, 'main.py');
|
||
|
|
launchMode = 'development';
|
||
|
|
launchCommand = formatCommand(pythonPath, [scriptPath, ...args]);
|
||
|
|
launchCwd = appRootDev;
|
||
|
|
backendProcess = spawn(pythonPath, [scriptPath, ...args], {
|
||
|
|
env,
|
||
|
|
cwd: launchCwd,
|
||
|
|
stdio: 'pipe',
|
||
|
|
windowsHide: true,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (backendProcess) {
|
||
|
|
let firstStdoutLogged = false;
|
||
|
|
let firstStderrLogged = false;
|
||
|
|
|
||
|
|
backendProcess.once('spawn', () => {
|
||
|
|
logLine(`[backend] spawned pid=${backendProcess.pid} in ${Date.now() - launchStartedAt}ms`);
|
||
|
|
});
|
||
|
|
backendProcess.on('error', (error) => {
|
||
|
|
backendStartError = error;
|
||
|
|
logLine(`[backend] failed to start: ${error.message}`);
|
||
|
|
});
|
||
|
|
backendProcess.stdout.on('data', (data) => {
|
||
|
|
if (!firstStdoutLogged) {
|
||
|
|
firstStdoutLogged = true;
|
||
|
|
logLine(`[backend] first stdout after ${Date.now() - launchStartedAt}ms`);
|
||
|
|
}
|
||
|
|
logLine(`[backend] ${String(data).trim()}`);
|
||
|
|
});
|
||
|
|
backendProcess.stderr.on('data', (data) => {
|
||
|
|
if (!firstStderrLogged) {
|
||
|
|
firstStderrLogged = true;
|
||
|
|
logLine(`[backend] first stderr after ${Date.now() - launchStartedAt}ms`);
|
||
|
|
}
|
||
|
|
logLine(`[backend] ${String(data).trim()}`);
|
||
|
|
});
|
||
|
|
backendProcess.on('exit', (code, signal) => {
|
||
|
|
logLine(`[backend] exited with code ${code}, signal ${signal || 'none'}`);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
mode: launchMode,
|
||
|
|
command: launchCommand,
|
||
|
|
cwd: launchCwd,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function stopBackend() {
|
||
|
|
if (!backendProcess || backendProcess.killed) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isWindows) {
|
||
|
|
spawn('taskkill', ['/PID', String(backendProcess.pid), '/T', '/F']);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
backendProcess.kill('SIGTERM');
|
||
|
|
setTimeout(() => {
|
||
|
|
if (!backendProcess.killed) {
|
||
|
|
backendProcess.kill('SIGKILL');
|
||
|
|
}
|
||
|
|
}, 3000);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function createWindow() {
|
||
|
|
initLogging();
|
||
|
|
const startupStartedAt = Date.now();
|
||
|
|
const logStartup = (message) => {
|
||
|
|
logLine(`[startup +${Date.now() - startupStartedAt}ms] ${message}`);
|
||
|
|
};
|
||
|
|
|
||
|
|
logStartup('createWindow started');
|
||
|
|
|
||
|
|
mainWindow = new BrowserWindow({
|
||
|
|
width: 1200,
|
||
|
|
height: 800,
|
||
|
|
minWidth: 960,
|
||
|
|
minHeight: 640,
|
||
|
|
backgroundColor: '#0f172a',
|
||
|
|
webPreferences: {
|
||
|
|
preload: path.join(__dirname, 'preload.js'),
|
||
|
|
nodeIntegration: false,
|
||
|
|
contextIsolation: true,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
logStartup('BrowserWindow created');
|
||
|
|
|
||
|
|
const loadingPath = path.join(__dirname, 'renderer', 'loading.html');
|
||
|
|
const loadingPageStartedAt = Date.now();
|
||
|
|
await mainWindow.loadFile(loadingPath);
|
||
|
|
logStartup(`Loading page rendered in ${Date.now() - loadingPageStartedAt}ms`);
|
||
|
|
|
||
|
|
const webViewStartedAt = Date.now();
|
||
|
|
mainWindow.webContents.on('did-start-loading', () => {
|
||
|
|
logStartup('WebContents did-start-loading');
|
||
|
|
});
|
||
|
|
mainWindow.webContents.on('dom-ready', () => {
|
||
|
|
logStartup(`WebContents dom-ready (+${Date.now() - webViewStartedAt}ms after events attached)`);
|
||
|
|
});
|
||
|
|
mainWindow.webContents.on('did-finish-load', () => {
|
||
|
|
logStartup(`WebContents did-finish-load (+${Date.now() - webViewStartedAt}ms after events attached)`);
|
||
|
|
});
|
||
|
|
mainWindow.webContents.on(
|
||
|
|
'did-fail-load',
|
||
|
|
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||
|
|
logStartup(
|
||
|
|
`WebContents did-fail-load code=${errorCode} mainFrame=${isMainFrame} url=${validatedURL} reason=${errorDescription}`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||
|
|
shell.openExternal(url);
|
||
|
|
return { action: 'deny' };
|
||
|
|
});
|
||
|
|
|
||
|
|
const appDir = resolveAppDir();
|
||
|
|
const envPath = path.join(appDir, '.env');
|
||
|
|
ensureEnvFile(envPath);
|
||
|
|
logStartup(`Env file ready: ${envPath}`);
|
||
|
|
|
||
|
|
const portFindStartedAt = Date.now();
|
||
|
|
const port = await findAvailablePort(8000, 8100);
|
||
|
|
logStartup(`Using port ${port} (selected in ${Date.now() - portFindStartedAt}ms)`);
|
||
|
|
logStartup(`App directory=${appDir}`);
|
||
|
|
|
||
|
|
const dbPath = path.join(appDir, 'data', 'stock_analysis.db');
|
||
|
|
const logDir = path.join(appDir, 'logs');
|
||
|
|
|
||
|
|
try {
|
||
|
|
const launchInfo = startBackend({ port, envFile: envPath, dbPath, logDir });
|
||
|
|
logStartup(`Backend launch mode=${launchInfo.mode}`);
|
||
|
|
logStartup(`Backend launch command=${launchInfo.command}`);
|
||
|
|
logStartup(`Backend launch cwd=${launchInfo.cwd}`);
|
||
|
|
logStartup('Waiting for backend health check');
|
||
|
|
} catch (error) {
|
||
|
|
logStartup(`Backend launch failed: ${String(error)}`);
|
||
|
|
const errorUrl = `file://${loadingPath}?error=${encodeURIComponent(String(error))}`;
|
||
|
|
await mainWindow.loadURL(errorUrl);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const healthUrl = `http://127.0.0.1:${port}/api/health`;
|
||
|
|
let lastHealthProgressLogAt = 0;
|
||
|
|
const healthProgressLogIntervalMs = 2000;
|
||
|
|
|
||
|
|
const onHealthProgress = (event) => {
|
||
|
|
if (!event || event.type === 'probe_start') {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (event.type === 'ready') {
|
||
|
|
logStartup(`Health ready in ${event.elapsedMs}ms (attempts=${event.attempts})`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (event.type === 'aborted' || event.type === 'total_timeout' || event.type === 'final_error') {
|
||
|
|
const details = event.reason || event.message || '';
|
||
|
|
logStartup(`Health ${event.type} after ${event.elapsedMs}ms (attempts=${event.attempts}) ${details}`.trim());
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const now = Date.now();
|
||
|
|
if (now - lastHealthProgressLogAt < healthProgressLogIntervalMs) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
lastHealthProgressLogAt = now;
|
||
|
|
let detail = '';
|
||
|
|
if (event.type === 'probe_status') {
|
||
|
|
detail = `status=${event.statusCode}`;
|
||
|
|
} else if (event.type === 'probe_timeout') {
|
||
|
|
detail = `probeTimeout=${event.requestTimeoutMs}ms`;
|
||
|
|
} else if (event.type === 'probe_error') {
|
||
|
|
detail = `error=${event.errorCode}:${event.errorMessage}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
logStartup(
|
||
|
|
`Waiting for backend health... elapsed=${event.elapsedMs}ms attempts=${event.attempts}${detail ? ` ${detail}` : ''}`
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
try {
|
||
|
|
const healthInfo = await waitForHealth(
|
||
|
|
healthUrl,
|
||
|
|
60000,
|
||
|
|
250,
|
||
|
|
1500,
|
||
|
|
() => {
|
||
|
|
if (backendStartError) {
|
||
|
|
return `backend start error: ${backendStartError.message}`;
|
||
|
|
}
|
||
|
|
if (!backendProcess) {
|
||
|
|
return 'backend process is unavailable';
|
||
|
|
}
|
||
|
|
if (backendProcess.exitCode !== null) {
|
||
|
|
return `backend exited with code ${backendProcess.exitCode}`;
|
||
|
|
}
|
||
|
|
if (backendProcess.signalCode) {
|
||
|
|
return `backend exited by signal ${backendProcess.signalCode}`;
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
onHealthProgress
|
||
|
|
);
|
||
|
|
logStartup(`Backend ready in ${healthInfo.elapsedMs}ms (${healthInfo.attempts} probes)`);
|
||
|
|
const mainPageStartedAt = Date.now();
|
||
|
|
await mainWindow.loadURL(`http://127.0.0.1:${port}/`);
|
||
|
|
logStartup(`Main page loadURL resolved in ${Date.now() - mainPageStartedAt}ms`);
|
||
|
|
logStartup(`Main UI loaded in ${Date.now() - startupStartedAt}ms`);
|
||
|
|
} catch (error) {
|
||
|
|
logStartup(`Startup failed while waiting for health: ${String(error)}`);
|
||
|
|
const errorUrl = `file://${loadingPath}?error=${encodeURIComponent(String(error))}`;
|
||
|
|
await mainWindow.loadURL(errorUrl);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
app.whenReady().then(createWindow);
|
||
|
|
|
||
|
|
app.on('activate', () => {
|
||
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||
|
|
createWindow();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
app.on('window-all-closed', () => {
|
||
|
|
stopBackend();
|
||
|
|
if (process.platform !== 'darwin') {
|
||
|
|
app.quit();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
app.on('before-quit', () => {
|
||
|
|
stopBackend();
|
||
|
|
});
|