diff --git a/surfsense_backend/alembic/versions/117_add_vision_llm_id_to_search_spaces.py b/surfsense_backend/alembic/versions/117_add_vision_llm_id_to_search_spaces.py index 254642c88..2bec374c6 100644 --- a/surfsense_backend/alembic/versions/117_add_vision_llm_id_to_search_spaces.py +++ b/surfsense_backend/alembic/versions/117_add_vision_llm_id_to_search_spaces.py @@ -25,15 +25,15 @@ depends_on: str | Sequence[str] | None = None def upgrade() -> None: conn = op.get_bind() existing_columns = [ - col["name"] for col in sa.inspect(conn).get_columns("search_spaces") + col["name"] for col in sa.inspect(conn).get_columns("searchspaces") ] if "vision_llm_id" not in existing_columns: op.add_column( - "search_spaces", + "searchspaces", sa.Column("vision_llm_id", sa.Integer(), nullable=True, server_default="0"), ) def downgrade() -> None: - op.drop_column("search_spaces", "vision_llm_id") + op.drop_column("searchspaces", "vision_llm_id") diff --git a/surfsense_desktop/electron-builder.yml b/surfsense_desktop/electron-builder.yml index 3de0f266d..be5e07c63 100644 --- a/surfsense_desktop/electron-builder.yml +++ b/surfsense_desktop/electron-builder.yml @@ -47,6 +47,7 @@ mac: gatekeeperAssess: false extendInfo: NSAccessibilityUsageDescription: "SurfSense uses accessibility features to insert suggestions into the active application." + NSScreenCaptureUsageDescription: "SurfSense uses screen capture to analyze your screen and provide context-aware writing suggestions." NSAppleEventsUsageDescription: "SurfSense uses Apple Events to interact with the active application." target: - target: dmg diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 905a84bc3..e41355eaf 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -9,6 +9,7 @@ export const IPC_CHANNELS = { // Permissions GET_PERMISSIONS_STATUS: 'get-permissions-status', REQUEST_ACCESSIBILITY: 'request-accessibility', + REQUEST_SCREEN_RECORDING: 'request-screen-recording', RESTART_APP: 'restart-app', // Autocomplete AUTOCOMPLETE_CONTEXT: 'autocomplete-context', diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index 8597a39e8..11cbfee05 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -3,6 +3,7 @@ import { IPC_CHANNELS } from './channels'; import { getPermissionsStatus, requestAccessibility, + requestScreenRecording, restartApp, } from '../modules/permissions'; @@ -30,6 +31,10 @@ export function registerIpcHandlers(): void { requestAccessibility(); }); + ipcMain.handle(IPC_CHANNELS.REQUEST_SCREEN_RECORDING, () => { + requestScreenRecording(); + }); + ipcMain.handle(IPC_CHANNELS.RESTART_APP, () => { restartApp(); }); diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index c96453c6d..584e930fe 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -8,7 +8,6 @@ import { setupMenu } from './modules/menu'; import { registerQuickAsk, unregisterQuickAsk } from './modules/quick-ask'; import { registerAutocomplete, unregisterAutocomplete } from './modules/autocomplete'; import { registerIpcHandlers } from './ipc/handlers'; -import { allPermissionsGranted } from './modules/permissions'; registerGlobalErrorHandlers(); @@ -18,14 +17,6 @@ if (!setupDeepLinks()) { registerIpcHandlers(); -function getInitialPath(): string { - const granted = allPermissionsGranted(); - if (process.platform === 'darwin' && !granted) { - return '/desktop/permissions'; - } - return '/dashboard'; -} - app.whenReady().then(async () => { setupMenu(); try { @@ -36,8 +27,7 @@ app.whenReady().then(async () => { return; } - const initialPath = getInitialPath(); - createMainWindow(initialPath); + createMainWindow('/dashboard'); registerQuickAsk(); registerAutocomplete(); setupAutoUpdater(); @@ -46,7 +36,7 @@ app.whenReady().then(async () => { app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { - createMainWindow(getInitialPath()); + createMainWindow('/dashboard'); } }); }); diff --git a/surfsense_desktop/src/modules/autocomplete/index.ts b/surfsense_desktop/src/modules/autocomplete/index.ts index 6763b2cae..958886b63 100644 --- a/surfsense_desktop/src/modules/autocomplete/index.ts +++ b/surfsense_desktop/src/modules/autocomplete/index.ts @@ -1,6 +1,7 @@ import { clipboard, globalShortcut, ipcMain, screen } from 'electron'; import { IPC_CHANNELS } from '../../ipc/channels'; import { getFrontmostApp, hasAccessibilityPermission, simulatePaste } from '../platform'; +import { hasScreenRecordingPermission, requestAccessibility, requestScreenRecording } from '../permissions'; import { getMainWindow } from '../window'; import { captureScreen } from './screenshot'; import { createSuggestionWindow, destroySuggestion, getSuggestionWindow } from './suggestion-window'; @@ -19,9 +20,13 @@ function isSurfSenseWindow(): boolean { async function triggerAutocomplete(): Promise { if (!autocompleteEnabled) return; - if (!hasAccessibilityPermission()) return; if (isSurfSenseWindow()) return; + if (!hasScreenRecordingPermission()) { + requestScreenRecording(); + return; + } + sourceApp = getFrontmostApp(); savedClipboard = clipboard.readText(); @@ -59,7 +64,11 @@ async function triggerAutocomplete(): Promise { async function acceptAndInject(text: string): Promise { if (!sourceApp) return; - if (!hasAccessibilityPermission()) return; + + if (!hasAccessibilityPermission()) { + requestAccessibility(); + return; + } clipboard.writeText(text); destroySuggestion(); diff --git a/surfsense_desktop/src/modules/permissions.ts b/surfsense_desktop/src/modules/permissions.ts index 4ac671b7c..a2f057795 100644 --- a/surfsense_desktop/src/modules/permissions.ts +++ b/surfsense_desktop/src/modules/permissions.ts @@ -4,6 +4,7 @@ type PermissionStatus = 'authorized' | 'denied' | 'not determined' | 'restricted export interface PermissionsStatus { accessibility: PermissionStatus; + screenRecording: PermissionStatus; } function isMac(): boolean { @@ -16,18 +17,19 @@ function getNodeMacPermissions() { export function getPermissionsStatus(): PermissionsStatus { if (!isMac()) { - return { accessibility: 'authorized' }; + return { accessibility: 'authorized', screenRecording: 'authorized' }; } const perms = getNodeMacPermissions(); return { accessibility: perms.getAuthStatus('accessibility'), + screenRecording: perms.getAuthStatus('screen'), }; } export function allPermissionsGranted(): boolean { const status = getPermissionsStatus(); - return status.accessibility === 'authorized'; + return status.accessibility === 'authorized' && status.screenRecording === 'authorized'; } export function requestAccessibility(): void { @@ -36,6 +38,18 @@ export function requestAccessibility(): void { perms.askForAccessibilityAccess(); } +export function hasScreenRecordingPermission(): boolean { + if (!isMac()) return true; + const perms = getNodeMacPermissions(); + return perms.getAuthStatus('screen') === 'authorized'; +} + +export function requestScreenRecording(): void { + if (!isMac()) return; + const perms = getNodeMacPermissions(); + perms.askForScreenCaptureAccess(); +} + export function restartApp(): void { app.relaunch(); app.exit(0); diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index 891d9b029..5c8b64f6f 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -24,6 +24,7 @@ contextBridge.exposeInMainWorld('electronAPI', { // Permissions getPermissionsStatus: () => ipcRenderer.invoke(IPC_CHANNELS.GET_PERMISSIONS_STATUS), requestAccessibility: () => ipcRenderer.invoke(IPC_CHANNELS.REQUEST_ACCESSIBILITY), + requestScreenRecording: () => ipcRenderer.invoke(IPC_CHANNELS.REQUEST_SCREEN_RECORDING), restartApp: () => ipcRenderer.invoke(IPC_CHANNELS.RESTART_APP), // Autocomplete onAutocompleteContext: (callback: (data: { screenshot: string; searchSpaceId?: string }) => void) => { diff --git a/surfsense_web/app/desktop/permissions/page.tsx b/surfsense_web/app/desktop/permissions/page.tsx index e0d3131e0..6c08e35b5 100644 --- a/surfsense_web/app/desktop/permissions/page.tsx +++ b/surfsense_web/app/desktop/permissions/page.tsx @@ -10,8 +10,26 @@ type PermissionStatus = "authorized" | "denied" | "not determined" | "restricted interface PermissionsStatus { accessibility: PermissionStatus; + screenRecording: PermissionStatus; } +const STEPS = [ + { + id: "screen-recording", + title: "Screen Recording", + description: "Lets SurfSense capture your screen to understand context and provide smart writing suggestions.", + action: "requestScreenRecording", + field: "screenRecording" as const, + }, + { + id: "accessibility", + title: "Accessibility", + description: "Lets SurfSense insert suggestions seamlessly, right where you\u2019re typing.", + action: "requestAccessibility", + field: "accessibility" as const, + }, +]; + function StatusBadge({ status }: { status: PermissionStatus }) { if (status === "authorized") { return ( @@ -48,11 +66,13 @@ export default function DesktopPermissionsPage() { let interval: ReturnType | null = null; + const isResolved = (s: string) => s === "authorized" || s === "restricted"; + const poll = async () => { const status = await window.electronAPI!.getPermissionsStatus(); setPermissions(status); - if (status.accessibility === "authorized" || status.accessibility === "restricted") { + if (isResolved(status.accessibility) && isResolved(status.screenRecording)) { if (interval) clearInterval(interval); } }; @@ -78,10 +98,14 @@ export default function DesktopPermissionsPage() { ); } - const allGranted = permissions.accessibility === "authorized"; + const allGranted = permissions.accessibility === "authorized" && permissions.screenRecording === "authorized"; - const handleRequest = async () => { - await window.electronAPI!.requestAccessibility(); + const handleRequest = async (action: string) => { + if (action === "requestScreenRecording") { + await window.electronAPI!.requestScreenRecording(); + } else if (action === "requestAccessibility") { + await window.electronAPI!.requestAccessibility(); + } }; const handleContinue = () => { @@ -103,55 +127,61 @@ export default function DesktopPermissionsPage() {

System Permissions

- SurfSense needs Accessibility permission to insert suggestions into the active application. + SurfSense needs two macOS permissions to provide context-aware writing suggestions.

- {/* Permission card */} + {/* Steps */}
-
-
-
- - {allGranted ? "\u2713" : "1"} - -
-

Accessibility

-

- Lets SurfSense insert suggestions seamlessly, right where you're typing. -

+ {STEPS.map((step, index) => { + const status = permissions[step.field]; + const isGranted = status === "authorized"; + + return ( +
+
+
+ + {isGranted ? "\u2713" : index + 1} + +
+

{step.title}

+

{step.description}

+
+
+
-
- -
- {!allGranted && ( -
- - {permissions.accessibility === "denied" && ( -

- Toggle SurfSense on in System Settings to continue. -

+ {!isGranted && ( +
+ + {status === "denied" && ( +

+ Toggle SurfSense on in System Settings to continue. +

+ )} +

+ If SurfSense doesn't appear in the list, click + and select it from Applications. +

+
)} -

- If SurfSense doesn't appear in the list, click + and select it from Applications. -

- )} -
+ ); + })}
{/* Footer */} @@ -168,7 +198,7 @@ export default function DesktopPermissionsPage() { ) : ( <> +
); diff --git a/surfsense_web/app/desktop/suggestion/suggestion.css b/surfsense_web/app/desktop/suggestion/suggestion.css index 0d3332103..712d12618 100644 --- a/surfsense_web/app/desktop/suggestion/suggestion.css +++ b/surfsense_web/app/desktop/suggestion/suggestion.css @@ -36,32 +36,44 @@ html, body { white-space: pre-wrap; } -.suggestion-hint { - color: #666; - font-size: 11px; +.suggestion-actions { display: flex; - align-items: center; - gap: 6px; + justify-content: flex-end; + gap: 4px; border-top: 1px solid #2a2a2a; padding-top: 6px; } -.suggestion-hint kbd { - background: #2a2a2a; - border: 1px solid #3c3c3c; +.suggestion-btn { + padding: 2px 8px; border-radius: 3px; - padding: 0 4px; + border: 1px solid #3c3c3c; font-family: inherit; font-size: 10px; - font-weight: 600; - color: #999; - line-height: 18px; + font-weight: 500; + cursor: pointer; + line-height: 16px; + transition: background 0.15s, border-color 0.15s; } -.suggestion-separator { - width: 1px; - height: 10px; +.suggestion-btn-accept { + background: #2563eb; + border-color: #3b82f6; + color: #fff; +} + +.suggestion-btn-accept:hover { + background: #1d4ed8; +} + +.suggestion-btn-dismiss { + background: #2a2a2a; + color: #999; +} + +.suggestion-btn-dismiss:hover { background: #333; + color: #ccc; } .suggestion-error { diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index a5b8566f9..dc3a6465e 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -17,8 +17,10 @@ interface ElectronAPI { // Permissions getPermissionsStatus: () => Promise<{ accessibility: 'authorized' | 'denied' | 'not determined' | 'restricted' | 'limited'; + screenRecording: 'authorized' | 'denied' | 'not determined' | 'restricted' | 'limited'; }>; requestAccessibility: () => Promise; + requestScreenRecording: () => Promise; restartApp: () => Promise; // Autocomplete onAutocompleteContext: (callback: (data: { screenshot: string; searchSpaceId?: string }) => void) => () => void;