feat: Add embedded backend packaging for all-in-one deployment
- Add backend/run_server.py entry point for embedded deployment - Add backend/build.py PyInstaller script for backend packaging - Modify config.py to support frozen executable paths - Extend client/config.json with backend configuration section - Add backend sidecar management in Electron main process - Add Whisper model download progress reporting - Update build-client.bat with --embedded-backend flag - Update DEPLOYMENT.md with all-in-one deployment documentation This enables packaging frontend and backend into a single executable for simplified enterprise deployment. Backward compatible with existing separate deployment mode (backend.embedded: false). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,10 @@ let streamingActive = false;
|
||||
let appConfig = null;
|
||||
let activeWhisperConfig = null;
|
||||
|
||||
// Backend sidecar state
|
||||
let backendProcess = null;
|
||||
let backendReady = false;
|
||||
|
||||
/**
|
||||
* Load configuration from external config.json
|
||||
* Config file location:
|
||||
@@ -67,6 +71,189 @@ function loadConfig() {
|
||||
return appConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get config.json path for backend sidecar
|
||||
*/
|
||||
function getConfigPath() {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, "config.json");
|
||||
}
|
||||
return path.join(__dirname, "..", "config.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Start backend sidecar process (FastAPI server)
|
||||
* Only starts if backend.embedded is true in config
|
||||
*/
|
||||
function startBackendSidecar() {
|
||||
const backendConfig = appConfig?.backend || {};
|
||||
|
||||
// Check if embedded backend is enabled
|
||||
if (!backendConfig.embedded) {
|
||||
console.log("Backend embedded mode disabled, using remote backend at:", appConfig?.apiBaseUrl);
|
||||
backendReady = true; // Assume remote backend is ready
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Starting embedded backend sidecar...");
|
||||
|
||||
const backendDir = app.isPackaged
|
||||
? path.join(process.resourcesPath, "backend")
|
||||
: path.join(__dirname, "..", "..", "backend");
|
||||
|
||||
// Determine the backend executable path
|
||||
let backendExecutable;
|
||||
let backendArgs = [];
|
||||
|
||||
if (app.isPackaged) {
|
||||
// Packaged app: use PyInstaller-built executable
|
||||
if (process.platform === "win32") {
|
||||
backendExecutable = path.join(backendDir, "backend", "backend.exe");
|
||||
} else {
|
||||
backendExecutable = path.join(backendDir, "backend", "backend");
|
||||
}
|
||||
// Pass config path
|
||||
backendArgs = ["--config", getConfigPath()];
|
||||
} else {
|
||||
// Development mode: use Python script with venv
|
||||
const backendScript = path.join(backendDir, "run_server.py");
|
||||
|
||||
if (!fs.existsSync(backendScript)) {
|
||||
console.log("Backend script not found at:", backendScript);
|
||||
console.log("Backend sidecar will not be available.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for virtual environment Python
|
||||
let venvPython;
|
||||
if (process.platform === "win32") {
|
||||
venvPython = path.join(backendDir, "venv", "Scripts", "python.exe");
|
||||
} else {
|
||||
venvPython = path.join(backendDir, "venv", "bin", "python");
|
||||
}
|
||||
|
||||
backendExecutable = fs.existsSync(venvPython) ? venvPython : "python3";
|
||||
backendArgs = [backendScript, "--config", getConfigPath()];
|
||||
}
|
||||
|
||||
if (!fs.existsSync(backendExecutable) && app.isPackaged) {
|
||||
console.log("Backend executable not found at:", backendExecutable);
|
||||
console.log("Backend sidecar will not be available.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Starting backend with:", backendExecutable, backendArgs.join(" "));
|
||||
|
||||
backendProcess = spawn(backendExecutable, backendArgs, {
|
||||
cwd: backendDir,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
backendProcess.stdout.on("data", (data) => {
|
||||
console.log("Backend:", data.toString().trim());
|
||||
});
|
||||
|
||||
backendProcess.stderr.on("data", (data) => {
|
||||
console.log("Backend:", data.toString().trim());
|
||||
});
|
||||
|
||||
backendProcess.on("close", (code) => {
|
||||
console.log(`Backend exited with code ${code}`);
|
||||
backendReady = false;
|
||||
backendProcess = null;
|
||||
});
|
||||
|
||||
backendProcess.on("error", (err) => {
|
||||
console.error("Backend error:", err.message);
|
||||
backendReady = false;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to start backend:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for backend to be ready by polling health endpoint
|
||||
* @param {number} maxAttempts - Maximum number of attempts (default 30)
|
||||
* @param {number} intervalMs - Interval between attempts in ms (default 1000)
|
||||
* @returns {Promise<boolean>} - True if backend is ready, false if timeout
|
||||
*/
|
||||
async function waitForBackendReady(maxAttempts = 30, intervalMs = 1000) {
|
||||
const backendConfig = appConfig?.backend || {};
|
||||
|
||||
// If embedded mode is disabled, assume backend is ready
|
||||
if (!backendConfig.embedded) {
|
||||
backendReady = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
const host = backendConfig.host || "127.0.0.1";
|
||||
const port = backendConfig.port || 8000;
|
||||
const healthUrl = `http://${host}:${port}/api/health`;
|
||||
|
||||
console.log(`Waiting for backend at ${healthUrl}...`);
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const http = require("http");
|
||||
const ready = await new Promise((resolve) => {
|
||||
const req = http.get(healthUrl, (res) => {
|
||||
resolve(res.statusCode === 200);
|
||||
});
|
||||
req.on("error", () => resolve(false));
|
||||
req.setTimeout(2000, () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
if (ready) {
|
||||
console.log(`Backend ready after ${attempt} attempt(s)`);
|
||||
backendReady = true;
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors, will retry
|
||||
}
|
||||
|
||||
console.log(`Backend health check attempt ${attempt}/${maxAttempts} failed, retrying...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
|
||||
console.error(`Backend did not become ready after ${maxAttempts} attempts`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop backend sidecar process
|
||||
*/
|
||||
function stopBackendSidecar() {
|
||||
if (!backendProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Stopping backend sidecar...");
|
||||
|
||||
// Send SIGTERM first
|
||||
backendProcess.kill("SIGTERM");
|
||||
|
||||
// Force kill after 5 seconds if still running
|
||||
const forceKillTimeout = setTimeout(() => {
|
||||
if (backendProcess) {
|
||||
console.log("Force killing backend sidecar...");
|
||||
backendProcess.kill("SIGKILL");
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
backendProcess.on("close", () => {
|
||||
clearTimeout(forceKillTimeout);
|
||||
backendProcess = null;
|
||||
backendReady = false;
|
||||
});
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
// Set window title from config
|
||||
const windowTitle = appConfig?.appTitle || "Meeting Assistant";
|
||||
@@ -205,6 +392,26 @@ function startSidecar() {
|
||||
if (msg.result !== undefined && mainWindow) {
|
||||
mainWindow.webContents.send("transcription-result", msg.result);
|
||||
}
|
||||
|
||||
// Forward model download progress to renderer
|
||||
if (msg.status === "downloading_model" && mainWindow) {
|
||||
mainWindow.webContents.send("model-download-progress", msg);
|
||||
}
|
||||
|
||||
// Forward model downloaded status
|
||||
if (msg.status === "model_downloaded" && mainWindow) {
|
||||
mainWindow.webContents.send("model-download-progress", msg);
|
||||
}
|
||||
|
||||
// Forward model loading status
|
||||
if (msg.status === "loading_model" && mainWindow) {
|
||||
mainWindow.webContents.send("model-download-progress", msg);
|
||||
}
|
||||
|
||||
// Forward model loaded status
|
||||
if (msg.status === "model_loaded" && mainWindow) {
|
||||
mainWindow.webContents.send("model-download-progress", msg);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Sidecar output:", line);
|
||||
}
|
||||
@@ -229,10 +436,28 @@ function startSidecar() {
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
app.whenReady().then(async () => {
|
||||
// Load configuration first
|
||||
loadConfig();
|
||||
|
||||
// Start backend sidecar if embedded mode is enabled
|
||||
startBackendSidecar();
|
||||
|
||||
// Wait for backend to be ready before creating window
|
||||
const backendConfig = appConfig?.backend || {};
|
||||
if (backendConfig.embedded) {
|
||||
const ready = await waitForBackendReady();
|
||||
if (!ready) {
|
||||
const { dialog } = require("electron");
|
||||
dialog.showErrorBox(
|
||||
"Backend Startup Failed",
|
||||
"The backend server failed to start within 30 seconds. Please check the logs for details."
|
||||
);
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
createWindow();
|
||||
startSidecar();
|
||||
|
||||
@@ -244,12 +469,17 @@ app.whenReady().then(() => {
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
// Stop transcriber sidecar
|
||||
if (sidecarProcess) {
|
||||
try {
|
||||
sidecarProcess.stdin.write(JSON.stringify({ action: "quit" }) + "\n");
|
||||
} catch (e) {}
|
||||
sidecarProcess.kill();
|
||||
}
|
||||
|
||||
// Stop backend sidecar
|
||||
stopBackendSidecar();
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
@@ -274,6 +504,19 @@ ipcMain.handle("get-sidecar-status", () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Get backend status (for renderer to check backend readiness)
|
||||
ipcMain.handle("get-backend-status", () => {
|
||||
const backendConfig = appConfig?.backend || {};
|
||||
const host = backendConfig.host || "127.0.0.1";
|
||||
const port = backendConfig.port || 8000;
|
||||
|
||||
return {
|
||||
ready: backendReady,
|
||||
embedded: backendConfig.embedded || false,
|
||||
url: `http://${host}:${port}`
|
||||
};
|
||||
});
|
||||
|
||||
// === Streaming Mode IPC Handlers ===
|
||||
|
||||
ipcMain.handle("start-recording-stream", async () => {
|
||||
|
||||
Reference in New Issue
Block a user