diff --git a/surfsense_desktop/electron-builder.yml b/surfsense_desktop/electron-builder.yml index e4f510718..eaca0f19b 100644 --- a/surfsense_desktop/electron-builder.yml +++ b/surfsense_desktop/electron-builder.yml @@ -17,6 +17,10 @@ extraResources: to: standalone/ filter: - "**/*" + - "!**/node_modules" + - from: ../surfsense_web/.next/standalone/surfsense_web/node_modules/ + to: standalone/node_modules/ + filter: ["**/*"] - from: ../surfsense_web/.next/static/ to: standalone/.next/static/ filter: ["**/*"] @@ -51,11 +55,13 @@ linux: icon: assets/icon.png category: Utility artifactName: "${productName}-${version}-${arch}.${ext}" + mimeTypes: + - x-scheme-handler/surfsense desktop: - Name: SurfSense - Comment: AI-powered research assistant - Categories: Utility;Office; - MimeType: x-scheme-handler/surfsense; + entry: + Name: SurfSense + Comment: AI-powered research assistant + Categories: Utility;Office; target: - deb - AppImage diff --git a/surfsense_desktop/scripts/build-electron.mjs b/surfsense_desktop/scripts/build-electron.mjs index fcb368766..36892ab64 100644 --- a/surfsense_desktop/scripts/build-electron.mjs +++ b/surfsense_desktop/scripts/build-electron.mjs @@ -2,32 +2,92 @@ import { build } from 'esbuild'; import fs from 'fs'; import path from 'path'; +const STANDALONE_ROOT = path.join( + '..', 'surfsense_web', '.next', 'standalone', 'surfsense_web' +); + /** * electron-builder cannot follow symlinks when packaging into ASAR. - * Next.js standalone output contains symlinks in node_modules that - * must be replaced with real copies before packaging. - * Pattern from CodePilot (github.com/op7418/CodePilot). + * Recursively walk the standalone output and replace every symlink + * with a real copy (or remove it if the target doesn't exist). */ -function resolveStandaloneSymlinks() { - const standaloneModules = path.join( - '..', 'surfsense_web', '.next', 'standalone', 'surfsense_web', 'node_modules' - ); - if (!fs.existsSync(standaloneModules)) return; +function resolveAllSymlinks(dir) { + if (!fs.existsSync(dir)) return; - const entries = fs.readdirSync(standaloneModules); - for (const entry of entries) { - const fullPath = path.join(standaloneModules, entry); - const stat = fs.lstatSync(fullPath); - if (stat.isSymbolicLink()) { - const target = fs.readlinkSync(fullPath); - const resolved = path.resolve(standaloneModules, target); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isSymbolicLink()) { + const target = fs.readlinkSync(full); + const resolved = path.resolve(dir, target); if (fs.existsSync(resolved)) { - fs.rmSync(fullPath, { recursive: true, force: true }); - fs.cpSync(resolved, fullPath, { recursive: true }); - console.log(`Resolved symlink: ${entry} -> ${target}`); + fs.rmSync(full, { recursive: true, force: true }); + fs.cpSync(resolved, full, { recursive: true }); + console.log(`Resolved symlink: ${full}`); + } else { + fs.rmSync(full, { force: true }); + console.log(`Removed broken symlink: ${full}`); + } + } else if (entry.isDirectory()) { + resolveAllSymlinks(full); + } + } +} + +/** + * pnpm's .pnpm/ virtual store uses symlinks for sibling dependency resolution. + * After resolveAllSymlinks converts everything to real copies, packages can no + * longer find their dependencies through the pnpm structure. We flatten the + * tree into a standard npm-like layout: every package from .pnpm/*/node_modules/ + * gets hoisted to the top-level node_modules/. This lets Node.js standard + * module resolution find all dependencies (e.g. next → styled-jsx). + */ +function flattenPnpmStore(nodeModulesDir) { + const pnpmDir = path.join(nodeModulesDir, '.pnpm'); + if (!fs.existsSync(pnpmDir)) return; + + console.log('Flattening pnpm store to top-level node_modules...'); + let hoisted = 0; + + for (const storePkg of fs.readdirSync(pnpmDir, { withFileTypes: true })) { + if (!storePkg.isDirectory() || storePkg.name === 'node_modules') continue; + + const innerNM = path.join(pnpmDir, storePkg.name, 'node_modules'); + if (!fs.existsSync(innerNM)) continue; + + for (const dep of fs.readdirSync(innerNM, { withFileTypes: true })) { + const depName = dep.name; + // Handle scoped packages (@org/pkg) + if (depName.startsWith('@') && dep.isDirectory()) { + const scopeDir = path.join(innerNM, depName); + for (const scopedPkg of fs.readdirSync(scopeDir, { withFileTypes: true })) { + const fullName = `${depName}/${scopedPkg.name}`; + const src = path.join(scopeDir, scopedPkg.name); + const dest = path.join(nodeModulesDir, depName, scopedPkg.name); + if (!fs.existsSync(dest)) { + fs.mkdirSync(path.join(nodeModulesDir, depName), { recursive: true }); + fs.cpSync(src, dest, { recursive: true }); + hoisted++; + } + } + } else if (dep.isDirectory() || dep.isFile()) { + const dest = path.join(nodeModulesDir, depName); + if (!fs.existsSync(dest)) { + fs.cpSync(path.join(innerNM, depName), dest, { recursive: true }); + hoisted++; + } } } } + + // Remove the .pnpm directory — no longer needed + fs.rmSync(pnpmDir, { recursive: true, force: true }); + console.log(`Hoisted ${hoisted} packages, removed .pnpm/`); +} + +function resolveStandaloneSymlinks() { + console.log('Resolving symlinks in standalone output...'); + resolveAllSymlinks(STANDALONE_ROOT); + flattenPnpmStore(path.join(STANDALONE_ROOT, 'node_modules')); } async function buildElectron() { diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 74fd7ba4e..a17905c34 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,14 +1,12 @@ import { app, BrowserWindow, shell, ipcMain, session } from 'electron'; import path from 'path'; -import { spawn, ChildProcess } from 'child_process'; import { resolveEnv } from './resolve-env'; const isDev = !app.isPackaged; let mainWindow: BrowserWindow | null = null; -let serverProcess: ChildProcess | null = null; let deepLinkUrl: string | null = null; +let serverPort: number = 3000; -const SERVER_PORT = 3000; const PROTOCOL = 'surfsense'; // TODO: Hardcoded URL is fragile — production domain may change and // self-hosted users have their own. Two options: @@ -19,63 +17,48 @@ const HOSTED_FRONTEND_URL = 'https://surfsense.net'; function getStandalonePath(): string { if (isDev) { - return path.join(__dirname, '..', '..', 'surfsense_web', '.next', 'standalone'); + return path.join(__dirname, '..', '..', 'surfsense_web', '.next', 'standalone', 'surfsense_web'); } return path.join(process.resourcesPath, 'standalone'); } -function startNextServer(): Promise { - return new Promise((resolve, reject) => { - // In dev mode, Next.js dev server is already running externally - if (isDev) { - resolve(); - return; +async function waitForServer(url: string, maxRetries = 60): Promise { + 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 } - - const standalonePath = getStandalonePath(); - resolveEnv(standalonePath); - const serverScript = path.join(standalonePath, 'server.js'); - - serverProcess = spawn(process.execPath, [serverScript], { - cwd: standalonePath, - env: { - ...process.env, - PORT: String(SERVER_PORT), - HOSTNAME: 'localhost', - NODE_ENV: 'production', - }, - stdio: 'pipe', - }); - - serverProcess.stdout?.on('data', (data: Buffer) => { - const output = data.toString(); - console.log(`[next] ${output}`); - if (output.includes('Ready') || output.includes('started server')) { - resolve(); - } - }); - - serverProcess.stderr?.on('data', (data: Buffer) => { - console.error(`[next] ${data.toString()}`); - }); - - serverProcess.on('error', reject); - serverProcess.on('exit', (code) => { - if (code !== 0 && code !== null) { - reject(new Error(`Next.js server exited with code ${code}`)); - } - }); - - // Fallback: resolve after 5s even if we don't see the "Ready" message - setTimeout(() => resolve(), 5000); - }); + await new Promise((r) => setTimeout(r, 500)); + } + return false; } -function killServer() { - if (serverProcess && !serverProcess.killed) { - serverProcess.kill(); - serverProcess = null; +async function startNextServer(): Promise { + if (isDev) return; + + const standalonePath = getStandalonePath(); + resolveEnv(standalonePath); + + 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() { @@ -99,7 +82,7 @@ function createWindow() { mainWindow?.show(); }); - mainWindow.loadURL(`http://localhost:${SERVER_PORT}/login`); + mainWindow.loadURL(`http://localhost:${serverPort}/login`); // External links open in system browser, not in the Electron window mainWindow.webContents.setWindowOpenHandler(({ url }) => { @@ -114,7 +97,7 @@ function createWindow() { // 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:${SERVER_PORT}`); + const rewritten = details.url.replace(HOSTED_FRONTEND_URL, `http://localhost:${serverPort}`); callback({ redirectURL: rewritten }); }); @@ -148,7 +131,7 @@ function handleDeepLink(url: string) { const parsed = new URL(url); if (parsed.hostname === 'auth' && parsed.pathname === '/callback') { const params = parsed.searchParams.toString(); - mainWindow.loadURL(`http://localhost:${SERVER_PORT}/auth/callback?${params}`); + mainWindow.loadURL(`http://localhost:${serverPort}/auth/callback?${params}`); } mainWindow.show(); @@ -212,5 +195,5 @@ app.on('window-all-closed', () => { }); app.on('will-quit', () => { - killServer(); + // Server runs in-process — no child process to kill });