feat: add auto-launch functionality for desktop app

- Implemented IPC channels for managing auto-launch settings.
- Enhanced main process to handle auto-launch behavior on startup.
- Updated UI components to allow users to configure launch options.
- Integrated analytics tracking for auto-launch events.

This commit introduces the ability for users to enable or disable the application launching at system startup, along with options for starting minimized to the tray.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-04-20 12:42:06 -07:00
parent 7a389e7a25
commit 24383a3741
9 changed files with 493 additions and 17 deletions

View file

@ -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',

View file

@ -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) =>

View file

@ -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');
});

View file

@ -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<PersistedState> {
const s = await getStore();
const stored = s.get(STORE_KEY) as Partial<PersistedState> | undefined;
return { ...DEFAULTS, ...(stored ?? {}) };
}
async function writePersisted(next: PersistedState): Promise<void> {
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<AutoLaunchState> {
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<AutoLaunchState> {
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<boolean> {
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<void> {
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;
}

View file

@ -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;
}

View file

@ -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;
});

View file

@ -82,6 +82,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
setShortcuts: (config: Record<string, string>) =>
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) =>

View file

@ -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<SearchSpace[]>([]);
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(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() {
</CardContent>
</Card>
{/* Launch on Startup */}
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<CardTitle className="text-base md:text-lg flex items-center gap-2">
<Power className="h-4 w-4" />
Launch on Startup
</CardTitle>
<CardDescription className="text-xs md:text-sm">
Automatically start SurfSense when you sign in to your computer so global
shortcuts and folder sync are always available.
</CardDescription>
</CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6 space-y-3">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="auto-launch-toggle" className="text-sm font-medium cursor-pointer">
Open SurfSense at login
</Label>
<p className="text-xs text-muted-foreground">
{autoLaunchSupported
? "Adds SurfSense to your system's login items."
: "Only available in the packaged desktop app."}
</p>
</div>
<Switch
id="auto-launch-toggle"
checked={autoLaunchEnabled}
onCheckedChange={handleAutoLaunchToggle}
disabled={!autoLaunchSupported}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label
htmlFor="auto-launch-hidden-toggle"
className="text-sm font-medium cursor-pointer"
>
Start minimized to tray
</Label>
<p className="text-xs text-muted-foreground">
Skip the main window on boot SurfSense lives in the system tray until you need
it.
</p>
</div>
<Switch
id="auto-launch-hidden-toggle"
checked={autoLaunchHidden}
onCheckedChange={handleAutoLaunchHiddenToggle}
disabled={!autoLaunchSupported || !autoLaunchEnabled}
/>
</div>
</CardContent>
</Card>
{/* Keyboard Shortcuts */}
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">

View file

@ -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<string | null>;
setActiveSearchSpace: (id: string) => Promise<void>;