switch to on-demand permission requests and improve suggestion UX

This commit is contained in:
CREDO23 2026-04-03 19:57:48 +02:00
parent aeb3f13f91
commit c5aa869adb
12 changed files with 195 additions and 89 deletions

View file

@ -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")

View file

@ -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

View file

@ -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',

View file

@ -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();
});

View file

@ -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');
}
});
});

View file

@ -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<void> {
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<void> {
async function acceptAndInject(text: string): Promise<void> {
if (!sourceApp) return;
if (!hasAccessibilityPermission()) return;
if (!hasAccessibilityPermission()) {
requestAccessibility();
return;
}
clipboard.writeText(text);
destroySuggestion();

View file

@ -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);

View file

@ -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) => {

View file

@ -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<typeof setInterval> | 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() {
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">System Permissions</h1>
<p className="text-sm text-muted-foreground">
SurfSense needs Accessibility permission to insert suggestions into the active application.
SurfSense needs two macOS permissions to provide context-aware writing suggestions.
</p>
</div>
</div>
{/* Permission card */}
{/* Steps */}
<div className="rounded-xl border bg-background dark:bg-neutral-900 flex-1 min-h-0 overflow-y-auto px-6 py-6 space-y-6">
<div
className={`rounded-lg border p-4 transition-colors ${
allGranted
? "border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/20"
: "border-border"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary">
{allGranted ? "\u2713" : "1"}
</span>
<div className="space-y-1">
<h3 className="text-sm font-medium">Accessibility</h3>
<p className="text-xs text-muted-foreground">
Lets SurfSense insert suggestions seamlessly, right where you&apos;re typing.
</p>
{STEPS.map((step, index) => {
const status = permissions[step.field];
const isGranted = status === "authorized";
return (
<div
key={step.id}
className={`rounded-lg border p-4 transition-colors ${
isGranted
? "border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/20"
: "border-border"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary">
{isGranted ? "\u2713" : index + 1}
</span>
<div className="space-y-1">
<h3 className="text-sm font-medium">{step.title}</h3>
<p className="text-xs text-muted-foreground">{step.description}</p>
</div>
</div>
<StatusBadge status={status} />
</div>
</div>
<StatusBadge status={permissions.accessibility} />
</div>
{!allGranted && (
<div className="mt-3 pl-10 space-y-2">
<Button
size="sm"
variant="outline"
onClick={handleRequest}
className="text-xs"
>
Open System Settings
</Button>
{permissions.accessibility === "denied" && (
<p className="text-xs text-amber-700 dark:text-amber-400">
Toggle SurfSense on in System Settings to continue.
</p>
{!isGranted && (
<div className="mt-3 pl-10 space-y-2">
<Button
size="sm"
variant="outline"
onClick={() => handleRequest(step.action)}
className="text-xs"
>
Open System Settings
</Button>
{status === "denied" && (
<p className="text-xs text-amber-700 dark:text-amber-400">
Toggle SurfSense on in System Settings to continue.
</p>
)}
<p className="text-xs text-muted-foreground">
If SurfSense doesn&apos;t appear in the list, click <strong>+</strong> and select it from Applications.
</p>
</div>
)}
<p className="text-xs text-muted-foreground">
If SurfSense doesn&apos;t appear in the list, click <strong>+</strong> and select it from Applications.
</p>
</div>
)}
</div>
);
})}
</div>
{/* Footer */}
@ -168,7 +198,7 @@ export default function DesktopPermissionsPage() {
) : (
<>
<Button disabled className="text-sm h-9 min-w-[180px]">
Grant permission to continue
Grant permissions to continue
</Button>
<button
onClick={handleSkip}

View file

@ -11,12 +11,40 @@ type SSEEvent =
| { type: "finish" }
| { type: "error"; errorText: string };
function friendlyError(raw: string | number): string {
if (typeof raw === "number") {
if (raw === 401) return "Please sign in to use suggestions.";
if (raw === 403) return "You don\u2019t have permission for this.";
if (raw === 404) return "Suggestion service not found. Is the backend running?";
if (raw >= 500) return "Something went wrong on the server. Try again.";
return "Something went wrong. Try again.";
}
const lower = raw.toLowerCase();
if (lower.includes("not authenticated") || lower.includes("unauthorized"))
return "Please sign in to use suggestions.";
if (lower.includes("no vision llm configured") || lower.includes("no llm configured"))
return "No Vision LLM configured. Set one in search space settings.";
if (lower.includes("fetch") || lower.includes("network") || lower.includes("econnrefused"))
return "Can\u2019t reach the server. Check your connection.";
return "Something went wrong. Try again.";
}
const AUTO_DISMISS_MS = 3000;
export default function SuggestionPage() {
const [suggestion, setSuggestion] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
if (!error) return;
const timer = setTimeout(() => {
window.electronAPI?.dismissSuggestion?.();
}, AUTO_DISMISS_MS);
return () => clearTimeout(timer);
}, [error]);
const fetchSuggestion = useCallback(
async (screenshot: string, searchSpaceId: string) => {
abortRef.current?.abort();
@ -29,7 +57,7 @@ export default function SuggestionPage() {
const token = getBearerToken();
if (!token) {
setError("Not authenticated");
setError(friendlyError("not authenticated"));
setIsLoading(false);
return;
}
@ -55,13 +83,13 @@ export default function SuggestionPage() {
);
if (!response.ok) {
setError(`Error: ${response.status}`);
setError(friendlyError(response.status));
setIsLoading(false);
return;
}
if (!response.body) {
setError("No response body");
setError(friendlyError("network error"));
setIsLoading(false);
return;
}
@ -94,7 +122,7 @@ export default function SuggestionPage() {
return updated;
});
} else if (parsed.type === "error") {
setError(parsed.errorText);
setError(friendlyError(parsed.errorText));
}
} catch {
continue;
@ -104,7 +132,7 @@ export default function SuggestionPage() {
}
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
setError("Failed to get suggestion");
setError(friendlyError("network error"));
} finally {
setIsLoading(false);
}
@ -145,15 +173,28 @@ export default function SuggestionPage() {
);
}
const handleAccept = () => {
if (suggestion) {
window.electronAPI?.acceptSuggestion?.(suggestion);
}
};
const handleDismiss = () => {
window.electronAPI?.dismissSuggestion?.();
};
if (!suggestion) return null;
return (
<div className="suggestion-tooltip">
<p className="suggestion-text">{suggestion}</p>
<div className="suggestion-hint">
<kbd>Tab</kbd> accept
<span className="suggestion-separator" />
<kbd>Esc</kbd> dismiss
<div className="suggestion-actions">
<button className="suggestion-btn suggestion-btn-accept" onClick={handleAccept}>
Accept
</button>
<button className="suggestion-btn suggestion-btn-dismiss" onClick={handleDismiss}>
Dismiss
</button>
</div>
</div>
);

View file

@ -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 {

View file

@ -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<void>;
requestScreenRecording: () => Promise<void>;
restartApp: () => Promise<void>;
// Autocomplete
onAutocompleteContext: (callback: (data: { screenshot: string; searchSpaceId?: string }) => void) => () => void;