diff --git a/main.js b/main.js index 6c2d621..9157cce 100644 --- a/main.js +++ b/main.js @@ -1,167 +1,184 @@ -const { app, BrowserWindow, dialog } = require('electron'); -const { spawn } = require('child_process'); -const path = require('path'); -const fs = require('fs'); -const http = require('http'); -const net = require('net'); - -let mainWindow; -let flaskProcess; -let activePort; -let serverReady = false; -let startupInterval = null; -let startupTimeout = null; - -const HOST = '127.0.0.1'; -const DEFAULT_PORT = 5000; -const MAX_SCAN_PORT = 5100; -const POLL_MS = 200; +const { app, BrowserWindow, dialog } = require('electron'); +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const http = require('http'); +const net = require('net'); + +let mainWindow; +let flaskProcess; +let activePort; +let serverReady = false; +let startupInterval = null; +let startupTimeout = null; + +const HOST = '127.0.0.1'; +const DEFAULT_PORT = 5000; +const MAX_SCAN_PORT = 5100; +const POLL_MS = 200; const STARTUP_TIMEOUT_MS = 60_000; const SAFE_EXTERNAL_PROTOCOLS = new Set(['http:', 'https:']); - -// We must preserve the python path to our bundled app or local python -function resolvePythonCmd() { - const venv = process.env.VIRTUAL_ENV; - if (venv) { - const venvPython = process.platform === 'win32' - ? path.join(venv, 'Scripts', 'python.exe') - : path.join(venv, 'bin', 'python'); - if (fs.existsSync(venvPython)) { - return venvPython; - } - } - return process.platform === 'win32' ? 'python' : 'python3'; -} - -const pythonCmd = resolvePythonCmd(); - -function parseDevShellPort(raw) { - if (raw === undefined || raw === '') { - return null; - } - const port = Number(raw); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error( - `Invalid DEVSHELL_PORT: ${JSON.stringify(raw)} (must be integer 1-65535)` - ); - } - return port; -} - -function isPortFree(port, host) { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.once('error', (err) => { - if (err.code === 'EADDRINUSE') { - resolve(false); - } else { - reject(err); - } - }); - server.once('listening', () => { - server.close(() => resolve(true)); - }); - server.listen(port, host); - }); -} - -async function resolvePort() { - const override = parseDevShellPort(process.env.DEVSHELL_PORT); - if (override !== null) { - if (!(await isPortFree(override, HOST))) { - throw new Error( - `DEVSHELL_PORT ${override} is already in use on ${HOST}` - ); - } - return override; - } - for (let p = DEFAULT_PORT; p <= MAX_SCAN_PORT; p++) { - if (await isPortFree(p, HOST)) { - return p; - } - } - throw new Error( - `No free port on ${HOST} in range ${DEFAULT_PORT}-${MAX_SCAN_PORT}` - ); -} - -function clearStartupTimers() { - if (startupInterval) { - clearInterval(startupInterval); - startupInterval = null; - } - if (startupTimeout) { - clearTimeout(startupTimeout); - startupTimeout = null; - } -} - -function showStartupError(title, message) { - clearStartupTimers(); - dialog.showErrorBox(title, message); - if (mainWindow) { - const body = `${message}\n\nIf port 5000 is in use (e.g. macOS AirPlay Receiver), unset DEVSHELL_PORT and restart, or set DEVSHELL_PORT to a free port.`; - mainWindow.loadURL( - `data:text/html,${encodeURIComponent( - `
${body}`
- )}`
- );
- }
-}
-
-function onServerReady(url) {
- if (serverReady) {
- return;
- }
- serverReady = true;
- clearStartupTimers();
- console.log('Server is ready. Loading UI...');
- mainWindow.loadURL(url);
-}
-
-function loadWhenReady(url) {
- clearStartupTimers();
- serverReady = false;
-
- const checkServer = () => {
- http.get(url, (res) => {
- if (res.statusCode && res.statusCode < 500) {
- onServerReady(url);
- }
- res.resume();
- }).on('error', () => {});
- };
-
- startupInterval = setInterval(checkServer, POLL_MS);
- checkServer();
-
- startupTimeout = setTimeout(() => {
- if (!serverReady) {
- showStartupError(
- 'DevShell failed to start',
- 'The backend server did not respond in time. Another process may be using the port, or Flask failed to start.'
- );
- app.quit();
- }
- }, STARTUP_TIMEOUT_MS);
-}
-
-function createWindow(port) {
- const baseUrl = `http://${HOST}:${port}`;
-
- mainWindow = new BrowserWindow({
- width: 1200,
- height: 800,
- minWidth: 900,
- minHeight: 600,
- title: "DevShell",
- autoHideMenuBar: true,
- webPreferences: {
- nodeIntegration: false,
- contextIsolation: true
- }
- });
-
+
+// We must preserve the python path to our bundled app or local python
+function resolvePythonCmd() {
+ if (app.isPackaged) {
+ const pythonDir = path.join(process.resourcesPath, 'python');
+ const pythonPath = process.platform === 'win32'
+ ? path.join(pythonDir, 'python.exe')
+ : path.join(pythonDir, 'bin', 'python');
+ if (!fs.existsSync(pythonPath)) {
+ console.error(`Bundled Python interpreter not found at ${pythonPath}`);
+ dialog.showErrorBox(
+ 'DevShell Error',
+ 'Bundled Python resources are missing. Please reinstall the application.'
+ );
+ app.quit();
+ }
+ return pythonPath;
+ } else {
+ // Development mode: retain existing logic for VIRTUAL_ENV
+ const venv = process.env.VIRTUAL_ENV;
+ if (venv) {
+ const venvPython = process.platform === 'win32'
+ ? path.join(venv, 'Scripts', 'python.exe')
+ : path.join(venv, 'bin', 'python');
+ if (fs.existsSync(venvPython)) {
+ return venvPython;
+ }
+ }
+ return process.platform === 'win32' ? 'python' : 'python3';
+ }
+}
+
+const pythonCmd = resolvePythonCmd();
+
+function parseDevShellPort(raw) {
+ if (raw === undefined || raw === '') {
+ return null;
+ }
+ const port = Number(raw);
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
+ throw new Error(
+ `Invalid DEVSHELL_PORT: ${JSON.stringify(raw)} (must be integer 1-65535)`
+ );
+ }
+ return port;
+}
+
+function isPortFree(port, host) {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.once('error', (err) => {
+ if (err.code === 'EADDRINUSE') {
+ resolve(false);
+ } else {
+ reject(err);
+ }
+ });
+ server.once('listening', () => {
+ server.close(() => resolve(true));
+ });
+ server.listen(port, host);
+ });
+}
+
+async function resolvePort() {
+ const override = parseDevShellPort(process.env.DEVSHELL_PORT);
+ if (override !== null) {
+ if (!(await isPortFree(override, HOST))) {
+ throw new Error(
+ `DEVSHELL_PORT ${override} is already in use on ${HOST}`
+ );
+ }
+ return override;
+ }
+ for (let p = DEFAULT_PORT; p <= MAX_SCAN_PORT; p++) {
+ if (await isPortFree(p, HOST)) {
+ return p;
+ }
+ }
+ throw new Error(
+ `No free port on ${HOST} in range ${DEFAULT_PORT}-${MAX_SCAN_PORT}`
+ );
+}
+
+function clearStartupTimers() {
+ if (startupInterval) {
+ clearInterval(startupInterval);
+ startupInterval = null;
+ }
+ if (startupTimeout) {
+ clearTimeout(startupTimeout);
+ startupTimeout = null;
+ }
+}
+
+function showStartupError(title, message) {
+ clearStartupTimers();
+ dialog.showErrorBox(title, message);
+ if (mainWindow) {
+ const body = `${message}\n\nIf port 5000 is in use (e.g. macOS AirPlay Receiver), unset DEVSHELL_PORT and restart, or set DEVSHELL_PORT to a free port.`;
+ mainWindow.loadURL(
+ `data:text/html,${encodeURIComponent(
+ `${body}`
+ )}`
+ );
+ }
+}
+
+function onServerReady(url) {
+ if (serverReady) {
+ return;
+ }
+ serverReady = true;
+ clearStartupTimers();
+ console.log('Server is ready. Loading UI...');
+ mainWindow.loadURL(url);
+}
+
+function loadWhenReady(url) {
+ clearStartupTimers();
+ serverReady = false;
+
+ const checkServer = () => {
+ http.get(url, (res) => {
+ if (res.statusCode && res.statusCode < 500) {
+ onServerReady(url);
+ }
+ res.resume();
+ }).on('error', () => {});
+ };
+
+ startupInterval = setInterval(checkServer, POLL_MS);
+ checkServer();
+
+ startupTimeout = setTimeout(() => {
+ if (!serverReady) {
+ showStartupError(
+ 'DevShell failed to start',
+ 'The backend server did not respond in time. Another process may be using the port, or Flask failed to start.'
+ );
+ app.quit();
+ }
+ }, STARTUP_TIMEOUT_MS);
+}
+
+function createWindow(port) {
+ const baseUrl = `http://${HOST}:${port}`;
+
+ mainWindow = new BrowserWindow({
+ width: 1200,
+ height: 800,
+ minWidth: 900,
+ minHeight: 600,
+ title: "DevShell",
+ autoHideMenuBar: true,
+ webPreferences: {
+ nodeIntegration: false,
+ contextIsolation: true
+ }
+ });
+
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
let target;
try {
@@ -180,81 +197,81 @@ function createWindow(port) {
return { action: 'deny' };
});
-
- loadWhenReady(baseUrl);
-
- mainWindow.on('closed', function () {
- mainWindow = null;
- });
-}
-
-function startFlaskServer(port) {
- console.log(`Starting Python server on ${HOST}:${port} (using ${pythonCmd})`);
-
- flaskProcess = spawn(pythonCmd, ['app.py'], {
- cwd: __dirname,
- env: {
- ...process.env,
- DEVSHELL_PORT: String(port),
- DEV_SHELL_DATA_DIR: app.getPath('userData'),
- }
- });
-
- flaskProcess.on('error', (err) => {
- showStartupError(
- 'DevShell failed to start',
- `Failed to launch Python backend: ${err.message}. Make sure Python is installed and available as "${pythonCmd}".`
- );
- app.quit();
- });
-
- flaskProcess.stdout.on('data', (data) => {
- console.log(`Flask: ${data}`);
- });
-
- flaskProcess.stderr.on('data', (data) => {
- console.error(`Flask Err: ${data}`);
- });
-
- flaskProcess.on('close', (code) => {
- console.log(`Flask process exited with code ${code}`);
- if (!serverReady && code !== 0) {
- showStartupError(
- 'DevShell failed to start',
- `The backend server exited unexpectedly (code ${code}). If Flask fails to bind even after port resolution, this error is shown instead of polling forever.`
- );
- app.quit();
- }
- });
-}
-
-app.whenReady().then(async () => {
- try {
- const port = await resolvePort();
- activePort = port;
- startFlaskServer(port);
- createWindow(port);
- } catch (err) {
- showStartupError('DevShell failed to start', err.message);
- app.quit();
- }
-});
-
-app.on('window-all-closed', function () {
- if (process.platform !== 'darwin') {
- app.quit();
- }
-});
-
-app.on('activate', function () {
- if (mainWindow === null && activePort) {
- createWindow(activePort);
- }
-});
-
-app.on('will-quit', () => {
- clearStartupTimers();
- if (flaskProcess) {
- flaskProcess.kill();
- }
-});
+
+ loadWhenReady(baseUrl);
+
+ mainWindow.on('closed', function () {
+ mainWindow = null;
+ });
+}
+
+function startFlaskServer(port) {
+ console.log(`Starting Python server on ${HOST}:${port} (using ${pythonCmd})`);
+
+ flaskProcess = spawn(pythonCmd, ['app.py'], {
+ cwd: __dirname,
+ env: {
+ ...process.env,
+ DEVSHELL_PORT: String(port),
+ DEV_SHELL_DATA_DIR: app.getPath('userData'),
+ }
+ });
+
+ flaskProcess.on('error', (err) => {
+ showStartupError(
+ 'DevShell failed to start',
+ `Failed to launch Python backend: ${err.message}. Make sure Python is installed and available as "${pythonCmd}".`
+ );
+ app.quit();
+ });
+
+ flaskProcess.stdout.on('data', (data) => {
+ console.log(`Flask: ${data}`);
+ });
+
+ flaskProcess.stderr.on('data', (data) => {
+ console.error(`Flask Err: ${data}`);
+ });
+
+ flaskProcess.on('close', (code) => {
+ console.log(`Flask process exited with code ${code}`);
+ if (!serverReady && code !== 0) {
+ showStartupError(
+ 'DevShell failed to start',
+ `The backend server exited unexpectedly (code ${code}). If Flask fails to bind even after port resolution, this error is shown instead of polling forever.`
+ );
+ app.quit();
+ }
+ });
+}
+
+app.whenReady().then(async () => {
+ try {
+ const port = await resolvePort();
+ activePort = port;
+ startFlaskServer(port);
+ createWindow(port);
+ } catch (err) {
+ showStartupError('DevShell failed to start', err.message);
+ app.quit();
+ }
+});
+
+app.on('window-all-closed', function () {
+ if (process.platform !== 'darwin') {
+ app.quit();
+ }
+});
+
+app.on('activate', function () {
+ if (mainWindow === null && activePort) {
+ createWindow(activePort);
+ }
+});
+
+app.on('will-quit', () => {
+ clearStartupTimers();
+ if (flaskProcess) {
+ flaskProcess.kill();
+ }
+});