SurfSense/surfsense_desktop/src/main.ts

192 lines
5.2 KiB
TypeScript

import { app, BrowserWindow, shell, ipcMain, session } from 'electron';
import path from 'path';
const isDev = !app.isPackaged;
let mainWindow: BrowserWindow | null = null;
let deepLinkUrl: string | null = null;
let serverPort: number = 3000;
const PROTOCOL = 'surfsense';
// Injected at compile time from .env.desktop via esbuild define
const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string;
function getStandalonePath(): string {
if (isDev) {
return path.join(__dirname, '..', '..', 'surfsense_web', '.next', 'standalone', 'surfsense_web');
}
return path.join(process.resourcesPath, 'standalone');
}
async function waitForServer(url: string, maxRetries = 60): Promise<boolean> {
for (let i = 0; i < maxRetries; i++) {
try {
const res = await fetch(url);
if (res.ok || res.status === 404 || res.status === 500) return true;
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 500));
}
return false;
}
async function startNextServer(): Promise<void> {
if (isDev) return;
const standalonePath = getStandalonePath();
const serverScript = path.join(standalonePath, 'server.js');
// The standalone server.js reads PORT / HOSTNAME from process.env and
// uses process.chdir(__dirname). Running it via require() in the same
// process is the proven approach (avoids spawning a second Electron
// instance whose ASAR-patched fs breaks Next.js static file serving).
process.env.PORT = String(serverPort);
process.env.HOSTNAME = 'localhost';
process.env.NODE_ENV = 'production';
process.chdir(standalonePath);
require(serverScript);
const ready = await waitForServer(`http://localhost:${serverPort}`);
if (!ready) {
throw new Error('Next.js server failed to start within 30 s');
}
console.log(`Next.js server ready on port ${serverPort}`);
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 800,
minHeight: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webviewTag: false,
},
show: false,
titleBarStyle: 'hiddenInset',
});
mainWindow.once('ready-to-show', () => {
mainWindow?.show();
});
mainWindow.loadURL(`http://localhost:${serverPort}/login`);
// External links open in system browser, not in the Electron window
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http://localhost')) {
return { action: 'allow' };
}
shell.openExternal(url);
return { action: 'deny' };
});
// Intercept backend OAuth redirects targeting the hosted web frontend
// and rewrite them to localhost so the user stays in the desktop app.
const filter = { urls: [`${HOSTED_FRONTEND_URL}/*`] };
session.defaultSession.webRequest.onBeforeRequest(filter, (details, callback) => {
const rewritten = details.url.replace(HOSTED_FRONTEND_URL, `http://localhost:${serverPort}`);
callback({ redirectURL: rewritten });
});
if (isDev) {
mainWindow.webContents.openDevTools();
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
// IPC handlers
ipcMain.on('open-external', (_event, url: string) => {
shell.openExternal(url);
});
ipcMain.handle('get-app-version', () => {
return app.getVersion();
});
// Deep link handling
function handleDeepLink(url: string) {
if (!url.startsWith(`${PROTOCOL}://`)) return;
deepLinkUrl = url;
if (!mainWindow) return;
// Rewrite surfsense:// deep link to localhost so TokenHandler.tsx processes it
const parsed = new URL(url);
if (parsed.hostname === 'auth' && parsed.pathname === '/callback') {
const params = parsed.searchParams.toString();
mainWindow.loadURL(`http://localhost:${serverPort}/auth/callback?${params}`);
}
mainWindow.show();
mainWindow.focus();
}
// Single instance lock — second instance passes deep link to first
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', (_event, argv) => {
// Windows/Linux: deep link URL is in argv
const url = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`));
if (url) handleDeepLink(url);
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
}
// macOS: deep link arrives via open-url event
app.on('open-url', (event, url) => {
event.preventDefault();
handleDeepLink(url);
});
// Register surfsense:// protocol
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [path.resolve(process.argv[1])]);
}
} else {
app.setAsDefaultProtocolClient(PROTOCOL);
}
// App lifecycle
app.whenReady().then(async () => {
await startNextServer();
createWindow();
// If a deep link was received before the window was ready, handle it now
if (deepLinkUrl) {
handleDeepLink(deepLinkUrl);
deepLinkUrl = null;
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('will-quit', () => {
// Server runs in-process — no child process to kill
});