mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-14 20:55:15 +02:00
fix(desktop): resolve pnpm packaging and in-process server
This commit is contained in:
parent
9bc3a25669
commit
14b561bc39
3 changed files with 127 additions and 78 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<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
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue