diff --git a/docs/desktop-app-plan/TODO.md b/docs/desktop-app-plan/TODO.md index d69ae1d27..be7605273 100644 --- a/docs/desktop-app-plan/TODO.md +++ b/docs/desktop-app-plan/TODO.md @@ -34,6 +34,7 @@ surfsense_desktop/ - `next.config.ts` — keeps `output: "standalone"` - All 13 connector OAuth flows — happen in system browser, Electric SQL syncs results + --- ## Phase 1: Electron Shell Setup diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index a2b2f2843..aaf59292e 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -6,8 +6,10 @@ import { resolveEnv } from './resolve-env'; const isDev = !app.isPackaged; let mainWindow: BrowserWindow | null = null; let serverProcess: ChildProcess | null = null; +let deepLinkUrl: string | null = null; const SERVER_PORT = 3000; +const PROTOCOL = 'surfsense'; function getStandalonePath(): string { if (isDev) { @@ -120,11 +122,68 @@ 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:${SERVER_PORT}/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(); diff --git a/surfsense_web/next.config.ts b/surfsense_web/next.config.ts index 075383682..fe01f8998 100644 --- a/surfsense_web/next.config.ts +++ b/surfsense_web/next.config.ts @@ -5,6 +5,9 @@ import createNextIntlPlugin from "next-intl/plugin"; // Create the next-intl plugin const withNextIntl = createNextIntlPlugin("./i18n/request.ts"); +// TODO: Separate app routes (/login, /dashboard) from marketing routes +// (landing page, /contact, /pricing, /docs) so the desktop build only +// ships what desktop users actually need. const nextConfig: NextConfig = { output: "standalone", reactStrictMode: false,