diff --git a/surfsense_desktop/package.json b/surfsense_desktop/package.json index 58c053c04..74f6274cb 100644 --- a/surfsense_desktop/package.json +++ b/surfsense_desktop/package.json @@ -4,7 +4,7 @@ "description": "SurfSense Desktop App", "main": "dist/main.js", "scripts": { - "dev": "concurrently -k \"pnpm --dir ../surfsense_web dev\" \"wait-on http://localhost:3000 && electron .\"", + "dev": "pnpm build && concurrently -k \"pnpm --dir ../surfsense_web dev\" \"wait-on http://localhost:3000 && electron .\"", "build": "node scripts/build-electron.mjs", "pack:dir": "pnpm build && electron-builder --dir --config electron-builder.yml", "dist": "pnpm build && electron-builder --config electron-builder.yml", diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index d8a30347f..8051703fb 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -35,4 +35,7 @@ export const IPC_CHANNELS = { // Auth token sync across windows GET_AUTH_TOKENS: 'auth:get-tokens', SET_AUTH_TOKENS: 'auth:set-tokens', + // Keyboard shortcut configuration + GET_SHORTCUTS: 'shortcuts:get', + SET_SHORTCUTS: 'shortcuts:set', } as const; diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index b36dcbdcd..7872e7a42 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -20,6 +20,9 @@ import { browseFiles, readLocalFiles, } from '../modules/folder-watcher'; +import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts'; +import { reregisterQuickAsk } from '../modules/quick-ask'; +import { reregisterAutocomplete } from '../modules/autocomplete'; let authTokens: { bearer: string; refresh: string } | null = null; @@ -99,4 +102,13 @@ export function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.GET_AUTH_TOKENS, () => { return authTokens; }); + + ipcMain.handle(IPC_CHANNELS.GET_SHORTCUTS, () => getShortcuts()); + + ipcMain.handle(IPC_CHANNELS.SET_SHORTCUTS, async (_event, config: Partial) => { + const updated = await setShortcuts(config); + if (config.quickAsk) await reregisterQuickAsk(); + if (config.autocomplete) await reregisterAutocomplete(); + return updated; + }); } diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 7ef0ad5be..9eae8a4db 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -29,8 +29,8 @@ app.whenReady().then(async () => { } createMainWindow('/dashboard'); - registerQuickAsk(); - registerAutocomplete(); + await registerQuickAsk(); + await registerAutocomplete(); registerFolderWatcher(); setupAutoUpdater(); diff --git a/surfsense_desktop/src/modules/autocomplete/index.ts b/surfsense_desktop/src/modules/autocomplete/index.ts index 01a4cf913..1b64396b0 100644 --- a/surfsense_desktop/src/modules/autocomplete/index.ts +++ b/surfsense_desktop/src/modules/autocomplete/index.ts @@ -5,9 +5,9 @@ import { hasScreenRecordingPermission, requestAccessibility, requestScreenRecord import { getMainWindow } from '../window'; import { captureScreen } from './screenshot'; import { createSuggestionWindow, destroySuggestion, getSuggestionWindow } from './suggestion-window'; +import { getShortcuts } from '../shortcuts'; -const SHORTCUT = 'CommandOrControl+Shift+Space'; - +let currentShortcut = ''; let autocompleteEnabled = true; let savedClipboard = ''; let sourceApp = ''; @@ -91,7 +91,12 @@ async function acceptAndInject(text: string): Promise { } } +let ipcRegistered = false; + function registerIpcHandlers(): void { + if (ipcRegistered) return; + ipcRegistered = true; + ipcMain.handle(IPC_CHANNELS.ACCEPT_SUGGESTION, async (_event, text: string) => { await acceptAndInject(text); }); @@ -107,26 +112,39 @@ function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.GET_AUTOCOMPLETE_ENABLED, () => autocompleteEnabled); } -export function registerAutocomplete(): void { - registerIpcHandlers(); +function autocompleteHandler(): void { + const sw = getSuggestionWindow(); + if (sw && !sw.isDestroyed()) { + destroySuggestion(); + return; + } + triggerAutocomplete(); +} - const ok = globalShortcut.register(SHORTCUT, () => { - const sw = getSuggestionWindow(); - if (sw && !sw.isDestroyed()) { - destroySuggestion(); - return; - } - triggerAutocomplete(); - }); +async function registerShortcut(): Promise { + const shortcuts = await getShortcuts(); + currentShortcut = shortcuts.autocomplete; + + const ok = globalShortcut.register(currentShortcut, autocompleteHandler); if (!ok) { - console.error(`[autocomplete] Failed to register shortcut ${SHORTCUT}`); + console.error(`[autocomplete] Failed to register shortcut ${currentShortcut}`); } else { - console.log(`[autocomplete] Registered shortcut ${SHORTCUT}`); + console.log(`[autocomplete] Registered shortcut ${currentShortcut}`); } } +export async function registerAutocomplete(): Promise { + registerIpcHandlers(); + await registerShortcut(); +} + export function unregisterAutocomplete(): void { - globalShortcut.unregister(SHORTCUT); + if (currentShortcut) globalShortcut.unregister(currentShortcut); destroySuggestion(); } + +export async function reregisterAutocomplete(): Promise { + unregisterAutocomplete(); + await registerShortcut(); +} diff --git a/surfsense_desktop/src/modules/platform.ts b/surfsense_desktop/src/modules/platform.ts index 122e2efed..2b4d1f4a1 100644 --- a/surfsense_desktop/src/modules/platform.ts +++ b/surfsense_desktop/src/modules/platform.ts @@ -1,16 +1,20 @@ import { execSync } from 'child_process'; import { systemPreferences } from 'electron'; +const EXEC_OPTS = { windowsHide: true } as const; + export function getFrontmostApp(): string { try { if (process.platform === 'darwin') { return execSync( - 'osascript -e \'tell application "System Events" to get name of first application process whose frontmost is true\'' + 'osascript -e \'tell application "System Events" to get name of first application process whose frontmost is true\'', + EXEC_OPTS, ).toString().trim(); } if (process.platform === 'win32') { return execSync( - 'powershell -command "Add-Type \'using System; using System.Runtime.InteropServices; public class W { [DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow(); }\'; (Get-Process | Where-Object { $_.MainWindowHandle -eq [W]::GetForegroundWindow() }).ProcessName"' + 'powershell -NoProfile -NonInteractive -command "Add-Type \'using System; using System.Runtime.InteropServices; public class W { [DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow(); }\'; (Get-Process | Where-Object { $_.MainWindowHandle -eq [W]::GetForegroundWindow() }).ProcessName"', + EXEC_OPTS, ).toString().trim(); } } catch { @@ -21,9 +25,23 @@ export function getFrontmostApp(): string { export function simulatePaste(): void { if (process.platform === 'darwin') { - execSync('osascript -e \'tell application "System Events" to keystroke "v" using command down\''); + execSync('osascript -e \'tell application "System Events" to keystroke "v" using command down\'', EXEC_OPTS); } else if (process.platform === 'win32') { - execSync('powershell -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^v\')"'); + execSync('powershell -NoProfile -NonInteractive -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^v\')"', EXEC_OPTS); + } +} + +export function simulateCopy(): boolean { + try { + if (process.platform === 'darwin') { + execSync('osascript -e \'tell application "System Events" to keystroke "c" using command down\'', EXEC_OPTS); + } else if (process.platform === 'win32') { + execSync('powershell -NoProfile -NonInteractive -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^c\')"', EXEC_OPTS); + } + return true; + } catch (err) { + console.error('[simulateCopy] Failed:', err); + return false; } } @@ -36,12 +54,14 @@ export function getWindowTitle(): string { try { if (process.platform === 'darwin') { return execSync( - 'osascript -e \'tell application "System Events" to get title of front window of first application process whose frontmost is true\'' + 'osascript -e \'tell application "System Events" to get title of front window of first application process whose frontmost is true\'', + EXEC_OPTS, ).toString().trim(); } if (process.platform === 'win32') { return execSync( - 'powershell -command "(Get-Process | Where-Object { $_.MainWindowHandle -eq (Add-Type -MemberDefinition \'[DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow();\' -Name W -PassThru)::GetForegroundWindow() }).MainWindowTitle"' + 'powershell -NoProfile -NonInteractive -command "(Get-Process | Where-Object { $_.MainWindowHandle -eq (Add-Type -MemberDefinition \'[DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow();\' -Name W -PassThru)::GetForegroundWindow() }).MainWindowTitle"', + EXEC_OPTS, ).toString().trim(); } } catch { diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 52bfc6054..a015bfabf 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -1,10 +1,11 @@ import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron'; import path from 'path'; import { IPC_CHANNELS } from '../ipc/channels'; -import { checkAccessibilityPermission, getFrontmostApp, simulatePaste } from './platform'; +import { checkAccessibilityPermission, getFrontmostApp, simulateCopy, simulatePaste } from './platform'; import { getServerPort } from './server'; +import { getShortcuts } from './shortcuts'; -const SHORTCUT = 'CommandOrControl+Option+S'; +let currentShortcut = ''; let quickAskWindow: BrowserWindow | null = null; let pendingText = ''; let pendingMode = ''; @@ -77,29 +78,52 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { return quickAskWindow; } -export function registerQuickAsk(): void { - const ok = globalShortcut.register(SHORTCUT, () => { - if (quickAskWindow && !quickAskWindow.isDestroyed()) { - destroyQuickAsk(); - return; - } +function openQuickAsk(text: string): void { + pendingText = text; + const cursor = screen.getCursorScreenPoint(); + const pos = clampToScreen(cursor.x, cursor.y, 450, 750); + createQuickAskWindow(pos.x, pos.y); +} - sourceApp = getFrontmostApp(); - savedClipboard = clipboard.readText(); +async function quickAskHandler(): Promise { + console.log('[quick-ask] Handler triggered'); - const text = savedClipboard.trim(); - if (!text) return; - - pendingText = text; - const cursor = screen.getCursorScreenPoint(); - const pos = clampToScreen(cursor.x, cursor.y, 450, 750); - createQuickAskWindow(pos.x, pos.y); - }); - - if (!ok) { - console.log(`Quick-ask: failed to register ${SHORTCUT}`); + if (quickAskWindow && !quickAskWindow.isDestroyed()) { + console.log('[quick-ask] Window already open, closing'); + destroyQuickAsk(); + return; } + if (!checkAccessibilityPermission()) { + console.log('[quick-ask] Accessibility permission denied'); + return; + } + + savedClipboard = clipboard.readText(); + console.log('[quick-ask] Saved clipboard length:', savedClipboard.length); + + const copyOk = simulateCopy(); + console.log('[quick-ask] simulateCopy result:', copyOk); + + await new Promise((r) => setTimeout(r, 300)); + + const afterCopy = clipboard.readText(); + const selected = afterCopy.trim(); + console.log('[quick-ask] Clipboard after copy length:', afterCopy.length, 'changed:', afterCopy !== savedClipboard); + + const text = selected || savedClipboard.trim(); + + sourceApp = getFrontmostApp(); + console.log('[quick-ask] Source app:', sourceApp, '| Opening Quick Ask with', text.length, 'chars', selected ? '(selected)' : text ? '(clipboard fallback)' : '(empty)'); + openQuickAsk(text); +} + +let ipcRegistered = false; + +function registerIpcHandlers(): void { + if (ipcRegistered) return; + ipcRegistered = true; + ipcMain.handle(IPC_CHANNELS.QUICK_ASK_TEXT, () => { const text = pendingText; pendingText = ''; @@ -136,6 +160,24 @@ export function registerQuickAsk(): void { }); } -export function unregisterQuickAsk(): void { - globalShortcut.unregister(SHORTCUT); +async function registerShortcut(): Promise { + const shortcuts = await getShortcuts(); + currentShortcut = shortcuts.quickAsk; + + const ok = globalShortcut.register(currentShortcut, () => { quickAskHandler(); }); + console.log(`[quick-ask] Register ${currentShortcut}: ${ok ? 'OK' : 'FAILED'}`); +} + +export async function registerQuickAsk(): Promise { + registerIpcHandlers(); + await registerShortcut(); +} + +export function unregisterQuickAsk(): void { + if (currentShortcut) globalShortcut.unregister(currentShortcut); +} + +export async function reregisterQuickAsk(): Promise { + unregisterQuickAsk(); + await registerShortcut(); } diff --git a/surfsense_desktop/src/modules/shortcuts.ts b/surfsense_desktop/src/modules/shortcuts.ts new file mode 100644 index 000000000..8173b96c1 --- /dev/null +++ b/surfsense_desktop/src/modules/shortcuts.ts @@ -0,0 +1,42 @@ +export interface ShortcutConfig { + quickAsk: string; + autocomplete: string; +} + +const DEFAULTS: ShortcutConfig = { + quickAsk: 'CommandOrControl+Alt+S', + autocomplete: 'CommandOrControl+Shift+Space', +}; + +const STORE_KEY = 'shortcuts'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- lazily imported ESM module; matches folder-watcher.ts pattern +let store: any = null; + +async function getStore() { + if (!store) { + const { default: Store } = await import('electron-store'); + store = new Store({ + name: 'keyboard-shortcuts', + defaults: { [STORE_KEY]: DEFAULTS }, + }); + } + return store; +} + +export async function getShortcuts(): Promise { + const s = await getStore(); + const stored = s.get(STORE_KEY) as Partial | undefined; + return { ...DEFAULTS, ...stored }; +} + +export async function setShortcuts(config: Partial): Promise { + const s = await getStore(); + const current = (s.get(STORE_KEY) as ShortcutConfig) ?? DEFAULTS; + const merged = { ...current, ...config }; + s.set(STORE_KEY, merged); + return merged; +} + +export function getDefaults(): ShortcutConfig { + return { ...DEFAULTS }; +} diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index 2811c3b46..58ddd745e 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -73,4 +73,9 @@ contextBridge.exposeInMainWorld('electronAPI', { getAuthTokens: () => ipcRenderer.invoke(IPC_CHANNELS.GET_AUTH_TOKENS), setAuthTokens: (bearer: string, refresh: string) => ipcRenderer.invoke(IPC_CHANNELS.SET_AUTH_TOKENS, { bearer, refresh }), + + // Keyboard shortcut configuration + getShortcuts: () => ipcRenderer.invoke(IPC_CHANNELS.GET_SHORTCUTS), + setShortcuts: (config: Record) => + ipcRenderer.invoke(IPC_CHANNELS.SET_SHORTCUTS, config), }); 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 1522e153f..f83c3c9d4 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,30 +1,54 @@ "use client"; -import { useEffect, useState } from "react"; +import { Clipboard, Sparkles } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + DEFAULT_SHORTCUTS, + ShortcutRecorder, +} from "@/components/desktop/shortcut-recorder"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Spinner } from "@/components/ui/spinner"; +import { useElectronAPI } from "@/hooks/use-platform"; export function DesktopContent() { - const [isElectron, setIsElectron] = useState(false); + const api = useElectronAPI(); const [loading, setLoading] = useState(true); const [enabled, setEnabled] = useState(true); + const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS); + const [shortcutsLoaded, setShortcutsLoaded] = useState(false); + useEffect(() => { - if (!window.electronAPI) { + if (!api) { setLoading(false); + setShortcutsLoaded(true); return; } - setIsElectron(true); - window.electronAPI.getAutocompleteEnabled().then((val) => { - setEnabled(val); + let mounted = true; + + Promise.all([ + api.getAutocompleteEnabled(), + api.getShortcuts?.() ?? Promise.resolve(null), + ]).then(([autoEnabled, config]) => { + if (!mounted) return; + setEnabled(autoEnabled); + if (config) setShortcuts(config); setLoading(false); + setShortcutsLoaded(true); + }).catch(() => { + if (!mounted) return; + setLoading(false); + setShortcutsLoaded(true); }); - }, []); - if (!isElectron) { + return () => { mounted = false; }; + }, [api]); + + if (!api) { return (

@@ -44,11 +68,68 @@ export function DesktopContent() { const handleToggle = async (checked: boolean) => { setEnabled(checked); - await window.electronAPI!.setAutocompleteEnabled(checked); + await api.setAutocompleteEnabled(checked); + }; + + const updateShortcut = (key: "quickAsk" | "autocomplete", accelerator: string) => { + setShortcuts((prev) => { + const updated = { ...prev, [key]: accelerator }; + api.setShortcuts?.({ [key]: accelerator }).catch(() => { + toast.error("Failed to update shortcut"); + }); + return updated; + }); + toast.success("Shortcut updated"); + }; + + const resetShortcut = (key: "quickAsk" | "autocomplete") => { + updateShortcut(key, DEFAULT_SHORTCUTS[key]); }; return (

+ {/* Keyboard Shortcuts */} + + + Keyboard Shortcuts + + Customize the global keyboard shortcuts for desktop features. + + + + {shortcutsLoaded ? ( +
+ updateShortcut("quickAsk", accel)} + onReset={() => resetShortcut("quickAsk")} + defaultValue={DEFAULT_SHORTCUTS.quickAsk} + label="Quick Ask" + description="Copy selected text and ask AI about it" + icon={Clipboard} + /> + updateShortcut("autocomplete", accel)} + onReset={() => resetShortcut("autocomplete")} + defaultValue={DEFAULT_SHORTCUTS.autocomplete} + label="Autocomplete" + description="Get AI writing suggestions from a screenshot" + icon={Sparkles} + /> +

+ Click a shortcut and press a new key combination to change it. +

+
+ ) : ( +
+ +
+ )} +
+
+ + {/* Autocomplete Toggle */} Autocomplete diff --git a/surfsense_web/app/desktop/login/page.tsx b/surfsense_web/app/desktop/login/page.tsx new file mode 100644 index 000000000..529577b59 --- /dev/null +++ b/surfsense_web/app/desktop/login/page.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { IconBrandGoogleFilled } from "@tabler/icons-react"; +import { useAtom } from "jotai"; +import { + Eye, + EyeOff, + Keyboard, + Clipboard, + Sparkles, +} from "lucide-react"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; +import { + DEFAULT_SHORTCUTS, + ShortcutRecorder, +} from "@/components/desktop/shortcut-recorder"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { Spinner } from "@/components/ui/spinner"; +import { useElectronAPI } from "@/hooks/use-platform"; +import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config"; + +const isGoogleAuth = AUTH_TYPE === "GOOGLE"; + +export default function DesktopLoginPage() { + const router = useRouter(); + const api = useElectronAPI(); + const [{ mutateAsync: login, isPending: isLoggingIn }] = + useAtom(loginMutationAtom); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [loginError, setLoginError] = useState(null); + + const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS); + const [shortcutsLoaded, setShortcutsLoaded] = useState(false); + + useEffect(() => { + if (!api?.getShortcuts) { + setShortcutsLoaded(true); + return; + } + api.getShortcuts().then((config) => { + if (config) setShortcuts(config); + setShortcutsLoaded(true); + }).catch(() => setShortcutsLoaded(true)); + }, [api]); + + const updateShortcut = useCallback( + (key: "quickAsk" | "autocomplete", accelerator: string) => { + setShortcuts((prev) => { + const updated = { ...prev, [key]: accelerator }; + api?.setShortcuts?.({ [key]: accelerator }).catch(() => { + toast.error("Failed to update shortcut"); + }); + return updated; + }); + toast.success("Shortcut updated"); + }, + [api] + ); + + const resetShortcut = useCallback( + (key: "quickAsk" | "autocomplete") => { + updateShortcut(key, DEFAULT_SHORTCUTS[key]); + }, + [updateShortcut] + ); + + const handleGoogleLogin = () => { + window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`; + }; + + const handleLocalLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setLoginError(null); + + try { + const data = await login({ + username: email, + password, + grant_type: "password", + }); + + if (typeof window !== "undefined") { + sessionStorage.setItem("login_success_tracked", "true"); + } + + setTimeout(() => { + router.push(`/auth/callback?token=${data.access_token}`); + }, 300); + } catch (err) { + if (err instanceof Error) { + setLoginError(err.message); + } else { + setLoginError("Login failed. Please check your credentials."); + } + } + }; + + return ( +
+
+
+
+ + + + SurfSense + Welcome to SurfSense Desktop App + + Configure your shortcuts, then sign in to get started. + + + + + {/* ---- Shortcuts Section (first) ---- */} + {shortcutsLoaded ? ( +
+
+ + Keyboard Shortcuts +
+ updateShortcut("quickAsk", accel)} + onReset={() => resetShortcut("quickAsk")} + defaultValue={DEFAULT_SHORTCUTS.quickAsk} + label="Quick Ask" + description="Copy selected text and ask AI about it" + icon={Clipboard} + /> + updateShortcut("autocomplete", accel)} + onReset={() => resetShortcut("autocomplete")} + defaultValue={DEFAULT_SHORTCUTS.autocomplete} + label="Autocomplete" + description="Get AI writing suggestions from a screenshot" + icon={Sparkles} + /> +

+ Click a shortcut and press a new key combination to change it. +

+
+ ) : ( +
+ +
+ )} + + {/* ---- Divider ---- */} + + + {/* ---- Auth Section (second) ---- */} + {isGoogleAuth ? ( + + ) : ( +
+ {loginError && ( +
+ {loginError} +
+ )} + +
+ + setEmail(e.target.value)} + disabled={isLoggingIn} + autoFocus + /> +
+ +
+ +
+ setPassword(e.target.value)} + disabled={isLoggingIn} + className="pr-10" + /> + +
+
+ + +
+ )} +
+
+
+ ); +} diff --git a/surfsense_web/app/desktop/permissions/page.tsx b/surfsense_web/app/desktop/permissions/page.tsx index 6c08e35b5..178b6a533 100644 --- a/surfsense_web/app/desktop/permissions/page.tsx +++ b/surfsense_web/app/desktop/permissions/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { Logo } from "@/components/Logo"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; +import { useElectronAPI } from "@/hooks/use-platform"; type PermissionStatus = "authorized" | "denied" | "not determined" | "restricted" | "limited"; @@ -57,19 +58,18 @@ function StatusBadge({ status }: { status: PermissionStatus }) { export default function DesktopPermissionsPage() { const router = useRouter(); + const api = useElectronAPI(); const [permissions, setPermissions] = useState(null); - const [isElectron, setIsElectron] = useState(false); useEffect(() => { - if (!window.electronAPI) return; - setIsElectron(true); + if (!api) return; let interval: ReturnType | null = null; const isResolved = (s: string) => s === "authorized" || s === "restricted"; const poll = async () => { - const status = await window.electronAPI!.getPermissionsStatus(); + const status = await api.getPermissionsStatus(); setPermissions(status); if (isResolved(status.accessibility) && isResolved(status.screenRecording)) { @@ -80,9 +80,9 @@ export default function DesktopPermissionsPage() { poll(); interval = setInterval(poll, 2000); return () => { if (interval) clearInterval(interval); }; - }, []); + }, [api]); - if (!isElectron) { + if (!api) { return (

This page is only available in the desktop app.

@@ -102,15 +102,15 @@ export default function DesktopPermissionsPage() { const handleRequest = async (action: string) => { if (action === "requestScreenRecording") { - await window.electronAPI!.requestScreenRecording(); + await api.requestScreenRecording(); } else if (action === "requestAccessibility") { - await window.electronAPI!.requestAccessibility(); + await api.requestAccessibility(); } }; const handleContinue = () => { if (allGranted) { - window.electronAPI!.restartApp(); + api.restartApp(); } }; diff --git a/surfsense_web/app/desktop/suggestion/page.tsx b/surfsense_web/app/desktop/suggestion/page.tsx index 097047bb1..fb83e2113 100644 --- a/surfsense_web/app/desktop/suggestion/page.tsx +++ b/surfsense_web/app/desktop/suggestion/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useElectronAPI } from "@/hooks/use-platform"; import { getBearerToken, ensureTokensFromElectron } from "@/lib/auth-utils"; type SSEEvent = @@ -34,26 +35,27 @@ function friendlyError(raw: string | number): string { const AUTO_DISMISS_MS = 3000; export default function SuggestionPage() { + const api = useElectronAPI(); const [suggestion, setSuggestion] = useState(""); const [isLoading, setIsLoading] = useState(true); - const [isDesktop, setIsDesktop] = useState(true); const [error, setError] = useState(null); const abortRef = useRef(null); + const isDesktop = !!api?.onAutocompleteContext; + useEffect(() => { - if (!window.electronAPI?.onAutocompleteContext) { - setIsDesktop(false); + if (!api?.onAutocompleteContext) { setIsLoading(false); } - }, []); + }, [api]); useEffect(() => { if (!error) return; const timer = setTimeout(() => { - window.electronAPI?.dismissSuggestion?.(); + api?.dismissSuggestion?.(); }, AUTO_DISMISS_MS); return () => clearTimeout(timer); - }, [error]); + }, [error, api]); const fetchSuggestion = useCallback( async (screenshot: string, searchSpaceId: string, appName?: string, windowTitle?: string) => { @@ -153,9 +155,9 @@ export default function SuggestionPage() { ); useEffect(() => { - if (!window.electronAPI?.onAutocompleteContext) return; + if (!api?.onAutocompleteContext) return; - const cleanup = window.electronAPI.onAutocompleteContext((data) => { + const cleanup = api.onAutocompleteContext((data) => { const searchSpaceId = data.searchSpaceId || "1"; if (data.screenshot) { fetchSuggestion(data.screenshot, searchSpaceId, data.appName, data.windowTitle); @@ -163,7 +165,7 @@ export default function SuggestionPage() { }); return cleanup; - }, [fetchSuggestion]); + }, [fetchSuggestion, api]); if (!isDesktop) { return ( @@ -197,12 +199,12 @@ export default function SuggestionPage() { const handleAccept = () => { if (suggestion) { - window.electronAPI?.acceptSuggestion?.(suggestion); + api?.acceptSuggestion?.(suggestion); } }; const handleDismiss = () => { - window.electronAPI?.dismissSuggestion?.(); + api?.dismissSuggestion?.(); }; if (!suggestion) return null; diff --git a/surfsense_web/app/layout.tsx b/surfsense_web/app/layout.tsx index 784fd3bcf..8ebb0e848 100644 --- a/surfsense_web/app/layout.tsx +++ b/surfsense_web/app/layout.tsx @@ -10,6 +10,7 @@ import { ZeroProvider } from "@/components/providers/ZeroProvider"; import { ThemeProvider } from "@/components/theme/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { LocaleProvider } from "@/contexts/LocaleContext"; +import { PlatformProvider } from "@/contexts/platform-context"; import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider"; import { cn } from "@/lib/utils"; @@ -139,15 +140,17 @@ export default function RootLayout({ disableTransitionOnChange defaultTheme="system" > - - - - {children} - - - - - + + + + + {children} + + + + + + diff --git a/surfsense_web/components/UserDropdown.tsx b/surfsense_web/components/UserDropdown.tsx index 197db6287..19dceb06b 100644 --- a/surfsense_web/components/UserDropdown.tsx +++ b/surfsense_web/components/UserDropdown.tsx @@ -15,7 +15,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; -import { logout } from "@/lib/auth-utils"; +import { getLoginPath, logout } from "@/lib/auth-utils"; import { resetUser, trackLogout } from "@/lib/posthog/events"; export function UserDropdown({ @@ -33,22 +33,19 @@ export function UserDropdown({ if (isLoggingOut) return; setIsLoggingOut(true); try { - // Track logout event and reset PostHog identity trackLogout(); resetUser(); - // Revoke refresh token on server and clear all tokens from localStorage await logout(); if (typeof window !== "undefined") { - window.location.href = "/"; + window.location.href = getLoginPath(); } } catch (error) { console.error("Error during logout:", error); - // Even if there's an error, try to clear tokens and redirect await logout(); if (typeof window !== "undefined") { - window.location.href = "/"; + window.location.href = getLoginPath(); } } }; diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 0dcaf6350..d0cada0bd 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -87,6 +87,7 @@ import { } from "@/components/ui/drawer"; import { useComments } from "@/hooks/use-comments"; import { useMediaQuery } from "@/hooks/use-media-query"; +import { useElectronAPI } from "@/hooks/use-platform"; import { cn } from "@/lib/utils"; // Dynamically import video presentation tool to avoid loading Babel and Remotion in main bundle @@ -463,16 +464,17 @@ export const AssistantMessage: FC = () => { const AssistantActionBar: FC = () => { const isLast = useAuiState((s) => s.message.isLast); const aui = useAui(); + const api = useElectronAPI(); const [quickAskMode, setQuickAskMode] = useState(""); useEffect(() => { - if (!isLast || !window.electronAPI?.getQuickAskMode) return; - window.electronAPI.getQuickAskMode().then((mode) => { + if (!isLast || !api?.getQuickAskMode) return; + api.getQuickAskMode().then((mode) => { if (mode) setQuickAskMode(mode); }); - }, [isLast]); + }, [isLast, api]); - const isTransform = isLast && !!window.electronAPI?.replaceText && quickAskMode === "transform"; + const isTransform = isLast && !!api?.replaceText && quickAskMode === "transform"; return ( { type="button" onClick={() => { const text = aui.message().getCopyText(); - window.electronAPI?.replaceText(text); + api?.replaceText(text); }} className="ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90" > diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx index 3e8aad620..4a97863fb 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx @@ -3,6 +3,7 @@ import type { FC } from "react"; import { EnumConnectorName } from "@/contracts/enums/connector"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { usePlatform } from "@/hooks/use-platform"; import { isSelfHosted } from "@/lib/env-config"; import { ConnectorCard } from "../components/connector-card"; import { @@ -74,9 +75,8 @@ export const AllConnectorsTab: FC = ({ onManage, onViewAccountsList, }) => { - // Check if self-hosted mode (for showing self-hosted only connectors) const selfHosted = isSelfHosted(); - const isDesktop = typeof window !== "undefined" && !!window.electronAPI; + const { isDesktop } = usePlatform(); const matchesSearch = (title: string, description: string) => title.toLowerCase().includes(searchQuery.toLowerCase()) || diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index af7a8397c..2d55f4d20 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -24,6 +24,7 @@ export interface MentionedDocument { export interface InlineMentionEditorRef { focus: () => void; clear: () => void; + setText: (text: string) => void; getText: () => string; getMentionedDocuments: () => MentionedDocument[]; insertDocumentChip: (doc: Pick) => void; @@ -397,6 +398,19 @@ export const InlineMentionEditor = forwardRef { + if (!editorRef.current) return; + editorRef.current.innerText = text; + const empty = text.length === 0; + setIsEmpty(empty); + onChange?.(text, Array.from(mentionedDocs.values())); + focusAtEnd(); + }, + [focusAtEnd, onChange, mentionedDocs] + ); + const setDocumentChipStatus = useCallback( ( docId: number, @@ -469,6 +483,7 @@ export const InlineMentionEditor = forwardRef ({ focus: () => editorRef.current?.focus(), clear, + setText, getText, getMentionedDocuments, insertDocumentChip, diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 597fcce39..7d8765399 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -89,6 +89,7 @@ import type { Document } from "@/contracts/types/document.types"; import { useBatchCommentsPreload } from "@/hooks/use-comments"; import { useCommentsSync } from "@/hooks/use-comments-sync"; import { useMediaQuery } from "@/hooks/use-media-query"; +import { useElectronAPI } from "@/hooks/use-platform"; import { cn } from "@/lib/utils"; /** Placeholder texts that cycle in new chats when input is empty */ @@ -362,18 +363,19 @@ const Composer: FC = () => { }; }, []); + const electronAPI = useElectronAPI(); const [clipboardInitialText, setClipboardInitialText] = useState(); const clipboardLoadedRef = useRef(false); useEffect(() => { - if (!window.electronAPI || clipboardLoadedRef.current) return; + if (!electronAPI || clipboardLoadedRef.current) return; clipboardLoadedRef.current = true; - window.electronAPI.getQuickAskText().then((text) => { + electronAPI.getQuickAskText().then((text) => { if (text) { setClipboardInitialText(text); setShowPromptPicker(true); } }); - }, []); + }, [electronAPI]); const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty); const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); @@ -504,34 +506,28 @@ const Composer: FC = () => { : userText ? `${action.prompt}\n\n${userText}` : action.prompt; + editorRef.current?.setText(finalPrompt); aui.composer().setText(finalPrompt); - aui.composer().send(); - editorRef.current?.clear(); setShowPromptPicker(false); setActionQuery(""); - setMentionedDocuments([]); - setSidebarDocs([]); }, - [actionQuery, aui, setMentionedDocuments, setSidebarDocs] + [actionQuery, aui] ); const handleQuickAskSelect = useCallback( (action: { name: string; prompt: string; mode: "transform" | "explore" }) => { if (!clipboardInitialText) return; - window.electronAPI?.setQuickAskMode(action.mode); + electronAPI?.setQuickAskMode(action.mode); const finalPrompt = action.prompt.includes("{selection}") ? action.prompt.replace("{selection}", () => clipboardInitialText) : `${action.prompt}\n\n${clipboardInitialText}`; + editorRef.current?.setText(finalPrompt); aui.composer().setText(finalPrompt); - aui.composer().send(); - editorRef.current?.clear(); setShowPromptPicker(false); setActionQuery(""); setClipboardInitialText(undefined); - setMentionedDocuments([]); - setSidebarDocs([]); }, - [clipboardInitialText, aui, setMentionedDocuments, setSidebarDocs] + [clipboardInitialText, electronAPI, aui] ); // Keyboard navigation for document/action picker (arrow keys, Enter, Escape) diff --git a/surfsense_web/components/desktop/shortcut-recorder.tsx b/surfsense_web/components/desktop/shortcut-recorder.tsx new file mode 100644 index 000000000..0c0012002 --- /dev/null +++ b/surfsense_web/components/desktop/shortcut-recorder.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { RotateCcw } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +// --------------------------------------------------------------------------- +// Accelerator ↔ display helpers +// --------------------------------------------------------------------------- + +export function keyEventToAccelerator(e: React.KeyboardEvent): string | null { + const parts: string[] = []; + if (e.ctrlKey || e.metaKey) parts.push("CommandOrControl"); + if (e.altKey) parts.push("Alt"); + if (e.shiftKey) parts.push("Shift"); + + const key = e.key; + if (["Control", "Meta", "Alt", "Shift"].includes(key)) return null; + + if (key === " ") parts.push("Space"); + else if (key.length === 1) parts.push(key.toUpperCase()); + else parts.push(key); + + if (parts.length < 2) return null; + return parts.join("+"); +} + +export function acceleratorToDisplay(accel: string): string[] { + if (!accel) return []; + return accel.split("+").map((part) => { + if (part === "CommandOrControl") return "Ctrl"; + if (part === "Space") return "Space"; + return part; + }); +} + +export const DEFAULT_SHORTCUTS = { + quickAsk: "CommandOrControl+Alt+S", + autocomplete: "CommandOrControl+Shift+Space", +}; + +// --------------------------------------------------------------------------- +// Kbd pill component +// --------------------------------------------------------------------------- + +export function Kbd({ + keys, + className, +}: { + keys: string[]; + className?: string; +}) { + return ( + + {keys.map((key) => ( + 3 && "px-2" + )} + > + {key} + + ))} + + ); +} + +// --------------------------------------------------------------------------- +// Shortcut recorder component +// --------------------------------------------------------------------------- + +export function ShortcutRecorder({ + value, + onChange, + onReset, + defaultValue, + label, + description, + icon: Icon, +}: { + value: string; + onChange: (accelerator: string) => void; + onReset: () => void; + defaultValue: string; + label: string; + description: string; + icon: React.ElementType; +}) { + const [recording, setRecording] = useState(false); + const inputRef = useRef(null); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!recording) return; + e.preventDefault(); + e.stopPropagation(); + + if (e.key === "Escape") { + setRecording(false); + return; + } + + const accel = keyEventToAccelerator(e); + if (accel) { + onChange(accel); + setRecording(false); + } + }, + [recording, onChange] + ); + + const displayKeys = acceleratorToDisplay(value); + const isDefault = value === defaultValue; + + return ( +
+
+
+ +
+
+

{label}

+

+ {description} +

+
+
+ +
+ {!isDefault && ( + + )} + +
+
+ ); +} diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 6138b67fb..380ffa656 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -55,7 +55,7 @@ import { useInbox } from "@/hooks/use-inbox"; import { useIsMobile } from "@/hooks/use-mobile"; import { notificationsApiService } from "@/lib/apis/notifications-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; -import { logout } from "@/lib/auth-utils"; +import { getLoginPath, logout } from "@/lib/auth-utils"; import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence"; import { resetUser, trackLogout } from "@/lib/posthog/events"; import { cacheKeys } from "@/lib/query-client/cache-keys"; @@ -600,12 +600,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid await logout(); if (typeof window !== "undefined") { - router.push("/"); + router.push(getLoginPath()); } } catch (error) { console.error("Error during logout:", error); await logout(); - router.push("/"); + router.push(getLoginPath()); } }, [router]); diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index aa409e179..f19b20971 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -41,6 +41,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useMediaQuery } from "@/hooks/use-media-query"; +import { useElectronAPI } from "@/hooks/use-platform"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { foldersApiService } from "@/lib/apis/folders-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; @@ -84,6 +85,7 @@ export function DocumentsSidebar({ const tSidebar = useTranslations("sidebar"); const params = useParams(); const isMobile = !useMediaQuery("(min-width: 640px)"); + const electronAPI = useElectronAPI(); const searchSpaceId = Number(params.search_space_id); const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom); const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom); @@ -97,11 +99,11 @@ export function DocumentsSidebar({ const [watchedFolderIds, setWatchedFolderIds] = useState>(new Set()); useEffect(() => { - const api = typeof window !== "undefined" ? window.electronAPI : null; - if (!api?.getWatchedFolders) return; + if (!electronAPI?.getWatchedFolders) return; + const api = electronAPI; async function loadWatchedIds() { - const folders = await api!.getWatchedFolders(); + const folders = await api.getWatchedFolders(); if (folders.length === 0) { try { @@ -109,7 +111,7 @@ export function DocumentsSidebar({ for (const bf of backendFolders) { const meta = bf.metadata as Record | null; if (!meta?.watched || !meta.folder_path) continue; - await api!.addWatchedFolder({ + await api.addWatchedFolder({ path: meta.folder_path as string, name: bf.name, rootFolderId: bf.id, @@ -119,7 +121,7 @@ export function DocumentsSidebar({ active: true, }); } - const recovered = await api!.getWatchedFolders(); + const recovered = await api.getWatchedFolders(); const ids = new Set( recovered.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number) ); @@ -137,7 +139,7 @@ export function DocumentsSidebar({ } loadWatchedIds(); - }, [searchSpaceId]); + }, [searchSpaceId, electronAPI]); const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom); @@ -276,10 +278,9 @@ export function DocumentsSidebar({ const handleRescanFolder = useCallback( async (folder: FolderDisplay) => { - const api = window.electronAPI; - if (!api) return; + if (!electronAPI) return; - const watchedFolders = await api.getWatchedFolders(); + const watchedFolders = await electronAPI.getWatchedFolders(); const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id); if (!matched) { toast.error("This folder is not being watched"); @@ -298,28 +299,27 @@ export function DocumentsSidebar({ toast.error((err as Error)?.message || "Failed to re-scan folder"); } }, - [searchSpaceId] + [searchSpaceId, electronAPI] ); const handleStopWatching = useCallback(async (folder: FolderDisplay) => { - const api = window.electronAPI; - if (!api) return; + if (!electronAPI) return; - const watchedFolders = await api.getWatchedFolders(); + const watchedFolders = await electronAPI.getWatchedFolders(); const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id); if (!matched) { toast.error("This folder is not being watched"); return; } - await api.removeWatchedFolder(matched.path); + await electronAPI.removeWatchedFolder(matched.path); try { await foldersApiService.stopWatching(folder.id); } catch (err) { console.error("[DocumentsSidebar] Failed to clear watched metadata:", err); } toast.success(`Stopped watching: ${matched.name}`); - }, []); + }, [electronAPI]); const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => { try { @@ -333,12 +333,11 @@ export function DocumentsSidebar({ const handleDeleteFolder = useCallback(async (folder: FolderDisplay) => { if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return; try { - const api = window.electronAPI; - if (api) { - const watchedFolders = await api.getWatchedFolders(); + if (electronAPI) { + const watchedFolders = await electronAPI.getWatchedFolders(); const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id); if (matched) { - await api.removeWatchedFolder(matched.path); + await electronAPI.removeWatchedFolder(matched.path); } } await foldersApiService.deleteFolder(folder.id); @@ -346,7 +345,7 @@ export function DocumentsSidebar({ } catch (e: unknown) { toast.error((e as Error)?.message || "Failed to delete folder"); } - }, []); + }, [electronAPI]); const handleMoveFolder = useCallback( (folder: FolderDisplay) => { diff --git a/surfsense_web/components/platform-gate.tsx b/surfsense_web/components/platform-gate.tsx new file mode 100644 index 000000000..6908c6d32 --- /dev/null +++ b/surfsense_web/components/platform-gate.tsx @@ -0,0 +1,16 @@ +"use client"; + +import type { ReactNode } from "react"; +import { usePlatform } from "@/hooks/use-platform"; + +export function DesktopOnly({ children }: { children: ReactNode }) { + const { isDesktop } = usePlatform(); + if (!isDesktop) return null; + return <>{children}; +} + +export function WebOnly({ children }: { children: ReactNode }) { + const { isWeb } = usePlatform(); + if (!isWeb) return null; + return <>{children}; +} diff --git a/surfsense_web/components/settings/user-settings-dialog.tsx b/surfsense_web/components/settings/user-settings-dialog.tsx index b74ff973b..919b08174 100644 --- a/surfsense_web/components/settings/user-settings-dialog.tsx +++ b/surfsense_web/components/settings/user-settings-dialog.tsx @@ -3,6 +3,7 @@ import { useAtom } from "jotai"; import { Globe, KeyRound, Monitor, Receipt, Sparkles, User } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useMemo } from "react"; import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent"; import { CommunityPromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent"; import { ProfileContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent"; @@ -11,37 +12,42 @@ import { PurchaseHistoryContent } from "@/app/dashboard/[search_space_id]/user-s import { DesktopContent } from "@/app/dashboard/[search_space_id]/user-settings/components/DesktopContent"; import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; import { SettingsDialog } from "@/components/settings/settings-dialog"; +import { usePlatform } from "@/hooks/use-platform"; export function UserSettingsDialog() { const t = useTranslations("userSettings"); const [state, setState] = useAtom(userSettingsDialogAtom); + const { isDesktop } = usePlatform(); - const navItems = [ - { value: "profile", label: t("profile_nav_label"), icon: }, - { - value: "api-key", - label: t("api_key_nav_label"), - icon: , - }, - { - value: "prompts", - label: "My Prompts", - icon: , - }, - { - value: "community-prompts", - label: "Community Prompts", - icon: , - }, - { - value: "purchases", - label: "Purchase History", - icon: , - }, - ...(typeof window !== "undefined" && window.electronAPI - ? [{ value: "desktop", label: "Desktop", icon: }] - : []), - ]; + const navItems = useMemo( + () => [ + { value: "profile", label: t("profile_nav_label"), icon: }, + { + value: "api-key", + label: t("api_key_nav_label"), + icon: , + }, + { + value: "prompts", + label: "My Prompts", + icon: , + }, + { + value: "community-prompts", + label: "Community Prompts", + icon: , + }, + { + value: "purchases", + label: "Purchase History", + icon: , + }, + ...(isDesktop + ? [{ value: "desktop", label: "Desktop", icon: }] + : []), + ], + [t, isDesktop] + ); return ( (null); const [watchFolder, setWatchFolder] = useState(true); const [folderSubmitting, setFolderSubmitting] = useState(false); - const isElectron = typeof window !== "undefined" && !!window.electronAPI?.browseFiles; + const isElectron = !!electronAPI?.browseFiles; const acceptedFileTypes = useMemo(() => { const etlService = process.env.NEXT_PUBLIC_ETL_SERVICE; @@ -216,33 +218,31 @@ export function DocumentUploadTab({ }, []); const handleBrowseFiles = useCallback(async () => { - const api = window.electronAPI; - if (!api?.browseFiles) return; + if (!electronAPI?.browseFiles) return; - const paths = await api.browseFiles(); + const paths = await electronAPI.browseFiles(); if (!paths || paths.length === 0) return; setSelectedFolder(null); - const fileDataList = await api.readLocalFiles(paths); + const fileDataList = await electronAPI.readLocalFiles(paths); const newFiles: FileWithId[] = fileDataList.map((fd) => ({ id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`, file: new File([fd.data], fd.name, { type: fd.mimeType }), })); setFiles((prev) => [...prev, ...newFiles]); - }, []); + }, [electronAPI]); const handleBrowseFolder = useCallback(async () => { - const api = window.electronAPI; - if (!api?.selectFolder) return; + if (!electronAPI?.selectFolder) return; - const folderPath = await api.selectFolder(); + const folderPath = await electronAPI.selectFolder(); if (!folderPath) return; const folderName = folderPath.split("/").pop() || folderPath.split("\\").pop() || folderPath; setFiles([]); setSelectedFolder({ path: folderPath, name: folderName }); setWatchFolder(true); - }, []); + }, [electronAPI]); const handleFolderChange = useCallback( (e: ChangeEvent) => { @@ -287,9 +287,7 @@ export function DocumentUploadTab({ ); const handleFolderSubmit = useCallback(async () => { - if (!selectedFolder) return; - const api = window.electronAPI; - if (!api) return; + if (!selectedFolder || !electronAPI) return; setFolderSubmitting(true); try { @@ -304,7 +302,7 @@ export function DocumentUploadTab({ const rootFolderId = (result as { root_folder_id?: number })?.root_folder_id ?? null; if (watchFolder) { - await api.addWatchedFolder({ + await electronAPI.addWatchedFolder({ path: selectedFolder.path, name: selectedFolder.name, excludePatterns: [ @@ -332,7 +330,7 @@ export function DocumentUploadTab({ } finally { setFolderSubmitting(false); } - }, [selectedFolder, watchFolder, searchSpaceId, shouldSummarize, onSuccess]); + }, [selectedFolder, watchFolder, searchSpaceId, shouldSummarize, onSuccess, electronAPI]); const handleUpload = async () => { setUploadProgress(0); diff --git a/surfsense_web/contexts/platform-context.tsx b/surfsense_web/contexts/platform-context.tsx new file mode 100644 index 000000000..bb3e3800d --- /dev/null +++ b/surfsense_web/contexts/platform-context.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { createContext, useEffect, useState, type ReactNode } from "react"; + +export interface PlatformContextValue { + isDesktop: boolean; + isWeb: boolean; + electronAPI: ElectronAPI | null; +} + +const SSR_VALUE: PlatformContextValue = { + isDesktop: false, + isWeb: false, + electronAPI: null, +}; + +export const PlatformContext = createContext(SSR_VALUE); + +export function PlatformProvider({ children }: { children: ReactNode }) { + const [value, setValue] = useState(SSR_VALUE); + + useEffect(() => { + const api = window.electronAPI ?? null; + const isDesktop = !!api; + setValue({ isDesktop, isWeb: !isDesktop, electronAPI: api }); + }, []); + + return ( + {children} + ); +} diff --git a/surfsense_web/hooks/use-folder-sync.ts b/surfsense_web/hooks/use-folder-sync.ts index ef3326556..847d0081b 100644 --- a/surfsense_web/hooks/use-folder-sync.ts +++ b/surfsense_web/hooks/use-folder-sync.ts @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef } from "react"; +import { useElectronAPI } from "@/hooks/use-platform"; import { documentsApiService } from "@/lib/apis/documents-api.service"; interface FileChangedEvent { @@ -29,6 +30,7 @@ interface BatchItem { } export function useFolderSync() { + const electronAPI = useElectronAPI(); const queueRef = useRef([]); const processingRef = useRef(false); const debounceTimers = useRef>>(new Map()); @@ -49,9 +51,8 @@ export function useFolderSync() { target_file_paths: batch.filePaths, root_folder_id: batch.rootFolderId, }); - const api = typeof window !== "undefined" ? window.electronAPI : null; - if (api?.acknowledgeFileEvents && batch.ackIds.length > 0) { - await api.acknowledgeFileEvents(batch.ackIds); + if (electronAPI?.acknowledgeFileEvents && batch.ackIds.length > 0) { + await electronAPI.acknowledgeFileEvents(batch.ackIds); } } catch (err) { console.error("[FolderSync] Failed to trigger batch re-index:", err); @@ -117,25 +118,22 @@ export function useFolderSync() { useEffect(() => { isMountedRef.current = true; - const api = typeof window !== "undefined" ? window.electronAPI : null; - if (!api?.onFileChanged) { + if (!electronAPI?.onFileChanged) { return () => { isMountedRef.current = false; }; } - // Signal to main process that the renderer is ready to receive events - api.signalRendererReady?.(); + electronAPI.signalRendererReady?.(); - // Drain durable outbox first so events survive renderer startup gaps and restarts - void api.getPendingFileEvents?.().then((pendingEvents) => { + void electronAPI.getPendingFileEvents?.().then((pendingEvents) => { if (!isMountedRef.current || !pendingEvents?.length) return; for (const event of pendingEvents) { enqueueWithDebounce(event); } }); - const cleanup = api.onFileChanged((event: FileChangedEvent) => { + const cleanup = electronAPI.onFileChanged((event: FileChangedEvent) => { enqueueWithDebounce(event); }); @@ -149,5 +147,5 @@ export function useFolderSync() { pendingByFolder.current.clear(); firstEventTime.current.clear(); }; - }, []); + }, [electronAPI]); } diff --git a/surfsense_web/hooks/use-platform.ts b/surfsense_web/hooks/use-platform.ts new file mode 100644 index 000000000..dc1f7e914 --- /dev/null +++ b/surfsense_web/hooks/use-platform.ts @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import { PlatformContext, type PlatformContextValue } from "@/contexts/platform-context"; + +export function usePlatform(): Pick { + const { isDesktop, isWeb } = useContext(PlatformContext); + return { isDesktop, isWeb }; +} + +export function useElectronAPI(): ElectronAPI | null { + const { electronAPI } = useContext(PlatformContext); + return electronAPI; +} diff --git a/surfsense_web/lib/auth-utils.ts b/surfsense_web/lib/auth-utils.ts index f7d1c5b09..d66934c3b 100644 --- a/surfsense_web/lib/auth-utils.ts +++ b/surfsense_web/lib/auth-utils.ts @@ -15,6 +15,7 @@ const PUBLIC_ROUTE_PREFIXES = [ "/login", "/register", "/auth", + "/desktop/login", "/docs", "/public", "/invite", @@ -34,6 +35,11 @@ export function isPublicRoute(pathname: string): boolean { return PUBLIC_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix)); } +export function getLoginPath(): string { + if (typeof window !== "undefined" && window.electronAPI) return "/desktop/login"; + return "/login"; +} + /** * Clears tokens and optionally redirects to login. * Call this when a 401 response is received. @@ -55,7 +61,7 @@ export function handleUnauthorized(): void { if (!excludedPaths.includes(pathname)) { localStorage.setItem(REDIRECT_PATH_KEY, currentPath); } - window.location.href = "/login"; + window.location.href = getLoginPath(); } } @@ -221,13 +227,12 @@ export function redirectToLogin(): void { const currentPath = window.location.pathname + window.location.search + window.location.hash; // Don't save auth-related paths or home page - const excludedPaths = ["/auth", "/auth/callback", "/", "/login", "/register"]; + const excludedPaths = ["/auth", "/auth/callback", "/", "/login", "/register", "/desktop/login"]; if (!excludedPaths.includes(window.location.pathname)) { localStorage.setItem(REDIRECT_PATH_KEY, currentPath); } - // Redirect to login page - window.location.href = "/login"; + window.location.href = getLoginPath(); } /** diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index 5e45635a2..615b861ea 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -81,6 +81,9 @@ interface ElectronAPI { // Auth token sync across windows getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>; setAuthTokens: (bearer: string, refresh: string) => Promise; + // Keyboard shortcut configuration + getShortcuts: () => Promise<{ quickAsk: string; autocomplete: string }>; + setShortcuts: (config: Partial<{ quickAsk: string; autocomplete: string }>) => Promise<{ quickAsk: string; autocomplete: string }>; } declare global {