From b440610e0419089a572259749982697135fdc0f9 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Sat, 18 Apr 2026 14:35:14 -0700 Subject: [PATCH 1/3] feat: implement analytics tracking for desktop app events - Added event tracking for desktop app activation and quitting. - Introduced analytics bridge in preload script to handle user identification and event capturing. - Updated IPC channels to support analytics-related actions. - Enhanced analytics functionality in the main process to track user interactions and application updates. - Integrated analytics tracking for folder watching and deep link handling. - Improved connector setup tracking in the web application. This commit enhances the overall analytics capabilities of the application, ensuring better user behavior insights and event tracking across both desktop and web environments. --- surfsense_desktop/src/ipc/channels.ts | 5 + surfsense_desktop/src/ipc/handlers.ts | 42 +++++ surfsense_desktop/src/main.ts | 2 + surfsense_desktop/src/modules/analytics.ts | 102 ++++++++++- surfsense_desktop/src/modules/auto-updater.ts | 15 ++ surfsense_desktop/src/modules/deep-links.ts | 5 + .../src/modules/folder-watcher.ts | 18 ++ surfsense_desktop/src/modules/tray.ts | 29 +-- surfsense_desktop/src/preload.ts | 10 ++ .../components/PurchaseHistoryContent.tsx | 133 +++++++++++--- .../constants/connector-constants.ts | 80 +++++++++ .../hooks/use-connector-dialog.ts | 49 +++++- .../components/free-chat/anonymous-chat.tsx | 7 + .../components/free-chat/free-chat-page.tsx | 9 + surfsense_web/contracts/types/stripe.types.ts | 5 +- surfsense_web/instrumentation-client.ts | 66 ++++++- surfsense_web/lib/posthog/events.ts | 166 ++++++++++++++---- surfsense_web/types/window.d.ts | 10 ++ 18 files changed, 673 insertions(+), 80 deletions(-) diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 61213eb46..410b924b9 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -43,4 +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', + // Analytics (PostHog) bridge: renderer <-> main + ANALYTICS_IDENTIFY: 'analytics:identify', + ANALYTICS_RESET: 'analytics:reset', + ANALYTICS_CAPTURE: 'analytics:capture', + ANALYTICS_GET_CONTEXT: 'analytics:get-context', } as const; diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index afb2ba038..63cce3d01 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -28,6 +28,13 @@ import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/activ import { reregisterQuickAsk } from '../modules/quick-ask'; import { reregisterAutocomplete } from '../modules/autocomplete'; import { reregisterGeneralAssist } from '../modules/tray'; +import { + getDistinctId, + getMachineId, + identifyUser as analyticsIdentify, + resetUser as analyticsReset, + trackEvent, +} from '../modules/analytics'; let authTokens: { bearer: string; refresh: string } | null = null; @@ -131,6 +138,41 @@ export function registerIpcHandlers(): void { if (config.generalAssist) await reregisterGeneralAssist(); if (config.quickAsk) await reregisterQuickAsk(); if (config.autocomplete) await reregisterAutocomplete(); + trackEvent('desktop_shortcut_updated', { + keys: Object.keys(config), + }); return updated; }); + + // Analytics bridge — the renderer (web UI) hands the logged-in user down + // to the main process so desktop-only events are attributed to the same + // PostHog person, not just an anonymous machine ID. + ipcMain.handle( + IPC_CHANNELS.ANALYTICS_IDENTIFY, + (_event, payload: { userId: string; properties?: Record }) => { + if (!payload?.userId) return; + analyticsIdentify(String(payload.userId), payload.properties); + } + ); + + ipcMain.handle(IPC_CHANNELS.ANALYTICS_RESET, () => { + analyticsReset(); + }); + + ipcMain.handle( + IPC_CHANNELS.ANALYTICS_CAPTURE, + (_event, payload: { event: string; properties?: Record }) => { + if (!payload?.event) return; + trackEvent(payload.event, payload.properties); + } + ); + + ipcMain.handle(IPC_CHANNELS.ANALYTICS_GET_CONTEXT, () => { + return { + distinctId: getDistinctId(), + machineId: getMachineId(), + appVersion: app.getVersion(), + platform: process.platform, + }; + }); } diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 231553f9a..7556a2743 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -55,6 +55,7 @@ app.whenReady().then(async () => { app.on('activate', () => { const mw = getMainWindow(); + trackEvent('desktop_app_activated'); if (!mw || mw.isDestroyed()) { createMainWindow('/dashboard'); } else { @@ -71,6 +72,7 @@ app.on('window-all-closed', () => { app.on('before-quit', () => { isQuitting = true; + trackEvent('desktop_app_quit'); }); let didCleanup = false; diff --git a/surfsense_desktop/src/modules/analytics.ts b/surfsense_desktop/src/modules/analytics.ts index 0bbcb3026..01dba60f0 100644 --- a/surfsense_desktop/src/modules/analytics.ts +++ b/surfsense_desktop/src/modules/analytics.ts @@ -3,14 +3,27 @@ import { machineIdSync } from 'node-machine-id'; import { app } from 'electron'; let client: PostHog | null = null; -let distinctId = ''; +let machineId = ''; +let currentDistinctId = ''; +let identifiedUserId: string | null = null; + +function baseProperties(): Record { + return { + platform: 'desktop', + app_version: app.getVersion(), + os: process.platform, + arch: process.arch, + machine_id: machineId, + }; +} export function initAnalytics(): void { const key = process.env.POSTHOG_KEY; if (!key) return; try { - distinctId = machineIdSync(true); + machineId = machineIdSync(true); + currentDistinctId = machineId; } catch { return; } @@ -22,17 +35,92 @@ export function initAnalytics(): void { }); } -export function trackEvent(event: string, properties?: Record): void { +export function getMachineId(): string { + return machineId; +} + +export function getDistinctId(): string { + return currentDistinctId; +} + +/** + * Identify the current logged-in user in PostHog so main-process desktop + * events (and linked anonymous machine events) are attributed to that person. + * + * Idempotent: calling identify repeatedly with the same userId is a no-op. + */ +export function identifyUser( + userId: string, + properties?: Record +): void { + if (!client || !userId) return; + if (identifiedUserId === userId) { + // Already identified — only refresh person properties + try { + client.identify({ + distinctId: userId, + properties: { + ...baseProperties(), + $set: { + ...(properties || {}), + platform: 'desktop', + last_seen_at: new Date().toISOString(), + }, + }, + }); + } catch { + // ignore + } + return; + } + + try { + // Link the anonymous machine distinct ID to the authenticated user + client.identify({ + distinctId: userId, + properties: { + ...baseProperties(), + $anon_distinct_id: machineId, + $set: { + ...(properties || {}), + platform: 'desktop', + last_seen_at: new Date().toISOString(), + }, + $set_once: { + first_seen_platform: 'desktop', + }, + }, + }); + + identifiedUserId = userId; + currentDistinctId = userId; + } catch { + // Analytics must never break the app + } +} + +/** + * Reset user identity on logout. Subsequent events are captured anonymously + * against the machine ID until the user logs in again. + */ +export function resetUser(): void { + if (!client) return; + identifiedUserId = null; + currentDistinctId = machineId; +} + +export function trackEvent( + event: string, + properties?: Record +): void { if (!client) return; try { client.capture({ - distinctId, + distinctId: currentDistinctId || machineId, event, properties: { - platform: 'desktop', - app_version: app.getVersion(), - os: process.platform, + ...baseProperties(), ...properties, }, }); diff --git a/surfsense_desktop/src/modules/auto-updater.ts b/surfsense_desktop/src/modules/auto-updater.ts index 47a85b730..e323abe53 100644 --- a/surfsense_desktop/src/modules/auto-updater.ts +++ b/surfsense_desktop/src/modules/auto-updater.ts @@ -1,4 +1,5 @@ import { app, dialog } from 'electron'; +import { trackEvent } from './analytics'; const SEMVER_RE = /^\d+\.\d+\.\d+/; @@ -17,10 +18,18 @@ export function setupAutoUpdater(): void { autoUpdater.on('update-available', (info: { version: string }) => { console.log(`Update available: ${info.version}`); + trackEvent('desktop_update_available', { + current_version: version, + new_version: info.version, + }); }); autoUpdater.on('update-downloaded', (info: { version: string }) => { console.log(`Update downloaded: ${info.version}`); + trackEvent('desktop_update_downloaded', { + current_version: version, + new_version: info.version, + }); dialog.showMessageBox({ type: 'info', buttons: ['Restart', 'Later'], @@ -29,13 +38,19 @@ export function setupAutoUpdater(): void { message: `Version ${info.version} has been downloaded. Restart to apply the update.`, }).then(({ response }: { response: number }) => { if (response === 0) { + trackEvent('desktop_update_install_accepted', { new_version: info.version }); autoUpdater.quitAndInstall(); + } else { + trackEvent('desktop_update_install_deferred', { new_version: info.version }); } }); }); autoUpdater.on('error', (err: Error) => { console.log('Auto-updater: update check skipped —', err.message?.split('\n')[0]); + trackEvent('desktop_update_error', { + message: err.message?.split('\n')[0], + }); }); autoUpdater.checkForUpdates().catch(() => {}); diff --git a/surfsense_desktop/src/modules/deep-links.ts b/surfsense_desktop/src/modules/deep-links.ts index 1a2b08395..bfd35bbaf 100644 --- a/surfsense_desktop/src/modules/deep-links.ts +++ b/surfsense_desktop/src/modules/deep-links.ts @@ -2,6 +2,7 @@ import { app } from 'electron'; import path from 'path'; import { getMainWindow } from './window'; import { getServerPort } from './server'; +import { trackEvent } from './analytics'; const PROTOCOL = 'surfsense'; @@ -16,6 +17,10 @@ function handleDeepLink(url: string) { if (!win) return; const parsed = new URL(url); + trackEvent('desktop_deep_link_received', { + host: parsed.hostname, + path: parsed.pathname, + }); if (parsed.hostname === 'auth' && parsed.pathname === '/callback') { const params = parsed.searchParams.toString(); win.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`); diff --git a/surfsense_desktop/src/modules/folder-watcher.ts b/surfsense_desktop/src/modules/folder-watcher.ts index 96b490d7b..ee4214d8a 100644 --- a/surfsense_desktop/src/modules/folder-watcher.ts +++ b/surfsense_desktop/src/modules/folder-watcher.ts @@ -4,6 +4,7 @@ import { randomUUID } from 'crypto'; import * as path from 'path'; import * as fs from 'fs'; import { IPC_CHANNELS } from '../ipc/channels'; +import { trackEvent } from './analytics'; export interface WatchedFolderConfig { path: string; @@ -401,6 +402,15 @@ export async function addWatchedFolder( await startWatcher(config); } + trackEvent('desktop_folder_watch_added', { + search_space_id: config.searchSpaceId, + root_folder_id: config.rootFolderId, + active: config.active, + has_exclude_patterns: (config.excludePatterns?.length ?? 0) > 0, + has_extension_filter: !!config.fileExtensions && config.fileExtensions.length > 0, + is_update: existing >= 0, + }); + return folders; } @@ -409,6 +419,7 @@ export async function removeWatchedFolder( ): Promise { const s = await getStore(); const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []); + const removed = folders.find((f: WatchedFolderConfig) => f.path === folderPath); const updated = folders.filter((f: WatchedFolderConfig) => f.path !== folderPath); s.set(STORE_KEY, updated); @@ -418,6 +429,13 @@ export async function removeWatchedFolder( const ms = await getMtimeStore(); ms.delete(folderPath); + if (removed) { + trackEvent('desktop_folder_watch_removed', { + search_space_id: removed.searchSpaceId, + root_folder_id: removed.rootFolderId, + }); + } + return updated; } diff --git a/surfsense_desktop/src/modules/tray.ts b/surfsense_desktop/src/modules/tray.ts index 1749145a1..88444cc54 100644 --- a/surfsense_desktop/src/modules/tray.ts +++ b/surfsense_desktop/src/modules/tray.ts @@ -2,6 +2,7 @@ import { app, globalShortcut, Menu, nativeImage, Tray } from 'electron'; import path from 'path'; import { getMainWindow, createMainWindow } from './window'; import { getShortcuts } from './shortcuts'; +import { trackEvent } from './analytics'; let tray: Tray | null = null; let currentShortcut: string | null = null; @@ -15,14 +16,16 @@ function getTrayIcon(): nativeImage { return img.resize({ width: 16, height: 16 }); } -function showMainWindow(): void { - let win = getMainWindow(); - if (!win || win.isDestroyed()) { - win = createMainWindow('/dashboard'); +function showMainWindow(source: 'tray_click' | 'tray_menu' | 'shortcut' = 'tray_click'): void { + const existing = getMainWindow(); + const reopened = !existing || existing.isDestroyed(); + if (reopened) { + createMainWindow('/dashboard'); } else { - win.show(); - win.focus(); + existing.show(); + existing.focus(); } + trackEvent('desktop_main_window_shown', { source, reopened }); } function registerShortcut(accelerator: string): void { @@ -32,7 +35,7 @@ function registerShortcut(accelerator: string): void { } if (!accelerator) return; try { - const ok = globalShortcut.register(accelerator, showMainWindow); + const ok = globalShortcut.register(accelerator, () => showMainWindow('shortcut')); if (ok) { currentShortcut = accelerator; } else { @@ -50,13 +53,19 @@ export async function createTray(): Promise { tray.setToolTip('SurfSense'); const contextMenu = Menu.buildFromTemplate([ - { label: 'Open SurfSense', click: showMainWindow }, + { label: 'Open SurfSense', click: () => showMainWindow('tray_menu') }, { type: 'separator' }, - { label: 'Quit', click: () => { app.exit(0); } }, + { + label: 'Quit', + click: () => { + trackEvent('desktop_tray_quit_clicked'); + app.exit(0); + }, + }, ]); tray.setContextMenu(contextMenu); - tray.on('double-click', showMainWindow); + tray.on('double-click', () => showMainWindow('tray_click')); const shortcuts = await getShortcuts(); registerShortcut(shortcuts.generalAssist); diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index e3d12c5e6..01248e7d0 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -86,4 +86,14 @@ contextBridge.exposeInMainWorld('electronAPI', { getActiveSearchSpace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE), setActiveSearchSpace: (id: string) => ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, id), + + // Analytics bridge — lets posthog-js running inside the Next.js renderer + // mirror identify/reset/capture into the Electron main-process PostHog + // client so desktop-only events are attributed to the logged-in user. + analyticsIdentify: (userId: string, properties?: Record) => + ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_IDENTIFY, { userId, properties }), + analyticsReset: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_RESET), + analyticsCapture: (event: string, properties?: Record) => + ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }), + getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT), }); diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx index 9bc77edff..cb079db70 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx @@ -1,7 +1,8 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import { ReceiptText } from "lucide-react"; +import { useQueries } from "@tanstack/react-query"; +import { Coins, FileText, ReceiptText } from "lucide-react"; +import { useMemo } from "react"; import { Badge } from "@/components/ui/badge"; import { Spinner } from "@/components/ui/spinner"; import { @@ -12,10 +13,26 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import type { PagePurchase, PagePurchaseStatus } from "@/contracts/types/stripe.types"; +import type { + PagePurchase, + PagePurchaseStatus, + TokenPurchase, +} from "@/contracts/types/stripe.types"; import { stripeApiService } from "@/lib/apis/stripe-api.service"; import { cn } from "@/lib/utils"; +type PurchaseKind = "pages" | "tokens"; + +type UnifiedPurchase = { + id: string; + kind: PurchaseKind; + created_at: string; + status: PagePurchaseStatus; + granted: number; + amount_total: number | null; + currency: string | null; +}; + const STATUS_STYLES: Record = { completed: { label: "Completed", @@ -31,6 +48,22 @@ const STATUS_STYLES: Record; iconClass: string } +> = { + pages: { + label: "Pages", + icon: FileText, + iconClass: "text-sky-500", + }, + tokens: { + label: "Premium Tokens", + icon: Coins, + iconClass: "text-amber-500", + }, +}; + function formatDate(iso: string): string { return new Date(iso).toLocaleDateString(undefined, { year: "numeric", @@ -39,19 +72,65 @@ function formatDate(iso: string): string { }); } -function formatAmount(purchase: PagePurchase): string { - if (purchase.amount_total == null) return "—"; - const dollars = purchase.amount_total / 100; - const currency = (purchase.currency ?? "usd").toUpperCase(); - return `$${dollars.toFixed(2)} ${currency}`; +function formatAmount(amount: number | null, currency: string | null): string { + if (amount == null) return "—"; + const dollars = amount / 100; + const code = (currency ?? "usd").toUpperCase(); + return `$${dollars.toFixed(2)} ${code}`; +} + +function normalizePagePurchase(p: PagePurchase): UnifiedPurchase { + return { + id: p.id, + kind: "pages", + created_at: p.created_at, + status: p.status, + granted: p.pages_granted, + amount_total: p.amount_total, + currency: p.currency, + }; +} + +function normalizeTokenPurchase(p: TokenPurchase): UnifiedPurchase { + return { + id: p.id, + kind: "tokens", + created_at: p.created_at, + status: p.status, + granted: p.tokens_granted, + amount_total: p.amount_total, + currency: p.currency, + }; } export function PurchaseHistoryContent() { - const { data, isLoading } = useQuery({ - queryKey: ["stripe-purchases"], - queryFn: () => stripeApiService.getPurchases(), + const results = useQueries({ + queries: [ + { + queryKey: ["stripe-purchases"], + queryFn: () => stripeApiService.getPurchases(), + }, + { + queryKey: ["stripe-token-purchases"], + queryFn: () => stripeApiService.getTokenPurchases(), + }, + ], }); + const [pagesQuery, tokensQuery] = results; + const isLoading = pagesQuery.isLoading || tokensQuery.isLoading; + + const purchases = useMemo(() => { + const pagePurchases = pagesQuery.data?.purchases ?? []; + const tokenPurchases = tokensQuery.data?.purchases ?? []; + return [ + ...pagePurchases.map(normalizePagePurchase), + ...tokenPurchases.map(normalizeTokenPurchase), + ].sort( + (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + }, [pagesQuery.data, tokensQuery.data]); + if (isLoading) { return (
@@ -60,15 +139,13 @@ export function PurchaseHistoryContent() { ); } - const purchases = data?.purchases ?? []; - if (purchases.length === 0) { return (

No purchases yet

- Your page-pack purchases will appear here after checkout. + Your page and premium token purchases will appear here after checkout.

); @@ -81,25 +158,36 @@ export function PurchaseHistoryContent() { Date - Pages + Type + Granted Amount Status {purchases.map((p) => { - const style = STATUS_STYLES[p.status]; + const statusStyle = STATUS_STYLES[p.status]; + const kind = KIND_META[p.kind]; + const KindIcon = kind.icon; return ( - + {formatDate(p.created_at)} - - {p.pages_granted.toLocaleString()} + +
+ + {kind.label} +
- {formatAmount(p)} + {p.granted.toLocaleString()} + + + {formatAmount(p.amount_total, p.currency)} - {style.label} + + {statusStyle.label} +
); @@ -108,7 +196,8 @@ export function PurchaseHistoryContent() {

- Showing your {purchases.length} most recent purchase{purchases.length !== 1 ? "s" : ""}. + Showing your {purchases.length} most recent purchase + {purchases.length !== 1 ? "s" : ""}.

); diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts index da6885ffe..d430e0f6c 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts @@ -340,5 +340,85 @@ export const AUTO_INDEX_DEFAULTS: Record = { export const AUTO_INDEX_CONNECTOR_TYPES = new Set(Object.keys(AUTO_INDEX_DEFAULTS)); +// ============================================================================ +// CONNECTOR TELEMETRY REGISTRY +// ---------------------------------------------------------------------------- +// Single source of truth for "what does this connector_type look like in +// analytics?". Any connector added to the lists above is automatically +// picked up here, so adding a new integration does NOT require touching +// `lib/posthog/events.ts` or per-connector tracking code. +// ============================================================================ + +export type ConnectorTelemetryGroup = + | "oauth" + | "composio" + | "crawler" + | "other" + | "unknown"; + +export interface ConnectorTelemetryMeta { + connector_type: string; + connector_title: string; + connector_group: ConnectorTelemetryGroup; + is_oauth: boolean; +} + +const CONNECTOR_TELEMETRY_REGISTRY: ReadonlyMap = + (() => { + const map = new Map(); + + for (const c of OAUTH_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "oauth", + is_oauth: true, + }); + } + for (const c of COMPOSIO_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "composio", + is_oauth: true, + }); + } + for (const c of CRAWLERS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "crawler", + is_oauth: false, + }); + } + for (const c of OTHER_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "other", + is_oauth: false, + }); + } + + return map; + })(); + +/** + * Returns telemetry metadata for a connector_type, or a minimal "unknown" + * record so tracking never no-ops for connectors that exist in the backend + * but were forgotten in the UI registry. + */ +export function getConnectorTelemetryMeta(connectorType: string): ConnectorTelemetryMeta { + const hit = CONNECTOR_TELEMETRY_REGISTRY.get(connectorType); + if (hit) return hit; + + return { + connector_type: connectorType, + connector_title: connectorType, + connector_group: "unknown", + is_oauth: false, + }; +} + // Re-export IndexingConfigState from schemas for backward compatibility export type { IndexingConfigState } from "./connector-popup.schemas"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index caa85ba2d..7ac903342 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -24,6 +24,8 @@ import { isSelfHosted } from "@/lib/env-config"; import { trackConnectorConnected, trackConnectorDeleted, + trackConnectorSetupFailure, + trackConnectorSetupStarted, trackIndexWithDateRangeOpened, trackIndexWithDateRangeStarted, trackPeriodicIndexingStarted, @@ -232,10 +234,20 @@ export const useConnectorDialog = () => { if (result.error) { const oauthConnector = result.connector - ? OAUTH_CONNECTORS.find((c) => c.id === result.connector) + ? OAUTH_CONNECTORS.find((c) => c.id === result.connector) || + COMPOSIO_CONNECTORS.find((c) => c.id === result.connector) : null; const name = oauthConnector?.title || "connector"; + if (oauthConnector) { + trackConnectorSetupFailure( + Number(searchSpaceId), + oauthConnector.connectorType, + result.error, + "oauth_callback" + ); + } + if (result.error === "duplicate_account") { toast.error(`This ${name} account is already connected`, { description: "Please use a different account or manage the existing connection.", @@ -348,6 +360,12 @@ export const useConnectorDialog = () => { // Set connecting state immediately to disable button and show spinner setConnectingId(connector.id); + trackConnectorSetupStarted( + Number(searchSpaceId), + connector.connectorType, + "oauth_click" + ); + try { // Check if authEndpoint already has query parameters const separator = connector.authEndpoint.includes("?") ? "&" : "?"; @@ -369,6 +387,12 @@ export const useConnectorDialog = () => { window.location.href = validatedData.auth_url; } catch (error) { console.error(`Error connecting to ${connector.title}:`, error); + trackConnectorSetupFailure( + Number(searchSpaceId), + connector.connectorType, + error instanceof Error ? error.message : "oauth_initiation_failed", + "oauth_init" + ); if (error instanceof Error && error.message.includes("Invalid auth URL")) { toast.error(`Invalid response from ${connector.title} OAuth endpoint`); } else { @@ -392,6 +416,11 @@ export const useConnectorDialog = () => { if (!searchSpaceId) return; setConnectingId("webcrawler-connector"); + trackConnectorSetupStarted( + Number(searchSpaceId), + EnumConnectorName.WEBCRAWLER_CONNECTOR, + "webcrawler_quick_add" + ); try { await createConnector({ data: { @@ -441,6 +470,12 @@ export const useConnectorDialog = () => { } } catch (error) { console.error("Error creating webcrawler connector:", error); + trackConnectorSetupFailure( + Number(searchSpaceId), + EnumConnectorName.WEBCRAWLER_CONNECTOR, + error instanceof Error ? error.message : "webcrawler_create_failed", + "webcrawler_quick_add" + ); toast.error("Failed to create web crawler connector"); } finally { setConnectingId(null); @@ -452,6 +487,12 @@ export const useConnectorDialog = () => { (connectorType: string) => { if (!searchSpaceId) return; + trackConnectorSetupStarted( + Number(searchSpaceId), + connectorType, + "non_oauth_click" + ); + // Handle Obsidian specifically on Desktop & Cloud if (connectorType === EnumConnectorName.OBSIDIAN_CONNECTOR && !selfHosted && isDesktop) { setIsOpen(false); @@ -680,6 +721,12 @@ export const useConnectorDialog = () => { } } catch (error) { console.error("Error creating connector:", error); + trackConnectorSetupFailure( + Number(searchSpaceId), + connectingConnectorType ?? formData.connector_type, + error instanceof Error ? error.message : "connector_create_failed", + "non_oauth_form" + ); toast.error(error instanceof Error ? error.message : "Failed to create connector"); } finally { isCreatingConnectorRef.current = false; diff --git a/surfsense_web/components/free-chat/anonymous-chat.tsx b/surfsense_web/components/free-chat/anonymous-chat.tsx index 1ac6baad4..b286c5316 100644 --- a/surfsense_web/components/free-chat/anonymous-chat.tsx +++ b/surfsense_web/components/free-chat/anonymous-chat.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types"; import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service"; import { readSSEStream } from "@/lib/chat/streaming-state"; +import { trackAnonymousChatMessageSent } from "@/lib/posthog/events"; import { cn } from "@/lib/utils"; import { QuotaBar } from "./quota-bar"; import { QuotaWarningBanner } from "./quota-warning-banner"; @@ -61,6 +62,12 @@ export function AnonymousChat({ model }: AnonymousChatProps) { textareaRef.current.style.height = "auto"; } + trackAnonymousChatMessageSent({ + modelSlug: model.seo_slug, + messageLength: trimmed.length, + surface: "free_model_page", + }); + const controller = new AbortController(); abortRef.current = controller; diff --git a/surfsense_web/components/free-chat/free-chat-page.tsx b/surfsense_web/components/free-chat/free-chat-page.tsx index b1d0f6850..b389a8489 100644 --- a/surfsense_web/components/free-chat/free-chat-page.tsx +++ b/surfsense_web/components/free-chat/free-chat-page.tsx @@ -28,6 +28,7 @@ import { updateToolCall, } from "@/lib/chat/streaming-state"; import { BACKEND_URL } from "@/lib/env-config"; +import { trackAnonymousChatMessageSent } from "@/lib/posthog/events"; import { FreeModelSelector } from "./free-model-selector"; import { FreeThread } from "./free-thread"; @@ -206,6 +207,14 @@ export function FreeChatPage() { } if (!userQuery.trim()) return; + trackAnonymousChatMessageSent({ + modelSlug, + messageLength: userQuery.trim().length, + hasUploadedDoc: + anonMode.isAnonymous && anonMode.uploadedDoc !== null ? true : false, + surface: "free_chat_page", + }); + const userMsgId = `msg-user-${Date.now()}`; setMessages((prev) => [ ...prev, diff --git a/surfsense_web/contracts/types/stripe.types.ts b/surfsense_web/contracts/types/stripe.types.ts index c4c6f2d74..c8b017044 100644 --- a/surfsense_web/contracts/types/stripe.types.ts +++ b/surfsense_web/contracts/types/stripe.types.ts @@ -49,6 +49,8 @@ export const tokenStripeStatusResponse = z.object({ premium_tokens_remaining: z.number().default(0), }); +export const tokenPurchaseStatusEnum = pagePurchaseStatusEnum; + export const tokenPurchase = z.object({ id: z.uuid(), stripe_checkout_session_id: z.string(), @@ -57,7 +59,7 @@ export const tokenPurchase = z.object({ tokens_granted: z.number(), amount_total: z.number().nullable(), currency: z.string().nullable(), - status: z.string(), + status: tokenPurchaseStatusEnum, completed_at: z.string().nullable(), created_at: z.string(), }); @@ -75,5 +77,6 @@ export type GetPagePurchasesResponse = z.infer; export type CreateTokenCheckoutSessionRequest = z.infer; export type CreateTokenCheckoutSessionResponse = z.infer; export type TokenStripeStatusResponse = z.infer; +export type TokenPurchaseStatus = z.infer; export type TokenPurchase = z.infer; export type GetTokenPurchasesResponse = z.infer; diff --git a/surfsense_web/instrumentation-client.ts b/surfsense_web/instrumentation-client.ts index dff2e9bfe..3ae97fc0b 100644 --- a/surfsense_web/instrumentation-client.ts +++ b/surfsense_web/instrumentation-client.ts @@ -1,18 +1,65 @@ import posthog from "posthog-js"; -function initPostHog() { +/** + * PostHog initialisation for the Next.js renderer. + * + * The same bundle ships in two contexts: + * 1. A normal browser session on surfsense.com -> platform = "web" + * 2. The Electron desktop app (renders the Next app from localhost) + * -> platform = "desktop" + * + * When running inside Electron we also seed `posthog-js` with the main + * process's machine distinctId so that events fired from both the renderer + * (e.g. `chat_message_sent`, page views) and the Electron main process + * (e.g. `desktop_quick_ask_opened`) share a single PostHog person before + * login, and can be merged into the authenticated user afterwards. + */ + +function isElectron(): boolean { + return typeof window !== "undefined" && !!window.electronAPI; +} + +function currentPlatform(): "desktop" | "web" { + return isElectron() ? "desktop" : "web"; +} + +async function resolveBootstrapDistinctId(): Promise { + if (!isElectron() || !window.electronAPI?.getAnalyticsContext) return undefined; + try { + const ctx = await window.electronAPI.getAnalyticsContext(); + return ctx?.machineId || ctx?.distinctId || undefined; + } catch { + return undefined; + } +} + +async function initPostHog() { try { if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return; + const platform = currentPlatform(); + const bootstrapDistinctId = await resolveBootstrapDistinctId(); + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: "https://assets.surfsense.com", ui_host: "https://us.posthog.com", defaults: "2026-01-30", capture_pageview: "history_change", capture_pageleave: true, + ...(bootstrapDistinctId + ? { + bootstrap: { + distinctID: bootstrapDistinctId, + isIdentifiedID: false, + }, + } + : {}), before_send: (event) => { if (event?.properties) { - event.properties.platform = "web"; + event.properties.platform = platform; + if (platform === "desktop") { + event.properties.is_desktop = true; + } const params = new URLSearchParams(window.location.search); const ref = params.get("ref"); @@ -30,9 +77,14 @@ function initPostHog() { event.properties.$set = { ...event.properties.$set, - platform: "web", + platform, last_seen_at: new Date().toISOString(), }; + + event.properties.$set_once = { + ...event.properties.$set_once, + first_seen_platform: platform, + }; } return event; }, @@ -51,8 +103,12 @@ if (typeof window !== "undefined") { window.posthog = posthog; if ("requestIdleCallback" in window) { - requestIdleCallback(initPostHog); + requestIdleCallback(() => { + void initPostHog(); + }); } else { - setTimeout(initPostHog, 3500); + setTimeout(() => { + void initPostHog(); + }, 3500); } } diff --git a/surfsense_web/lib/posthog/events.ts b/surfsense_web/lib/posthog/events.ts index 53aaa71b9..34ed3044d 100644 --- a/surfsense_web/lib/posthog/events.ts +++ b/surfsense_web/lib/posthog/events.ts @@ -1,4 +1,5 @@ import posthog from "posthog-js"; +import { getConnectorTelemetryMeta } from "@/components/assistant-ui/connector-popup/constants/connector-constants"; /** * PostHog Analytics Event Definitions @@ -13,8 +14,8 @@ import posthog from "posthog-js"; * - auth: Authentication events * - search_space: Search space management * - document: Document management - * - chat: Chat and messaging - * - connector: External connector events + * - chat: Chat and messaging (authenticated + anonymous) + * - connector: External connector events (all lifecycle stages) * - contact: Contact form events * - settings: Settings changes * - marketing: Marketing/referral tracking @@ -28,6 +29,17 @@ function safeCapture(event: string, properties?: Record) { } } +/** + * Drop undefined values so PostHog doesn't log `"foo": undefined` noise. + */ +function compact>(obj: T): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (v !== undefined) out[k] = v; + } + return out; +} + // ============================================ // AUTH EVENTS // ============================================ @@ -127,6 +139,28 @@ export function trackChatError(searchSpaceId: number, chatId: number, error?: st }); } +/** + * Track a message sent from the unauthenticated "free" / anonymous chat + * flow. This is intentionally a separate event from `chat_message_sent` + * so WAU / retention queries on the authenticated event stay clean while + * still giving us visibility into top-of-funnel usage on /free/*. + */ +export function trackAnonymousChatMessageSent(options: { + modelSlug: string; + messageLength?: number; + hasUploadedDoc?: boolean; + webSearchEnabled?: boolean; + surface?: "free_chat_page" | "free_model_page"; +}) { + safeCapture("anonymous_chat_message_sent", { + model_slug: options.modelSlug, + message_length: options.messageLength, + has_uploaded_doc: options.hasUploadedDoc ?? false, + web_search_enabled: options.webSearchEnabled, + surface: options.surface, + }); +} + // ============================================ // DOCUMENT EVENTS // ============================================ @@ -179,37 +213,88 @@ export function trackYouTubeImport(searchSpaceId: number, url: string) { } // ============================================ -// CONNECTOR EVENTS +// CONNECTOR EVENTS (generic lifecycle dispatcher) // ============================================ +// +// All connector events go through `trackConnectorEvent`. The connector's +// human-readable title and its group (oauth/composio/crawler/other) are +// auto-attached from the shared registry in `connector-constants.ts`, so +// adding a new connector to that list is the only change required for it +// to show up correctly in PostHog dashboards. -export function trackConnectorSetupStarted(searchSpaceId: number, connectorType: string) { - safeCapture("connector_setup_started", { - search_space_id: searchSpaceId, - connector_type: connectorType, +export type ConnectorEventStage = + | "setup_started" + | "setup_success" + | "setup_failure" + | "oauth_initiated" + | "connected" + | "deleted" + | "synced"; + +export interface ConnectorEventOptions { + searchSpaceId?: number | null; + connectorId?: number | null; + /** Source of the action (e.g. "oauth_callback", "non_oauth_form", "webcrawler_quick_add"). */ + source?: string; + /** Free-form error message for failure events. */ + error?: string; + /** Extra properties specific to the stage (e.g. frequency_minutes for sync events). */ + extra?: Record; +} + +/** + * Generic connector lifecycle tracker. Every connector analytics event + * should funnel through here so the enrichment stays consistent. + */ +export function trackConnectorEvent( + stage: ConnectorEventStage, + connectorType: string, + options: ConnectorEventOptions = {} +) { + const meta = getConnectorTelemetryMeta(connectorType); + safeCapture(`connector_${stage}`, { + ...compact({ + search_space_id: options.searchSpaceId ?? undefined, + connector_id: options.connectorId ?? undefined, + source: options.source, + error: options.error, + }), + connector_type: meta.connector_type, + connector_title: meta.connector_title, + connector_group: meta.connector_group, + is_oauth: meta.is_oauth, + ...(options.extra ?? {}), }); } +// ---- Convenience wrappers kept for backward compatibility ---- + +export function trackConnectorSetupStarted( + searchSpaceId: number, + connectorType: string, + source?: string +) { + trackConnectorEvent("setup_started", connectorType, { searchSpaceId, source }); +} + export function trackConnectorSetupSuccess( searchSpaceId: number, connectorType: string, connectorId: number ) { - safeCapture("connector_setup_success", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, - }); + trackConnectorEvent("setup_success", connectorType, { searchSpaceId, connectorId }); } export function trackConnectorSetupFailure( - searchSpaceId: number, + searchSpaceId: number | null | undefined, connectorType: string, - error?: string + error?: string, + source?: string ) { - safeCapture("connector_setup_failure", { - search_space_id: searchSpaceId, - connector_type: connectorType, + trackConnectorEvent("setup_failure", connectorType, { + searchSpaceId: searchSpaceId ?? undefined, error, + source, }); } @@ -218,11 +303,7 @@ export function trackConnectorDeleted( connectorType: string, connectorId: number ) { - safeCapture("connector_deleted", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, - }); + trackConnectorEvent("deleted", connectorType, { searchSpaceId, connectorId }); } export function trackConnectorSynced( @@ -230,11 +311,7 @@ export function trackConnectorSynced( connectorType: string, connectorId: number ) { - safeCapture("connector_synced", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, - }); + trackConnectorEvent("synced", connectorType, { searchSpaceId, connectorId }); } // ============================================ @@ -345,10 +422,9 @@ export function trackConnectorConnected( connectorType: string, connectorId?: number ) { - safeCapture("connector_connected", { - search_space_id: searchSpaceId, - connector_type: connectorType, - connector_id: connectorId, + trackConnectorEvent("connected", connectorType, { + searchSpaceId, + connectorId: connectorId ?? undefined, }); } @@ -467,8 +543,13 @@ export function trackReferralLanding(refCode: string, landingUrl: string) { // ============================================ /** - * Identify a user for PostHog analytics - * Call this after successful authentication + * Identify a user for PostHog analytics. + * Call this after successful authentication. + * + * In the Electron desktop app the same call is mirrored into the + * main-process PostHog client so desktop-only events (e.g. + * `desktop_quick_ask_opened`, `desktop_autocomplete_accepted`) are + * attributed to the logged-in user rather than an anonymous machine ID. */ export function identifyUser(userId: string, properties?: Record) { try { @@ -476,10 +557,19 @@ export function identifyUser(userId: string, properties?: Record Promise; setActiveSearchSpace: (id: string) => Promise; + // Analytics bridge (PostHog mirror into the Electron main process) + analyticsIdentify: (userId: string, properties?: Record) => Promise; + analyticsReset: () => Promise; + analyticsCapture: (event: string, properties?: Record) => Promise; + getAnalyticsContext: () => Promise<{ + distinctId: string; + machineId: string; + appVersion: string; + platform: string; + }>; } declare global { From 7a389e7a25ead19dd2bd7c88f3872dd0d07ac8b6 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Sat, 18 Apr 2026 16:05:18 -0700 Subject: [PATCH 2/3] fix: alembic migration nos --- ..._content_type.py => 127_add_report_content_type.py} | 10 +++++----- ...esume_prompt.py => 128_seed_build_resume_prompt.py} | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) rename surfsense_backend/alembic/versions/{126_add_report_content_type.py => 127_add_report_content_type.py} (87%) rename surfsense_backend/alembic/versions/{127_seed_build_resume_prompt.py => 128_seed_build_resume_prompt.py} (89%) diff --git a/surfsense_backend/alembic/versions/126_add_report_content_type.py b/surfsense_backend/alembic/versions/127_add_report_content_type.py similarity index 87% rename from surfsense_backend/alembic/versions/126_add_report_content_type.py rename to surfsense_backend/alembic/versions/127_add_report_content_type.py index 3d9e4860c..93bf471af 100644 --- a/surfsense_backend/alembic/versions/126_add_report_content_type.py +++ b/surfsense_backend/alembic/versions/127_add_report_content_type.py @@ -1,7 +1,7 @@ -"""126_add_report_content_type +"""127_add_report_content_type -Revision ID: 126 -Revises: 125 +Revision ID: 127 +Revises: 126 Create Date: 2026-04-15 Adds content_type column to reports table to distinguish between @@ -16,8 +16,8 @@ import sqlalchemy as sa from alembic import op -revision: str = "126" -down_revision: str | None = "125" +revision: str = "127" +down_revision: str | None = "126" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None diff --git a/surfsense_backend/alembic/versions/127_seed_build_resume_prompt.py b/surfsense_backend/alembic/versions/128_seed_build_resume_prompt.py similarity index 89% rename from surfsense_backend/alembic/versions/127_seed_build_resume_prompt.py rename to surfsense_backend/alembic/versions/128_seed_build_resume_prompt.py index 9e05a0510..886879a7b 100644 --- a/surfsense_backend/alembic/versions/127_seed_build_resume_prompt.py +++ b/surfsense_backend/alembic/versions/128_seed_build_resume_prompt.py @@ -1,7 +1,7 @@ -"""127_seed_build_resume_prompt +"""128_seed_build_resume_prompt -Revision ID: 127 -Revises: 126 +Revision ID: 128 +Revises: 127 Create Date: 2026-04-15 Seeds the 'Build Resume' default prompt for all existing users. @@ -16,8 +16,8 @@ import sqlalchemy as sa from alembic import op -revision: str = "127" -down_revision: str | None = "126" +revision: str = "128" +down_revision: str | None = "127" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None From 24383a3741a7d07d4cdad532686382cb0e145948 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Mon, 20 Apr 2026 12:42:06 -0700 Subject: [PATCH 3/3] 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. --- surfsense_desktop/src/ipc/channels.ts | 3 + surfsense_desktop/src/ipc/handlers.ts | 16 + surfsense_desktop/src/main.ts | 43 ++- surfsense_desktop/src/modules/auto-launch.ts | 304 ++++++++++++++++++ surfsense_desktop/src/modules/deep-links.ts | 7 + surfsense_desktop/src/modules/window.ts | 17 + surfsense_desktop/src/preload.ts | 5 + .../components/DesktopContent.tsx | 105 +++++- surfsense_web/types/window.d.ts | 10 + 9 files changed, 493 insertions(+), 17 deletions(-) create mode 100644 surfsense_desktop/src/modules/auto-launch.ts 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;