diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 410b924b9..6731ecbfa 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -43,6 +43,9 @@ export const IPC_CHANNELS = { // Active search space GET_ACTIVE_SEARCH_SPACE: 'search-space:get-active', SET_ACTIVE_SEARCH_SPACE: 'search-space:set-active', + // Launch on system startup + GET_AUTO_LAUNCH: 'auto-launch:get', + SET_AUTO_LAUNCH: 'auto-launch:set', // Analytics (PostHog) bridge: renderer <-> main ANALYTICS_IDENTIFY: 'analytics:identify', ANALYTICS_RESET: 'analytics:reset', diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index 63cce3d01..05c327436 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -24,6 +24,7 @@ import { type WatchedFolderConfig, } from '../modules/folder-watcher'; import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts'; +import { getAutoLaunchState, setAutoLaunch } from '../modules/auto-launch'; import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/active-search-space'; import { reregisterQuickAsk } from '../modules/quick-ask'; import { reregisterAutocomplete } from '../modules/autocomplete'; @@ -127,6 +128,21 @@ export function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.GET_SHORTCUTS, () => getShortcuts()); + ipcMain.handle(IPC_CHANNELS.GET_AUTO_LAUNCH, () => getAutoLaunchState()); + + ipcMain.handle( + IPC_CHANNELS.SET_AUTO_LAUNCH, + async (_event, payload: { enabled: boolean; openAsHidden?: boolean }) => { + const next = await setAutoLaunch(payload.enabled, payload.openAsHidden); + trackEvent('desktop_auto_launch_toggled', { + enabled: next.enabled, + open_as_hidden: next.openAsHidden, + supported: next.supported, + }); + return next; + }, + ); + ipcMain.handle(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE, () => getActiveSearchSpaceId()); ipcMain.handle(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, (_event, id: string) => diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 7556a2743..399144bed 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,10 +1,9 @@ -import { app, BrowserWindow } from 'electron'; +import { app } from 'electron'; -let isQuitting = false; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; import { startNextServer } from './modules/server'; -import { createMainWindow, getMainWindow } from './modules/window'; -import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; +import { createMainWindow, getMainWindow, markQuitting } from './modules/window'; +import { setupDeepLinks, handlePendingDeepLink, hasPendingDeepLink } from './modules/deep-links'; import { setupAutoUpdater } from './modules/auto-updater'; import { setupMenu } from './modules/menu'; import { registerQuickAsk, unregisterQuickAsk } from './modules/quick-ask'; @@ -13,6 +12,12 @@ import { registerFolderWatcher, unregisterFolderWatcher } from './modules/folder import { registerIpcHandlers } from './ipc/handlers'; import { createTray, destroyTray } from './modules/tray'; import { initAnalytics, shutdownAnalytics, trackEvent } from './modules/analytics'; +import { + applyAutoLaunchDefaults, + shouldStartHidden, + syncAutoLaunchOnStartup, + wasLaunchedAtLogin, +} from './modules/auto-launch'; registerGlobalErrorHandlers(); @@ -24,7 +29,12 @@ registerIpcHandlers(); app.whenReady().then(async () => { initAnalytics(); - trackEvent('desktop_app_launched'); + const launchedAtLogin = wasLaunchedAtLogin(); + const startedHidden = shouldStartHidden(); + trackEvent('desktop_app_launched', { + launched_at_login: launchedAtLogin, + started_hidden: startedHidden, + }); setupMenu(); try { await startNextServer(); @@ -35,16 +45,19 @@ app.whenReady().then(async () => { } await createTray(); + const defaultsApplied = await applyAutoLaunchDefaults(); + if (defaultsApplied) { + trackEvent('desktop_auto_launch_defaulted_on'); + } + await syncAutoLaunchOnStartup(); - const win = createMainWindow('/dashboard'); - - // Minimize to tray instead of closing the app - win.on('close', (e) => { - if (!isQuitting) { - e.preventDefault(); - win.hide(); - } - }); + // When started by the OS at login we stay quietly in the tray. The window + // is created lazily on first user interaction (tray click / activate). + // Exception: if a deep link is queued, the user explicitly asked to land + // in the app — don't swallow it. + if (!startedHidden || hasPendingDeepLink()) { + createMainWindow('/dashboard'); + } await registerQuickAsk(); await registerAutocomplete(); @@ -71,7 +84,7 @@ app.on('window-all-closed', () => { }); app.on('before-quit', () => { - isQuitting = true; + markQuitting(); trackEvent('desktop_app_quit'); }); diff --git a/surfsense_desktop/src/modules/auto-launch.ts b/surfsense_desktop/src/modules/auto-launch.ts new file mode 100644 index 000000000..9759c4ef9 --- /dev/null +++ b/surfsense_desktop/src/modules/auto-launch.ts @@ -0,0 +1,304 @@ +import { app } from 'electron'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// --------------------------------------------------------------------------- +// Launch on system startup ("auto-launch" / "open at login"). +// +// macOS + Windows : uses Electron's built-in `app.setLoginItemSettings()`. +// Linux : writes a freedesktop autostart `.desktop` file into +// `~/.config/autostart/`. Electron's API is a no-op there. +// +// The OS is the source of truth for whether we're enabled (so a user who +// disables us via System Settings / GNOME Tweaks isn't silently overridden). +// We persist a small companion record in electron-store for things the OS +// can't tell us — currently just `openAsHidden`, since on Windows we encode +// it as a CLI arg and on Linux as part of the Exec line, but on a fresh +// startup we still want the renderer toggle to reflect the user's intent. +// --------------------------------------------------------------------------- + +const STORE_KEY = 'launchAtLogin'; +const HIDDEN_FLAG = '--hidden'; +const LINUX_DESKTOP_FILENAME = 'surfsense.desktop'; + +export interface AutoLaunchState { + enabled: boolean; + openAsHidden: boolean; + supported: boolean; +} + +interface PersistedState { + enabled: boolean; + openAsHidden: boolean; + // True once we've run the first-launch defaults (opt-in to auto-launch). + // We never re-apply defaults if this is set, so a user who has explicitly + // turned auto-launch off stays off forever. + defaultsApplied: boolean; +} + +const DEFAULTS: PersistedState = { + enabled: false, + openAsHidden: true, + defaultsApplied: false, +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- lazily imported ESM module; matches shortcuts.ts pattern +let store: any = null; + +async function getStore() { + if (!store) { + const { default: Store } = await import('electron-store'); + store = new Store({ + name: 'auto-launch', + defaults: { [STORE_KEY]: DEFAULTS }, + }); + } + return store; +} + +async function readPersisted(): Promise { + const s = await getStore(); + const stored = s.get(STORE_KEY) as Partial | undefined; + return { ...DEFAULTS, ...(stored ?? {}) }; +} + +async function writePersisted(next: PersistedState): Promise { + const s = await getStore(); + s.set(STORE_KEY, next); +} + +// --------------------------------------------------------------------------- +// Platform support +// --------------------------------------------------------------------------- + +// Auto-launch only makes sense for the packaged app — in dev `process.execPath` +// is the local Electron binary, so registering it would point the OS at a +// throwaway path the next time the dev server isn't running. +function isSupported(): boolean { + if (!app.isPackaged) return false; + return ['darwin', 'win32', 'linux'].includes(process.platform); +} + +// --------------------------------------------------------------------------- +// Linux: ~/.config/autostart/surfsense.desktop +// --------------------------------------------------------------------------- + +function linuxAutostartDir(): string { + const xdg = process.env.XDG_CONFIG_HOME; + const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), '.config'); + return path.join(base, 'autostart'); +} + +function linuxAutostartFile(): string { + return path.join(linuxAutostartDir(), LINUX_DESKTOP_FILENAME); +} + +// AppImages move around with the user — `process.execPath` points at a temp +// mount, so we have to use the original AppImage path exposed via env. +function linuxExecPath(): string { + return process.env.APPIMAGE && process.env.APPIMAGE.length > 0 + ? process.env.APPIMAGE + : process.execPath; +} + +function escapeDesktopExecArg(value: string): string { + // Freedesktop `.desktop` Exec values require quoted args when spaces are + // present. We keep this intentionally minimal and escape only characters + // that can break quoted parsing. + return `"${value.replace(/(["\\`$])/g, '\\$1')}"`; +} + +function writeLinuxDesktopFile(openAsHidden: boolean): void { + const exec = escapeDesktopExecArg(linuxExecPath()); + const args = openAsHidden ? ` ${HIDDEN_FLAG}` : ''; + const contents = [ + '[Desktop Entry]', + 'Type=Application', + 'Version=1.0', + 'Name=SurfSense', + 'Comment=AI-powered research assistant', + `Exec=${exec}${args}`, + 'Terminal=false', + 'Categories=Utility;Office;', + 'X-GNOME-Autostart-enabled=true', + `X-GNOME-Autostart-Delay=${openAsHidden ? '5' : '0'}`, + '', + ].join('\n'); + + fs.mkdirSync(linuxAutostartDir(), { recursive: true }); + fs.writeFileSync(linuxAutostartFile(), contents, { mode: 0o644 }); +} + +function removeLinuxDesktopFile(): void { + try { + fs.unlinkSync(linuxAutostartFile()); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + } +} + +function readLinuxDesktopFile(): boolean { + return fs.existsSync(linuxAutostartFile()); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export async function getAutoLaunchState(): Promise { + const supported = isSupported(); + const persisted = await readPersisted(); + + if (!supported) { + return { enabled: false, openAsHidden: persisted.openAsHidden, supported: false }; + } + + // Trust the OS state — the user may have disabled it from system settings. + return { enabled: readOsEnabled(), openAsHidden: persisted.openAsHidden, supported: true }; +} + +export async function setAutoLaunch( + enabled: boolean, + openAsHidden: boolean = DEFAULTS.openAsHidden, +): Promise { + const supported = isSupported(); + + if (!supported) { + return { enabled: false, openAsHidden, supported: false }; + } + + applySystemRegistration(enabled, openAsHidden); + // Preserve `defaultsApplied` (and any future fields) — and explicitly + // mark them as applied, since the user has now made an intentional choice. + await writePersisted({ enabled, openAsHidden, defaultsApplied: true }); + return { enabled, openAsHidden, supported: true }; +} + +function applySystemRegistration(enabled: boolean, openAsHidden: boolean): void { + if (process.platform === 'linux') { + if (enabled) writeLinuxDesktopFile(openAsHidden); + else removeLinuxDesktopFile(); + return; + } + + if (!enabled) { + app.setLoginItemSettings({ openAtLogin: false }); + return; + } + + if (process.platform === 'win32') { + // On Windows we can't tell the OS to "launch hidden" — instead we pass an + // arg the app introspects on boot to skip showing the main window. + app.setLoginItemSettings({ + openAtLogin: true, + args: openAsHidden ? [HIDDEN_FLAG] : [], + }); + return; + } + + // darwin + app.setLoginItemSettings({ + openAtLogin: true, + openAsHidden, + }); +} + +// First-launch opt-in: register SurfSense as a hidden login item so the tray, +// global shortcuts, and folder watchers are ready right after the user signs +// in. Runs at most once per installation — the `defaultsApplied` flag is +// flipped before we ever touch the OS so a failure to register doesn't cause +// us to retry on every boot, and a user who turns the toggle off afterwards +// is never silently re-enabled. +// +// Returns whether the defaults were actually applied this boot, so callers +// can fire an analytics event without coupling this module to PostHog. +export async function applyAutoLaunchDefaults(): Promise { + if (!isSupported()) return false; + const persisted = await readPersisted(); + if (persisted.defaultsApplied) return false; + + // Mark the defaults as applied *first*. If `applySystemRegistration` + // throws (e.g. read-only home dir on Linux), we'd rather silently leave + // the user un-registered than spam them with a failed registration on + // every single boot. + const next: PersistedState = { + enabled: true, + openAsHidden: true, + defaultsApplied: true, + }; + + try { + applySystemRegistration(true, true); + } catch (err) { + console.error('[auto-launch] First-run registration failed:', err); + next.enabled = false; + } + + await writePersisted(next); + return next.enabled; +} + +// Called once at startup. Goal: +// * If the OS-level entry is already enabled, re-assert it so a moved +// binary (Windows reinstall to a new dir, Linux AppImage moved by user) +// gets its registered path refreshed. +// * If the OS-level entry has been disabled — typically because the user +// turned it off in System Settings / GNOME Tweaks — *respect that* and +// reconcile our persisted state to match. We never silently re-enable +// a login item the user explicitly turned off. +export async function syncAutoLaunchOnStartup(): Promise { + if (!isSupported()) return; + + const persisted = await readPersisted(); + const osEnabled = readOsEnabled(); + + if (!osEnabled) { + // User (or some other tool) turned us off out-of-band. Don't re-enable; + // just bring our persisted state in sync so the settings UI reflects + // reality on the next render. + if (persisted.enabled) { + await writePersisted({ ...persisted, enabled: false }); + } + return; + } + + // OS says we're enabled — refresh the registration so the recorded path / + // args match this binary. Idempotent on macOS; corrects path drift on + // Windows and Linux. If our persisted state was somehow stale we also + // bring it back in line. + try { + applySystemRegistration(true, persisted.openAsHidden); + if (!persisted.enabled) { + await writePersisted({ ...persisted, enabled: true }); + } + } catch (err) { + console.error('[auto-launch] Failed to re-assert login item:', err); + } +} + +function readOsEnabled(): boolean { + if (process.platform === 'linux') return readLinuxDesktopFile(); + return app.getLoginItemSettings().openAtLogin; +} + +// True when the OS launched us as part of login (used for analytics). +export function wasLaunchedAtLogin(): boolean { + if (process.argv.includes(HIDDEN_FLAG)) return true; + if (process.platform === 'darwin') { + const settings = app.getLoginItemSettings(); + return settings.wasOpenedAtLogin || settings.wasOpenedAsHidden; + } + return false; +} + +// Used for boot UI behavior. On macOS we only start hidden when the OS +// explicitly launched the app as hidden, not merely "at login". +export function shouldStartHidden(): boolean { + if (process.argv.includes(HIDDEN_FLAG)) return true; + if (process.platform === 'darwin') { + const settings = app.getLoginItemSettings(); + return settings.wasOpenedAsHidden; + } + return false; +} diff --git a/surfsense_desktop/src/modules/deep-links.ts b/surfsense_desktop/src/modules/deep-links.ts index bfd35bbaf..11b7bfcff 100644 --- a/surfsense_desktop/src/modules/deep-links.ts +++ b/surfsense_desktop/src/modules/deep-links.ts @@ -69,3 +69,10 @@ export function handlePendingDeepLink(): void { deepLinkUrl = null; } } + +// True when a deep link arrived before the main window existed. Callers can +// use this to force-create a window even on a "started hidden" boot, so we +// don't silently swallow a `surfsense://` URL the user actually clicked on. +export function hasPendingDeepLink(): boolean { + return deepLinkUrl !== null; +} diff --git a/surfsense_desktop/src/modules/window.ts b/surfsense_desktop/src/modules/window.ts index 9cd216501..c925bf947 100644 --- a/surfsense_desktop/src/modules/window.ts +++ b/surfsense_desktop/src/modules/window.ts @@ -8,11 +8,18 @@ const isDev = !app.isPackaged; const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string; let mainWindow: BrowserWindow | null = null; +let isQuitting = false; export function getMainWindow(): BrowserWindow | null { return mainWindow; } +// Called from main.ts on `before-quit` so the close-to-tray handler knows +// to actually let the window die instead of hiding it. +export function markQuitting(): void { + isQuitting = true; +} + export function createMainWindow(initialPath = '/dashboard'): BrowserWindow { mainWindow = new BrowserWindow({ width: 1280, @@ -70,6 +77,16 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow { mainWindow.webContents.openDevTools(); } + // Hide-to-tray on close (don't actually destroy the window unless the + // user really is quitting). Applies to every instance — including the one + // created lazily after a launch-at-login boot. + mainWindow.on('close', (e) => { + if (!isQuitting && mainWindow) { + e.preventDefault(); + mainWindow.hide(); + } + }); + mainWindow.on('closed', () => { mainWindow = null; }); diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index 01248e7d0..3a69f3239 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -82,6 +82,11 @@ contextBridge.exposeInMainWorld('electronAPI', { setShortcuts: (config: Record) => ipcRenderer.invoke(IPC_CHANNELS.SET_SHORTCUTS, config), + // Launch on system startup + getAutoLaunch: () => ipcRenderer.invoke(IPC_CHANNELS.GET_AUTO_LAUNCH), + setAutoLaunch: (enabled: boolean, openAsHidden?: boolean) => + ipcRenderer.invoke(IPC_CHANNELS.SET_AUTO_LAUNCH, { enabled, openAsHidden }), + // Active search space getActiveSearchSpace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE), setActiveSearchSpace: (id: string) => diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx index c3f457f96..3175268d2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx @@ -1,6 +1,6 @@ "use client"; -import { BrainCog, Rocket, Zap } from "lucide-react"; +import { BrainCog, Power, Rocket, Zap } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder"; @@ -30,6 +30,10 @@ export function DesktopContent() { const [searchSpaces, setSearchSpaces] = useState([]); const [activeSpaceId, setActiveSpaceId] = useState(null); + const [autoLaunchEnabled, setAutoLaunchEnabled] = useState(false); + const [autoLaunchHidden, setAutoLaunchHidden] = useState(true); + const [autoLaunchSupported, setAutoLaunchSupported] = useState(false); + useEffect(() => { if (!api) { setLoading(false); @@ -38,19 +42,28 @@ export function DesktopContent() { } let mounted = true; + const hasAutoLaunchApi = + typeof api.getAutoLaunch === "function" && typeof api.setAutoLaunch === "function"; + setAutoLaunchSupported(hasAutoLaunchApi); Promise.all([ api.getAutocompleteEnabled(), api.getShortcuts?.() ?? Promise.resolve(null), api.getActiveSearchSpace?.() ?? Promise.resolve(null), searchSpacesApiService.getSearchSpaces(), + hasAutoLaunchApi ? api.getAutoLaunch() : Promise.resolve(null), ]) - .then(([autoEnabled, config, spaceId, spaces]) => { + .then(([autoEnabled, config, spaceId, spaces, autoLaunch]) => { if (!mounted) return; setEnabled(autoEnabled); if (config) setShortcuts(config); setActiveSpaceId(spaceId); if (spaces) setSearchSpaces(spaces); + if (autoLaunch) { + setAutoLaunchEnabled(autoLaunch.enabled); + setAutoLaunchHidden(autoLaunch.openAsHidden); + setAutoLaunchSupported(autoLaunch.supported); + } setLoading(false); setShortcutsLoaded(true); }) @@ -106,6 +119,40 @@ export function DesktopContent() { updateShortcut(key, DEFAULT_SHORTCUTS[key]); }; + const handleAutoLaunchToggle = async (checked: boolean) => { + if (!autoLaunchSupported || !api.setAutoLaunch) { + toast.error("Please update the desktop app to configure launch on startup"); + return; + } + setAutoLaunchEnabled(checked); + try { + const next = await api.setAutoLaunch(checked, autoLaunchHidden); + if (next) { + setAutoLaunchEnabled(next.enabled); + setAutoLaunchHidden(next.openAsHidden); + setAutoLaunchSupported(next.supported); + } + toast.success(checked ? "SurfSense will launch on startup" : "Launch on startup disabled"); + } catch { + setAutoLaunchEnabled(!checked); + toast.error("Failed to update launch on startup"); + } + }; + + const handleAutoLaunchHiddenToggle = async (checked: boolean) => { + if (!autoLaunchSupported || !api.setAutoLaunch) { + toast.error("Please update the desktop app to configure startup behavior"); + return; + } + setAutoLaunchHidden(checked); + try { + await api.setAutoLaunch(autoLaunchEnabled, checked); + } catch { + setAutoLaunchHidden(!checked); + toast.error("Failed to update startup behavior"); + } + }; + const handleSearchSpaceChange = (value: string) => { setActiveSpaceId(value); api.setActiveSearchSpace?.(value); @@ -145,6 +192,60 @@ export function DesktopContent() { + {/* Launch on Startup */} + + + + + Launch on Startup + + + Automatically start SurfSense when you sign in to your computer so global + shortcuts and folder sync are always available. + + + +
+
+ +

+ {autoLaunchSupported + ? "Adds SurfSense to your system's login items." + : "Only available in the packaged desktop app."} +

+
+ +
+
+
+ +

+ Skip the main window on boot — SurfSense lives in the system tray until you need + it. +

+
+ +
+
+
+ {/* Keyboard Shortcuts */} diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index 9779a3037..a80520684 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -102,6 +102,16 @@ interface ElectronAPI { setShortcuts: ( config: Partial<{ generalAssist: string; quickAsk: string; autocomplete: string }> ) => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>; + // Launch on system startup + getAutoLaunch: () => Promise<{ + enabled: boolean; + openAsHidden: boolean; + supported: boolean; + }>; + setAutoLaunch: ( + enabled: boolean, + openAsHidden?: boolean + ) => Promise<{ enabled: boolean; openAsHidden: boolean; supported: boolean }>; // Active search space getActiveSearchSpace: () => Promise; setActiveSearchSpace: (id: string) => Promise;