mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-26 09:16:22 +02:00
feat: add permission onboarding page and startup routing for macOS
This commit is contained in:
parent
ec2b7851b6
commit
eaabad38fc
7 changed files with 228 additions and 7 deletions
212
surfsense_web/app/desktop/permissions/page.tsx
Normal file
212
surfsense_web/app/desktop/permissions/page.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
type PermissionStatus = "authorized" | "denied" | "not determined" | "restricted" | "limited";
|
||||
|
||||
interface PermissionsStatus {
|
||||
accessibility: PermissionStatus;
|
||||
inputMonitoring: PermissionStatus;
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
id: "input-monitoring",
|
||||
title: "Input Monitoring",
|
||||
description: "Helps you write faster by enriching your text with suggestions from your knowledge base.",
|
||||
action: "requestInputMonitoring",
|
||||
field: "inputMonitoring" as const,
|
||||
},
|
||||
{
|
||||
id: "accessibility",
|
||||
title: "Accessibility",
|
||||
description: "Lets you accept suggestions seamlessly, right where you're typing.",
|
||||
action: "requestAccessibility",
|
||||
field: "accessibility" as const,
|
||||
},
|
||||
];
|
||||
|
||||
function StatusBadge({ status }: { status: PermissionStatus }) {
|
||||
if (status === "authorized") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-green-700 dark:text-green-400">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
Granted
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "denied") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-amber-700 dark:text-amber-400">
|
||||
<span className="h-2 w-2 rounded-full bg-amber-500" />
|
||||
Denied
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<span className="h-2 w-2 rounded-full bg-muted-foreground/40" />
|
||||
Pending
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DesktopPermissionsPage() {
|
||||
const router = useRouter();
|
||||
const [permissions, setPermissions] = useState<PermissionsStatus | null>(null);
|
||||
const [isElectron, setIsElectron] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return;
|
||||
setIsElectron(true);
|
||||
|
||||
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 (isResolved(status.accessibility) && isResolved(status.inputMonitoring)) {
|
||||
if (interval) clearInterval(interval);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
interval = setInterval(poll, 2000);
|
||||
return () => { if (interval) clearInterval(interval); };
|
||||
}, []);
|
||||
|
||||
if (!isElectron) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-background">
|
||||
<p className="text-muted-foreground">This page is only available in the desktop app.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!permissions) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-background">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const allGranted = permissions.accessibility === "authorized" && permissions.inputMonitoring === "authorized";
|
||||
|
||||
const handleRequest = async (action: string) => {
|
||||
if (action === "requestInputMonitoring") {
|
||||
await window.electronAPI!.requestInputMonitoring();
|
||||
} else if (action === "requestAccessibility") {
|
||||
await window.electronAPI!.requestAccessibility();
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (allGranted) {
|
||||
window.electronAPI!.restartApp();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
router.push("/dashboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col items-center p-4 bg-background dark:bg-neutral-900 select-none overflow-hidden">
|
||||
<div className="w-full max-w-lg flex flex-col min-h-0 h-full gap-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-3 shrink-0">
|
||||
<Logo className="w-12 h-12 mx-auto" />
|
||||
<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 two macOS permissions to provide system-wide autocomplete.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{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 ? "✓" : 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>
|
||||
{!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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center space-y-3 shrink-0">
|
||||
{allGranted ? (
|
||||
<>
|
||||
<Button onClick={handleContinue} className="text-sm h-9 min-w-[180px]">
|
||||
Restart & Get Started
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A restart is needed for permissions to take effect.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button disabled className="text-sm h-9 min-w-[180px]">
|
||||
Grant permissions to continue
|
||||
</Button>
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="block mx-auto text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
surfsense_web/app/desktop/suggestion/layout.tsx
Normal file
13
surfsense_web/app/desktop/suggestion/layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import "./suggestion.css";
|
||||
|
||||
export const metadata = {
|
||||
title: "SurfSense Suggestion",
|
||||
};
|
||||
|
||||
export default function SuggestionLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="suggestion-body">{children}</div>;
|
||||
}
|
||||
160
surfsense_web/app/desktop/suggestion/page.tsx
Normal file
160
surfsense_web/app/desktop/suggestion/page.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
|
||||
type SSEEvent =
|
||||
| { type: "text-delta"; id: string; delta: string }
|
||||
| { type: "text-start"; id: string }
|
||||
| { type: "text-end"; id: string }
|
||||
| { type: "start"; messageId: string }
|
||||
| { type: "finish" }
|
||||
| { type: "error"; errorText: string };
|
||||
|
||||
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);
|
||||
|
||||
const fetchSuggestion = useCallback(
|
||||
async (text: string, cursorPosition: number, searchSpaceId: string) => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setIsLoading(true);
|
||||
setSuggestion("");
|
||||
setError(null);
|
||||
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
setError("Not authenticated");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const backendUrl =
|
||||
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
|
||||
const params = new URLSearchParams({
|
||||
text,
|
||||
cursor_position: String(cursorPosition),
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${backendUrl}/api/v1/autocomplete/stream?${params}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
setError(`Error: ${response.status}`);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
setError("No response body");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const events = buffer.split(/\r?\n\r?\n/);
|
||||
buffer = events.pop() || "";
|
||||
|
||||
for (const event of events) {
|
||||
const lines = event.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const data = line.slice(6).trim();
|
||||
if (!data || data === "[DONE]") continue;
|
||||
|
||||
try {
|
||||
const parsed: SSEEvent = JSON.parse(data);
|
||||
if (parsed.type === "text-delta") {
|
||||
setSuggestion((prev) => {
|
||||
const updated = prev + parsed.delta;
|
||||
window.electronAPI?.updateSuggestionText?.(updated);
|
||||
return updated;
|
||||
});
|
||||
} else if (parsed.type === "error") {
|
||||
setError(parsed.errorText);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setError("Failed to get suggestion");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI?.onAutocompleteContext) return;
|
||||
|
||||
const cleanup = window.electronAPI.onAutocompleteContext((data) => {
|
||||
const searchSpaceId = data.searchSpaceId || "1";
|
||||
fetchSuggestion(data.text, data.cursorPosition, searchSpaceId);
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [fetchSuggestion]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="suggestion-tooltip suggestion-error">
|
||||
<span className="suggestion-error-text">{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && !suggestion) {
|
||||
return (
|
||||
<div className="suggestion-tooltip">
|
||||
<div className="suggestion-loading">
|
||||
<span className="suggestion-dot" />
|
||||
<span className="suggestion-dot" />
|
||||
<span className="suggestion-dot" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!suggestion) return null;
|
||||
|
||||
return (
|
||||
<div className="suggestion-tooltip">
|
||||
<p className="suggestion-text">{suggestion}</p>
|
||||
<div className="suggestion-hint">
|
||||
<span className="suggestion-key">Tab</span> accept
|
||||
<span className="suggestion-separator">·</span>
|
||||
<span className="suggestion-key">Esc</span> dismiss
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
surfsense_web/app/desktop/suggestion/suggestion.css
Normal file
96
surfsense_web/app/desktop/suggestion/suggestion.css
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
.suggestion-body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.suggestion-tooltip {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
margin: 4px;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.suggestion-text {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 8px 0;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.suggestion-hint {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.suggestion-key {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.suggestion-separator {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.suggestion-error {
|
||||
border-color: rgba(255, 80, 80, 0.3);
|
||||
}
|
||||
|
||||
.suggestion-error-text {
|
||||
color: rgba(255, 120, 120, 0.9);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.suggestion-loading {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.suggestion-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
animation: suggestion-pulse 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.suggestion-dot:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.suggestion-dot:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
@keyframes suggestion-pulse {
|
||||
0%, 80%, 100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue