18 KiB
SurfSense Desktop App — Complete Implementation Plan
Decision: Electron + Next.js Standalone
Why Electron
- Electron bundles Node.js + Chromium → runs the full Next.js standalone server as-is
- Zero frontend code changes required (no
output: "export", no routing rewrite) - SurfSense uses dynamic routes, API routes, server components,
next-intl, middleware — all incompatible with static export - Tauri and Nextron both require
output: "export"which breaks all of the above - Evidence:
next-electron-rsclibrary (131 stars), Feb 2026 Medium article with production template - Reference project: CodePilot (4,155 stars) — Electron + Next.js desktop app using electron-builder + esbuild
Architecture
surfsense_desktop/
├── src/
│ ├── main.ts ← Electron main process (Node.js)
│ │ ├── Spawns Next.js standalone server as child process
│ │ ├── Registers deep link protocol (surfsense://)
│ │ ├── Creates BrowserWindow pointing to Next.js
│ │ ├── System tray, global shortcuts, native menus
│ │ └── IPC handlers (safeStorage, clipboard, notifications)
│ └── preload.ts ← contextBridge for secure renderer↔main IPC
├── scripts/
│ └── build-electron.mjs ← esbuild script to compile TS
├── assets/ ← App icons (icns, ico, png, tray)
├── electron-builder.yml ← Packaging config for macOS/Windows/Linux
└── package.json
What Stays Unchanged
surfsense_web/— entire frontend codebase (zero changes)surfsense_backend/— almost unchanged (1 CORS line for hosted users)next.config.ts— keepsoutput: "standalone"- All 13 connector OAuth flows — happen in system browser, Electric SQL syncs results
Phase 1: Electron Shell Setup
1.1 — Project structure and dependencies
- Create
surfsense_desktop/directory at repo root - Initialize with
pnpm init - Install dependencies:
electron(dev) — desktop shellelectron-builder(dev) — packaging/distributionesbuild(dev) — compile Electron TypeScript filesconcurrently(dev) — run Next.js dev + Electron togetherwait-on(dev) — wait for Next.js dev server before launching Electronelectron-updater(prod) — auto-updatetypescript(dev),@types/node(dev) — TypeScript support
- Create
tsconfig.jsonfor Electron TypeScript files - Create folder structure (
src/,scripts/,assets/) - Add npm scripts:
dev—concurrently -k "next dev" "wait-on http://localhost:3000 && electron ."electron:build—next build && node scripts/build-electron.mjselectron:pack—electron:build && electron-builder --config electron-builder.yml
1.2 — Main process (main.ts)
- Create
BrowserWindowwith secure defaults:contextIsolation: truenodeIntegration: falsesandbox: truewebviewTag: false
- Load content based on mode:
- Dev mode:
win.loadURL('http://localhost:3000') - Production: Spawn
node server.jsfrom.next/standalone/, wait for ready, loadhttp://localhost:{port}
- Dev mode:
- Handle
window-all-closed(quit on Windows/Linux, stay alive on macOS) - Handle
activate(re-create window on macOS dock click) - Set app user model ID on Windows:
app.setAppUserModelId('com.surfsense.desktop') - Handle
will-quitto clean up child processes
1.3 — Preload script (preload.ts)
- Use
contextBridge.exposeInMainWorldto expose safe IPC channels:- Auth:
storeToken,getToken,clearToken - Native:
openExternal,getClipboardText,showNotification - App info:
getAppVersion,getPlatform - Deep link:
onDeepLinkcallback
- Auth:
- Do NOT expose
ipcRendererdirectly
1.4 — Handle ASAR packaging and path resolution
- Use
app.getAppPath()instead of__dirnamefor locating bundled assets - Configure
electron-builder.ymlwithasarUnpack: [".next/standalone/**"] - Verify
public/and.next/static/are accessible after packaging - Test with
electron-builder --dirbefore full packaging
1.5 — Native menu bar (menu.ts)
- Create application menu (required for Cmd+C/Cmd+V to work on macOS)
- Standard items: App (About, Preferences, Quit), Edit (Undo, Redo, Cut, Copy, Paste, Select All), View (Reload, DevTools, Zoom), Window, Help
- Keyboard accelerators:
CmdOrCtrl+,→ Settings,CmdOrCtrl+N→ New chat,CmdOrCtrl+Q→ Quit
Phase 2: Environment Variables & Runtime Configuration
2.1 — Handle NEXT_PUBLIC_* variables at build time
| Variable | Electron Build Value | Notes |
|---|---|---|
NEXT_PUBLIC_FASTAPI_BACKEND_URL |
See 2.2 | Must point to actual backend |
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE |
GOOGLE or LOCAL |
Match target deployment |
NEXT_PUBLIC_ETL_SERVICE |
DOCLING |
Current default |
NEXT_PUBLIC_ELECTRIC_URL |
See 2.2 | Must point to Electric service |
NEXT_PUBLIC_ELECTRIC_AUTH_MODE |
insecure or secure |
Match target deployment |
NEXT_PUBLIC_DEPLOYMENT_MODE |
self-hosted or cloud |
Controls feature visibility |
NEXT_PUBLIC_POSTHOG_KEY |
Empty string OR real key | See 2.4 |
NEXT_PUBLIC_POSTHOG_HOST |
Empty string OR real host | See 2.4 |
NEXT_PUBLIC_APP_VERSION |
From package.json |
Can auto-detect |
- Create
.env.electronfile with Electron-specific values - Add build script that copies
.env.electronbeforenext build
2.2 — Runtime backend URL configuration (self-hosted users)
- Adapt
surfsense_web/docker-entrypoint.jsplaceholder replacement for Electron:- Build Next.js with placeholder values (e.g.
__NEXT_PUBLIC_FASTAPI_BACKEND_URL__) - On Electron startup, before spawning the Next.js server, run the same string replacement on
.next/standalone/files - Read target values from
electron-store(self-hosted) or use hosted defaults (cloud)
- Build Next.js with placeholder values (e.g.
- Add "Self-Hosted?" link at the bottom of the login page:
- Clicking reveals Backend URL and Electric URL input fields
- User fills in their URLs, clicks Save → stored in
electron-store - Electron re-runs placeholder replacement with new values, restarts the Next.js server
- This link is only visible in the Electron app (detect via
window.electronAPI) - Hosted users never see or interact with this
INTERNAL_FASTAPI_BACKEND_URL(used inverify-token/route.ts, defaults tohttp://backend:8000): Set viaprocess.envbefore spawning the Next.js server. For hosted builds, use the production backend URL. For self-hosted, use the same URL the user configured.
2.3 — Handle the contact form API route
surfsense_web/app/api/contact/route.tsuses Drizzle ORM withDATABASE_URLto insert directly into PostgreSQL- Desktop app does NOT have a direct PostgreSQL connection
- The contact form is a landing page feature. Desktop app starts at
/login→ never hits this route. Verify this is the case. - If reachable: make it return 503 when
DATABASE_URLis unset
2.4 — PostHog analytics in desktop
- PostHog is initialized only when
NEXT_PUBLIC_POSTHOG_KEYis set - Decision: Build with
NEXT_PUBLIC_POSTHOG_KEY=""to disable in v1. Re-enable in v2 withplatform: 'desktop'property.
Phase 3: Authentication
3.1 — Email/password login (should work as-is)
- Verify login page renders in Electron
- Verify POST to
{BACKEND_URL}/auth/jwt/loginsucceeds - Verify token stored in localStorage (
surfsense_bearer_token,surfsense_refresh_token) - Verify
authenticatedFetchincludes Bearer token - Verify token refresh on 401
- Verify logout clears tokens and redirects to
/login
3.2 — Google OAuth login via deep link
- Register
surfsense://protocol viaapp.setAsDefaultProtocolClient("surfsense") - Intercept "Login with Google" → open in system browser via
shell.openExternal() - Append
?source=desktop&redirect_uri=surfsense://auth/callbackto authorize URL - Backend change (
users.py, ~5 lines): Ifredirect_uristarts withsurfsense://, redirect there instead of{NEXT_FRONTEND_URL}/auth/callback - Handle deep link in Electron:
- macOS:
app.on('open-url') - Windows/Linux:
app.on('second-instance') - Parse URL, extract tokens, inject into renderer, navigate to
/dashboard
- macOS:
- Platform notes:
- macOS: requires packaged
.app(notelectron .dev mode) - Windows: works in dev mode
- Linux
.deb: registers.desktopfile withMimeType=x-scheme-handler/surfsense; - Linux AppImage: known issues on some DEs
- macOS: requires packaged
3.3 — Secure token storage
- v1: Use localStorage (matches web behavior)
- v2: Upgrade to
electron.safeStoragefor encrypted storage
3.4 — Handle ?source=desktop on the web
TokenHandler.tsxchecks forsource=desktopparam- If present: redirect to
surfsense://auth/callback?token=...&refresh_token=... - If absent: normal web behavior
Phase 4: CORS and Backend Connectivity
4.1 — CORS for hosted/SaaS users
- Add localhost origins unconditionally in
surfsense_backend/app/app.py(1-line change):allowed_origins.extend(["http://localhost:3000", "http://127.0.0.1:3000"]) - Self-hosted: already works (localhost in
NEXT_FRONTEND_URL)
4.2 — INTERNAL_FASTAPI_BACKEND_URL for verify-token/route.ts
- Set
INTERNAL_FASTAPI_BACKEND_URLviaprocess.envbefore spawning Next.js server - Hosted: hardcode production backend URL
- Self-hosted: use user-configured URL
4.3 — Verify Fumadocs search route
app/api/search/route.tsuses static MDX content — should work as-is- Verify search works in Electron
Phase 5: Connector OAuth Flows (13 connectors)
5.1 — Desktop connector OAuth (via system browser)
- Intercept connector OAuth URLs → open in system browser via
shell.openExternal() - Detection via
webContents.setWindowOpenHandlerandwebContents.on('will-navigate') - Electric SQL syncs new connectors to Electron automatically
- Zero backend changes needed
5.2 — Complete connector list
| # | Connector | Auth Endpoint |
|---|---|---|
| 1 | Airtable | /api/v1/auth/airtable/connector/add/ |
| 2 | Notion | /api/v1/auth/notion/connector/add/ |
| 3 | Google Calendar | /api/v1/auth/google/calendar/connector/add/ |
| 4 | Google Drive | /api/v1/auth/google/drive/connector/add/ |
| 5 | Gmail | /api/v1/auth/google/gmail/connector/add/ |
| 6 | Slack | /api/v1/auth/slack/connector/add/ |
| 7 | Microsoft Teams | /api/v1/auth/teams/connector/add/ |
| 8 | Discord | /api/v1/auth/discord/connector/add/ |
| 9 | Jira | /api/v1/auth/jira/connector/add/ |
| 10 | Confluence | /api/v1/auth/confluence/connector/add/ |
| 11 | Linear | /api/v1/auth/linear/connector/add/ |
| 12 | ClickUp | /api/v1/auth/clickup/connector/add/ |
| 13 | Composio | /api/v1/auth/composio/connector/add/?toolkit_id=... |
5.3 — Non-OAuth connectors
- Luma (API key) — works as-is
- File upload — works as-is
- YouTube crawler — works as-is
Phase 6: Security Hardening
6.1 — BrowserWindow security
contextIsolation: true(MANDATORY)nodeIntegration: false(MANDATORY)sandbox: truewebviewTag: false- Do NOT use
webSecurity: falseorallowRunningInsecureContent: true
6.2 — Content Security Policy
- Set CSP via
session.defaultSession.webRequest.onHeadersReceived - Block navigation to untrusted origins
6.3 — External link handling
- All external links open in system browser, not Electron window
- Implement in
webContents.setWindowOpenHandlerandwill-navigate
6.4 — Process management
- Kill Next.js server on app quit (
will-quit) - Kill on crash (
render-process-gone) - Handle port conflicts
Phase 7: Native Desktop Features
7.1 — System tray
- Tray icon with context menu (Open, New Chat, Quit)
- Minimize to tray on window close
7.2 — Global keyboard shortcut
CmdOrCtrl+Shift+Sto show/focus app- Unregister on
will-quit
7.3 — Native notifications
- Sync completed, new mentions, report generation
- Wire to Electric SQL shape updates
7.4 — Clipboard integration
clipboard.readText()/clipboard.writeText()via IPC
7.5 — File drag-and-drop
- Verify
react-dropzoneworks in Electron (should work as-is)
7.6 — Window state persistence
- Save/restore window position, size, maximized state
Phase 8: Internationalization
- Verify
next-intlworks in Electron (5 locales: en, es, pt, hi, zh) - Verify locale switching and persistence
- Verify URL-based locale prefix works
Phase 9: Packaging and Distribution
9.1 — electron-builder.yml configuration
- Configure for macOS (.dmg), Windows (.exe NSIS), Linux (.deb + AppImage)
- Include
.next/standalone,.next/static,publicas extra resources asarUnpackfor standalone server- Exclude
node_modules/typescript,.next/cache, source maps
9.2 — macOS build
- Code signing (Apple Developer certificate, $99/year)
- Notarization (required since macOS 10.15)
- Universal binary (Intel + Apple Silicon)
9.3 — Windows build
- NSIS installer
- Optional code signing (EV cert for SmartScreen)
9.4 — Linux build
.deb(primary), AppImage (secondary)- Deep link registration via
.desktopfile
9.5 — Auto-updater
electron-updaterwith GitHub Releases- Background download, install on restart
9.6 — App icons
.icns(macOS),.ico(Windows),.png(Linux), tray icons
Phase 10: CI/CD Pipeline
10.1 — GitHub Actions workflow
.github/workflows/desktop-build.yml- Matrix: macOS (.dmg), Windows (.exe), Ubuntu (.deb + AppImage)
- Steps: checkout → setup Node → build Next.js → build Electron → package → upload to GitHub Releases
10.2 — Release process
- Tag-based:
git tag v0.1.0 && git push --tags electron-updaterchecks GitHub Releases for update manifests
Phase 11: Development Workflow
- Dev: Terminal 1
next dev, Terminal 2electron .(or singlepnpm devwith concurrently) - DevTools enabled in dev mode
pnpm run packfor quick local testing without installer
Phase 12: Testing
12.1 — Functional (all platforms)
- Login, dashboard, chat, document upload/editor, search, settings, team management, i18n, dark mode
12.2 — Electric SQL real-time
- Notifications, connectors, documents, messages, comments sync in real-time
- Data persists across restart (PGlite IndexedDB)
12.3 — Auth flows
- Email/password, Google OAuth via deep link (macOS/Windows/Linux), token refresh, logout
12.4 — Connector OAuth (at least 3)
- Slack, Notion, Google Drive via system browser → Electric SQL sync
12.5 — Native features
- System tray, global shortcut, notifications, menu bar (Cut/Copy/Paste)
12.6 — Platform-specific
- macOS (latest, Apple Silicon), Windows 10/11, Ubuntu 22.04/24.04 (.deb), Fedora (AppImage)
12.7 — Edge cases
- Single instance lock, network disconnection, backend unreachable, high DPI, multi-monitor
Phase 13: Documentation
- User-facing: download links, self-hosted config guide, known limitations
- Developer-facing: README in
surfsense_desktop/, architecture diagram, dev workflow, debug guide
Summary of Backend Changes Required
| Change | File | Scope |
|---|---|---|
| CORS: always allow localhost | app/app.py |
1 line |
Google OAuth: accept surfsense:// redirect |
app/users.py |
~5 lines |
Pass redirect_uri through OAuth flow |
app/users.py + app/app.py |
~10 lines |
Total: ~16 lines of backend changes. Zero connector changes. Zero frontend changes (except optional electronAPI detection).
Decisions (Resolved)
1. next-electron-rsc vs child process?
Decision: Child process (spawn node server.js).
Standard pattern, no third-party dependency risk. Used by CodePilot (4,155 stars).
2. Runtime config for self-hosted users?
Decision: Single build. Adapt docker-entrypoint.js placeholder pattern for Electron.
- Hosted users: placeholders replaced with hosted defaults automatically. App goes straight to
/login. No config UI. - Self-hosted users: "Self-Hosted?" link at bottom of login page reveals Backend URL and Electric URL fields. Stored in
electron-store, persists across restarts. - Single build = 3 installers (not 6), one CI pipeline.
3. Which Electron version to target?
Decision: Latest stable at time of implementation. Verify Node.js >= 18.18 (Next.js 16 requirement) and Chromium WASM support for PGlite.
4. Secure token storage in v1?
Decision: localStorage in v1.
Matches web behavior. Upgrade to safeStorage in v2.
5. Landing page in desktop app?
Decision: Skip it. Start at /login (or /dashboard if authenticated).
Landing page contact form needs DATABASE_URL — irrelevant for desktop users.
6. Separate builds for hosted vs self-hosted?
Decision: Single build.
Uses docker-entrypoint.js placeholder pattern for runtime URL configuration. Hosted defaults applied automatically. Self-hosted users configure via "Self-Hosted?" link on the login page. One build, 3 installers (macOS/Windows/Linux), one CI pipeline.
7. Packaging tool?
Decision: electron-builder (not Electron Forge). Electron Forge is official but designed to manage the frontend build (Vite/webpack). We use Next.js which manages its own build. electron-builder is "bring your own build" friendly. Used by CodePilot (4,155 stars) and DarkGuy10 boilerplate (87 stars).
8. TypeScript compilation?
Decision: esbuild (via custom build script).
Used by CodePilot. Faster than tsc, simpler than tsup/tsdown. Only compiles 2-3 Electron files.