mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
Merge pull request #1276 from MODSetter/dev_mod
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions
feat: add startup for desktop app
This commit is contained in:
commit
1919150523
23 changed files with 1176 additions and 107 deletions
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -43,4 +43,12 @@ 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',
|
||||
ANALYTICS_CAPTURE: 'analytics:capture',
|
||||
ANALYTICS_GET_CONTEXT: 'analytics:get-context',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -24,10 +24,18 @@ 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';
|
||||
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;
|
||||
|
||||
|
|
@ -120,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) =>
|
||||
|
|
@ -131,6 +154,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<string, unknown> }) => {
|
||||
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<string, unknown> }) => {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -55,6 +68,7 @@ app.whenReady().then(async () => {
|
|||
|
||||
app.on('activate', () => {
|
||||
const mw = getMainWindow();
|
||||
trackEvent('desktop_app_activated');
|
||||
if (!mw || mw.isDestroyed()) {
|
||||
createMainWindow('/dashboard');
|
||||
} else {
|
||||
|
|
@ -70,7 +84,8 @@ app.on('window-all-closed', () => {
|
|||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true;
|
||||
markQuitting();
|
||||
trackEvent('desktop_app_quit');
|
||||
});
|
||||
|
||||
let didCleanup = false;
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
||||
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<string, unknown>): 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<string, unknown>
|
||||
): 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<string, unknown>
|
||||
): void {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
client.capture({
|
||||
distinctId,
|
||||
distinctId: currentDistinctId || machineId,
|
||||
event,
|
||||
properties: {
|
||||
platform: 'desktop',
|
||||
app_version: app.getVersion(),
|
||||
os: process.platform,
|
||||
...baseProperties(),
|
||||
...properties,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
304
surfsense_desktop/src/modules/auto-launch.ts
Normal file
304
surfsense_desktop/src/modules/auto-launch.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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(() => {});
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
@ -64,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WatchedFolderConfig[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -82,8 +82,23 @@ 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) =>
|
||||
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<string, unknown>) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_IDENTIFY, { userId, properties }),
|
||||
analyticsReset: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_RESET),
|
||||
analyticsCapture: (event: string, properties?: Record<string, unknown>) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }),
|
||||
getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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<PagePurchaseStatus, { label: string; className: string }> = {
|
||||
completed: {
|
||||
label: "Completed",
|
||||
|
|
@ -31,6 +48,22 @@ const STATUS_STYLES: Record<PagePurchaseStatus, { label: string; className: stri
|
|||
},
|
||||
};
|
||||
|
||||
const KIND_META: Record<
|
||||
PurchaseKind,
|
||||
{ label: string; icon: React.ComponentType<{ className?: string }>; 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<UnifiedPurchase[]>(() => {
|
||||
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 (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
|
|
@ -60,15 +139,13 @@ export function PurchaseHistoryContent() {
|
|||
);
|
||||
}
|
||||
|
||||
const purchases = data?.purchases ?? [];
|
||||
|
||||
if (purchases.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
||||
<ReceiptText className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">No purchases yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your page-pack purchases will appear here after checkout.
|
||||
Your page and premium token purchases will appear here after checkout.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -81,25 +158,36 @@ export function PurchaseHistoryContent() {
|
|||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="text-right">Pages</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="text-right">Granted</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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 (
|
||||
<TableRow key={p.id}>
|
||||
<TableRow key={`${p.kind}-${p.id}`}>
|
||||
<TableCell className="text-sm">{formatDate(p.created_at)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{p.pages_granted.toLocaleString()}
|
||||
<TableCell className="text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<KindIcon className={cn("h-4 w-4", kind.iconClass)} />
|
||||
<span>{kind.label}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{formatAmount(p)}
|
||||
{p.granted.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{formatAmount(p.amount_total, p.currency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={cn("text-[10px]", style.className)}>{style.label}</Badge>
|
||||
<Badge className={cn("text-[10px]", statusStyle.className)}>
|
||||
{statusStyle.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
|
@ -108,7 +196,8 @@ export function PurchaseHistoryContent() {
|
|||
</Table>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Showing your {purchases.length} most recent purchase{purchases.length !== 1 ? "s" : ""}.
|
||||
Showing your {purchases.length} most recent purchase
|
||||
{purchases.length !== 1 ? "s" : ""}.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -340,5 +340,85 @@ export const AUTO_INDEX_DEFAULTS: Record<string, AutoIndexConfig> = {
|
|||
|
||||
export const AUTO_INDEX_CONNECTOR_TYPES = new Set<string>(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<string, ConnectorTelemetryMeta> =
|
||||
(() => {
|
||||
const map = new Map<string, ConnectorTelemetryMeta>();
|
||||
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<typeof getPagePurchasesResponse>;
|
|||
export type CreateTokenCheckoutSessionRequest = z.infer<typeof createTokenCheckoutSessionRequest>;
|
||||
export type CreateTokenCheckoutSessionResponse = z.infer<typeof createTokenCheckoutSessionResponse>;
|
||||
export type TokenStripeStatusResponse = z.infer<typeof tokenStripeStatusResponse>;
|
||||
export type TokenPurchaseStatus = z.infer<typeof tokenPurchaseStatusEnum>;
|
||||
export type TokenPurchase = z.infer<typeof tokenPurchase>;
|
||||
export type GetTokenPurchasesResponse = z.infer<typeof getTokenPurchasesResponse>;
|
||||
|
|
|
|||
|
|
@ -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<string | undefined> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop undefined values so PostHog doesn't log `"foo": undefined` noise.
|
||||
*/
|
||||
function compact<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>) {
|
||||
try {
|
||||
|
|
@ -476,10 +557,19 @@ export function identifyUser(userId: string, properties?: Record<string, unknown
|
|||
} catch {
|
||||
// Silently ignore – ad-blockers may break posthog
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.electronAPI?.analyticsIdentify) {
|
||||
void window.electronAPI.analyticsIdentify(userId, properties);
|
||||
}
|
||||
} catch {
|
||||
// IPC errors must never break the app
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset user identity (call on logout)
|
||||
* Reset user identity (call on logout). Mirrors the reset into the
|
||||
* Electron main process when running inside the desktop app.
|
||||
*/
|
||||
export function resetUser() {
|
||||
try {
|
||||
|
|
@ -487,4 +577,12 @@ export function resetUser() {
|
|||
} catch {
|
||||
// Silently ignore – ad-blockers may break posthog
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.electronAPI?.analyticsReset) {
|
||||
void window.electronAPI.analyticsReset();
|
||||
}
|
||||
} catch {
|
||||
// IPC errors must never break the app
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
surfsense_web/types/window.d.ts
vendored
20
surfsense_web/types/window.d.ts
vendored
|
|
@ -102,9 +102,29 @@ 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>;
|
||||
// Analytics bridge (PostHog mirror into the Electron main process)
|
||||
analyticsIdentify: (userId: string, properties?: Record<string, unknown>) => Promise<void>;
|
||||
analyticsReset: () => Promise<void>;
|
||||
analyticsCapture: (event: string, properties?: Record<string, unknown>) => Promise<void>;
|
||||
getAnalyticsContext: () => Promise<{
|
||||
distinctId: string;
|
||||
machineId: string;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue