diff --git a/surfsense_desktop/electron-builder.yml b/surfsense_desktop/electron-builder.yml index 4d6f0b283..2c46c827a 100644 --- a/surfsense_desktop/electron-builder.yml +++ b/surfsense_desktop/electron-builder.yml @@ -19,6 +19,9 @@ files: - "!scripts" - "!release" extraResources: + - from: assets/ + to: assets/ + filter: ["*.ico", "*.png", "*.icns"] - from: ../surfsense_web/.next/standalone/surfsense_web/ to: standalone/ filter: diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index 7872e7a42..a583e5afc 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -23,6 +23,7 @@ import { import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts'; import { reregisterQuickAsk } from '../modules/quick-ask'; import { reregisterAutocomplete } from '../modules/autocomplete'; +import { reregisterGeneralAssist } from '../modules/tray'; let authTokens: { bearer: string; refresh: string } | null = null; @@ -107,6 +108,7 @@ export function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.SET_SHORTCUTS, async (_event, config: Partial) => { const updated = await setShortcuts(config); + if (config.generalAssist) await reregisterGeneralAssist(); 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 9eae8a4db..95b0359c8 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,7 +1,9 @@ import { app, BrowserWindow } from 'electron'; + +let isQuitting = false; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; import { startNextServer } from './modules/server'; -import { createMainWindow } from './modules/window'; +import { createMainWindow, getMainWindow } from './modules/window'; import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; import { setupAutoUpdater } from './modules/auto-updater'; import { setupMenu } from './modules/menu'; @@ -9,6 +11,7 @@ import { registerQuickAsk, unregisterQuickAsk } from './modules/quick-ask'; import { registerAutocomplete, unregisterAutocomplete } from './modules/autocomplete'; import { registerFolderWatcher, unregisterFolderWatcher } from './modules/folder-watcher'; import { registerIpcHandlers } from './ipc/handlers'; +import { createTray, destroyTray } from './modules/tray'; registerGlobalErrorHandlers(); @@ -28,7 +31,18 @@ app.whenReady().then(async () => { return; } - createMainWindow('/dashboard'); + await createTray(); + + const win = createMainWindow('/dashboard'); + + // Minimize to tray instead of closing the app + win.on('close', (e) => { + if (!isQuitting) { + e.preventDefault(); + win.hide(); + } + }); + await registerQuickAsk(); await registerAutocomplete(); registerFolderWatcher(); @@ -37,20 +51,28 @@ app.whenReady().then(async () => { handlePendingDeepLink(); app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { + const mw = getMainWindow(); + if (!mw || mw.isDestroyed()) { createMainWindow('/dashboard'); + } else { + mw.show(); + mw.focus(); } }); }); +// Keep running in the background — the tray "Quit" calls app.exit() app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } + // Do nothing: the app stays alive in the tray +}); + +app.on('before-quit', () => { + isQuitting = true; }); app.on('will-quit', () => { unregisterQuickAsk(); unregisterAutocomplete(); unregisterFolderWatcher(); + destroyTray(); }); diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index a015bfabf..224444be6 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -114,7 +114,7 @@ async function quickAskHandler(): Promise { 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)'); + console.log('[quick-ask] Source app:', sourceApp, '| Opening Quick Assist with', text.length, 'chars', selected ? '(selected)' : text ? '(clipboard fallback)' : '(empty)'); openQuickAsk(text); } diff --git a/surfsense_desktop/src/modules/shortcuts.ts b/surfsense_desktop/src/modules/shortcuts.ts index 8173b96c1..6948a005e 100644 --- a/surfsense_desktop/src/modules/shortcuts.ts +++ b/surfsense_desktop/src/modules/shortcuts.ts @@ -1,9 +1,11 @@ export interface ShortcutConfig { + generalAssist: string; quickAsk: string; autocomplete: string; } const DEFAULTS: ShortcutConfig = { + generalAssist: 'CommandOrControl+Shift+S', quickAsk: 'CommandOrControl+Alt+S', autocomplete: 'CommandOrControl+Shift+Space', }; diff --git a/surfsense_desktop/src/modules/tray.ts b/surfsense_desktop/src/modules/tray.ts new file mode 100644 index 000000000..1749145a1 --- /dev/null +++ b/surfsense_desktop/src/modules/tray.ts @@ -0,0 +1,77 @@ +import { app, globalShortcut, Menu, nativeImage, Tray } from 'electron'; +import path from 'path'; +import { getMainWindow, createMainWindow } from './window'; +import { getShortcuts } from './shortcuts'; + +let tray: Tray | null = null; +let currentShortcut: string | null = null; + +function getTrayIcon(): nativeImage { + const iconName = process.platform === 'win32' ? 'icon.ico' : 'icon.png'; + const iconPath = app.isPackaged + ? path.join(process.resourcesPath, 'assets', iconName) + : path.join(__dirname, '..', 'assets', iconName); + const img = nativeImage.createFromPath(iconPath); + return img.resize({ width: 16, height: 16 }); +} + +function showMainWindow(): void { + let win = getMainWindow(); + if (!win || win.isDestroyed()) { + win = createMainWindow('/dashboard'); + } else { + win.show(); + win.focus(); + } +} + +function registerShortcut(accelerator: string): void { + if (currentShortcut) { + globalShortcut.unregister(currentShortcut); + currentShortcut = null; + } + if (!accelerator) return; + try { + const ok = globalShortcut.register(accelerator, showMainWindow); + if (ok) { + currentShortcut = accelerator; + } else { + console.warn(`[tray] Failed to register General Assist shortcut: ${accelerator}`); + } + } catch (err) { + console.error(`[tray] Error registering General Assist shortcut:`, err); + } +} + +export async function createTray(): Promise { + if (tray) return; + + tray = new Tray(getTrayIcon()); + tray.setToolTip('SurfSense'); + + const contextMenu = Menu.buildFromTemplate([ + { label: 'Open SurfSense', click: showMainWindow }, + { type: 'separator' }, + { label: 'Quit', click: () => { app.exit(0); } }, + ]); + + tray.setContextMenu(contextMenu); + tray.on('double-click', showMainWindow); + + const shortcuts = await getShortcuts(); + registerShortcut(shortcuts.generalAssist); +} + +export async function reregisterGeneralAssist(): Promise { + const shortcuts = await getShortcuts(); + registerShortcut(shortcuts.generalAssist); +} + +export function destroyTray(): void { + if (currentShortcut) { + globalShortcut.unregister(currentShortcut); + currentShortcut = null; + } + tray?.destroy(); + tray = null; +} 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 a2f9da0f8..eaf015740 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,12 +1,13 @@ "use client"; -import { Clipboard, Sparkles } from "lucide-react"; +import { AppWindow, 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 { Spinner } from "@/components/ui/spinner"; +import { Switch } from "@/components/ui/switch"; import { useElectronAPI } from "@/hooks/use-platform"; export function DesktopContent() { @@ -68,7 +69,7 @@ export function DesktopContent() { await api.setAutocompleteEnabled(checked); }; - const updateShortcut = (key: "quickAsk" | "autocomplete", accelerator: string) => { + const updateShortcut = (key: "generalAssist" | "quickAsk" | "autocomplete", accelerator: string) => { setShortcuts((prev) => { const updated = { ...prev, [key]: accelerator }; api.setShortcuts?.({ [key]: accelerator }).catch(() => { @@ -79,7 +80,7 @@ export function DesktopContent() { toast.success("Shortcut updated"); }; - const resetShortcut = (key: "quickAsk" | "autocomplete") => { + const resetShortcut = (key: "generalAssist" | "quickAsk" | "autocomplete") => { updateShortcut(key, DEFAULT_SHORTCUTS[key]); }; @@ -95,23 +96,32 @@ export function DesktopContent() { {shortcutsLoaded ? ( -
- updateShortcut("quickAsk", accel)} - onReset={() => resetShortcut("quickAsk")} - defaultValue={DEFAULT_SHORTCUTS.quickAsk} - label="Quick Ask" +
+ updateShortcut("generalAssist", accel)} + onReset={() => resetShortcut("generalAssist")} + defaultValue={DEFAULT_SHORTCUTS.generalAssist} + label="General Assist" + description="Open SurfSense from anywhere" + icon={AppWindow} + /> + updateShortcut("quickAsk", accel)} + onReset={() => resetShortcut("quickAsk")} + defaultValue={DEFAULT_SHORTCUTS.quickAsk} + label="Quick Assist" description="Copy selected text and ask AI about it" - icon={Clipboard} - /> + icon={Clipboard} + /> updateShortcut("autocomplete", accel)} onReset={() => resetShortcut("autocomplete")} defaultValue={DEFAULT_SHORTCUTS.autocomplete} - label="Autocomplete" - description="Get AI writing suggestions from a screenshot" + label="Extreme Assist" + description="AI writing powered by your screen and knowledge base" icon={Sparkles} />

@@ -126,10 +136,10 @@ export function DesktopContent() { - {/* Autocomplete Toggle */} + {/* Extreme Assist Toggle */} - Autocomplete + Extreme Assist Get inline writing suggestions powered by your knowledge base as you type in any app. @@ -138,7 +148,7 @@ export function DesktopContent() {

Show suggestions while typing in other applications. diff --git a/surfsense_web/app/desktop/login/page.tsx b/surfsense_web/app/desktop/login/page.tsx index c81e284ba..f442b5d26 100644 --- a/surfsense_web/app/desktop/login/page.tsx +++ b/surfsense_web/app/desktop/login/page.tsx @@ -2,7 +2,7 @@ import { IconBrandGoogleFilled } from "@tabler/icons-react"; import { useAtom } from "jotai"; -import { Clipboard, Eye, EyeOff, Keyboard, Sparkles } from "lucide-react"; +import { AppWindow, Clipboard, Eye, EyeOff, Keyboard, Sparkles } from "lucide-react"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; @@ -48,7 +48,7 @@ export default function DesktopLoginPage() { }, [api]); const updateShortcut = useCallback( - (key: "quickAsk" | "autocomplete", accelerator: string) => { + (key: "generalAssist" | "quickAsk" | "autocomplete", accelerator: string) => { setShortcuts((prev) => { const updated = { ...prev, [key]: accelerator }; api?.setShortcuts?.({ [key]: accelerator }).catch(() => { @@ -62,7 +62,7 @@ export default function DesktopLoginPage() { ); const resetShortcut = useCallback( - (key: "quickAsk" | "autocomplete") => { + (key: "generalAssist" | "quickAsk" | "autocomplete") => { updateShortcut(key, DEFAULT_SHORTCUTS[key]); }, [updateShortcut] @@ -132,12 +132,21 @@ export default function DesktopLoginPage() { Keyboard Shortcuts

+ updateShortcut("generalAssist", accel)} + onReset={() => resetShortcut("generalAssist")} + defaultValue={DEFAULT_SHORTCUTS.generalAssist} + label="General Assist" + description="Open SurfSense from anywhere" + icon={AppWindow} + /> updateShortcut("quickAsk", accel)} onReset={() => resetShortcut("quickAsk")} defaultValue={DEFAULT_SHORTCUTS.quickAsk} - label="Quick Ask" + label="Quick Assist" description="Copy selected text and ask AI about it" icon={Clipboard} /> @@ -146,8 +155,8 @@ export default function DesktopLoginPage() { onChange={(accel) => updateShortcut("autocomplete", accel)} onReset={() => resetShortcut("autocomplete")} defaultValue={DEFAULT_SHORTCUTS.autocomplete} - label="Autocomplete" - description="Get AI writing suggestions from a screenshot" + label="Extreme Assist" + description="AI writing powered by your screen and knowledge base" icon={Sparkles} />

diff --git a/surfsense_web/components/desktop/shortcut-recorder.tsx b/surfsense_web/components/desktop/shortcut-recorder.tsx index 6d5e93a65..751579e50 100644 --- a/surfsense_web/components/desktop/shortcut-recorder.tsx +++ b/surfsense_web/components/desktop/shortcut-recorder.tsx @@ -36,6 +36,7 @@ export function acceleratorToDisplay(accel: string): string[] { } export const DEFAULT_SHORTCUTS = { + generalAssist: "CommandOrControl+Shift+S", quickAsk: "CommandOrControl+Alt+S", autocomplete: "CommandOrControl+Shift+Space", }; diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index 3f228066a..25077d1da 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -89,10 +89,10 @@ interface ElectronAPI { getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>; setAuthTokens: (bearer: string, refresh: string) => Promise; // Keyboard shortcut configuration - getShortcuts: () => Promise<{ quickAsk: string; autocomplete: string }>; + getShortcuts: () => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>; setShortcuts: ( - config: Partial<{ quickAsk: string; autocomplete: string }> - ) => Promise<{ quickAsk: string; autocomplete: string }>; + config: Partial<{ generalAssist: string; quickAsk: string; autocomplete: string }> + ) => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>; } declare global {