mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-06 23:13:58 +02:00
feat: move background processes to its own daemon
This commit is contained in:
@@ -14,6 +14,7 @@ import { GroupBadges } from "@/components/group-badges";
|
||||
import { GroupManagementDialog } from "@/components/group-management-dialog";
|
||||
import HomeHeader from "@/components/home-header";
|
||||
import { ImportProfileDialog } from "@/components/import-profile-dialog";
|
||||
import { IntegrationsDialog } from "@/components/integrations-dialog";
|
||||
import { PermissionDialog } from "@/components/permission-dialog";
|
||||
import { ProfilesDataTable } from "@/components/profile-data-table";
|
||||
import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
|
||||
@@ -88,6 +89,7 @@ export default function Home() {
|
||||
|
||||
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
|
||||
const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false);
|
||||
const [proxyManagementDialogOpen, setProxyManagementDialogOpen] =
|
||||
useState(false);
|
||||
@@ -805,6 +807,7 @@ export default function Home() {
|
||||
onProxyManagementDialogOpen={setProxyManagementDialogOpen}
|
||||
onSettingsDialogOpen={setSettingsDialogOpen}
|
||||
onSyncConfigDialogOpen={setSyncConfigDialogOpen}
|
||||
onIntegrationsDialogOpen={setIntegrationsDialogOpen}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
/>
|
||||
@@ -855,6 +858,17 @@ export default function Home() {
|
||||
onClose={() => {
|
||||
setSettingsDialogOpen(false);
|
||||
}}
|
||||
onIntegrationsOpen={() => {
|
||||
setSettingsDialogOpen(false);
|
||||
setIntegrationsDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<IntegrationsDialog
|
||||
isOpen={integrationsDialogOpen}
|
||||
onClose={() => {
|
||||
setIntegrationsDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ImportProfileDialog
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
|
||||
import { LuCloud, LuSearch, LuUsers, LuX } from "react-icons/lu";
|
||||
import { LuCloud, LuPlug, LuSearch, LuUsers, LuX } from "react-icons/lu";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { Button } from "./ui/button";
|
||||
import { CardTitle } from "./ui/card";
|
||||
@@ -21,6 +21,7 @@ type Props = {
|
||||
onImportProfileDialogOpen: (open: boolean) => void;
|
||||
onCreateProfileDialogOpen: (open: boolean) => void;
|
||||
onSyncConfigDialogOpen: (open: boolean) => void;
|
||||
onIntegrationsDialogOpen: (open: boolean) => void;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
};
|
||||
@@ -32,6 +33,7 @@ const HomeHeader = ({
|
||||
onImportProfileDialogOpen,
|
||||
onCreateProfileDialogOpen,
|
||||
onSyncConfigDialogOpen,
|
||||
onIntegrationsDialogOpen,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
}: Props) => {
|
||||
@@ -128,6 +130,14 @@ const HomeHeader = ({
|
||||
<LuCloud className="mr-2 w-4 h-4" />
|
||||
Sync Service
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onIntegrationsDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<LuPlug className="mr-2 w-4 h-4" />
|
||||
Integrations
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onImportProfileDialogOpen(true);
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { CopyToClipboard } from "./ui/copy-to-clipboard";
|
||||
|
||||
interface AppSettings {
|
||||
api_enabled: boolean;
|
||||
api_port: number;
|
||||
api_token?: string;
|
||||
mcp_enabled: boolean;
|
||||
mcp_port?: number;
|
||||
mcp_token?: string;
|
||||
}
|
||||
|
||||
interface McpConfig {
|
||||
port: number;
|
||||
token: string;
|
||||
config_json: string;
|
||||
}
|
||||
|
||||
interface IntegrationsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function IntegrationsDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: IntegrationsDialogProps) {
|
||||
const [settings, setSettings] = useState<AppSettings>({
|
||||
api_enabled: false,
|
||||
api_port: 10108,
|
||||
api_token: undefined,
|
||||
mcp_enabled: false,
|
||||
mcp_port: undefined,
|
||||
mcp_token: undefined,
|
||||
});
|
||||
const [apiServerPort, setApiServerPort] = useState<number | null>(null);
|
||||
const [mcpConfig, setMcpConfig] = useState<McpConfig | null>(null);
|
||||
const [_mcpRunning, setMcpRunning] = useState(false);
|
||||
const [showApiToken, setShowApiToken] = useState(false);
|
||||
const [showMcpToken, setShowMcpToken] = useState(false);
|
||||
const [isApiStarting, setIsApiStarting] = useState(false);
|
||||
const [isMcpStarting, setIsMcpStarting] = useState(false);
|
||||
|
||||
const { termsAccepted } = useWayfernTerms();
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
try {
|
||||
const loaded = await invoke<AppSettings>("get_app_settings");
|
||||
setSettings(loaded);
|
||||
} catch (e) {
|
||||
console.error("Failed to load settings:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMcpConfig = useCallback(async () => {
|
||||
try {
|
||||
const config = await invoke<McpConfig | null>("get_mcp_config");
|
||||
setMcpConfig(config);
|
||||
} catch (e) {
|
||||
console.error("Failed to get MCP config:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMcpServerStatus = useCallback(async () => {
|
||||
try {
|
||||
const isRunning = await invoke<boolean>("get_mcp_server_status");
|
||||
setMcpRunning(isRunning);
|
||||
} catch (e) {
|
||||
console.error("Failed to get MCP server status:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadApiServerStatus = useCallback(async () => {
|
||||
try {
|
||||
const port = await invoke<number | null>("get_api_server_status");
|
||||
setApiServerPort(port);
|
||||
} catch (e) {
|
||||
console.error("Failed to get API server status:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadSettings();
|
||||
loadApiServerStatus();
|
||||
loadMcpConfig();
|
||||
loadMcpServerStatus();
|
||||
}
|
||||
}, [
|
||||
isOpen,
|
||||
loadSettings,
|
||||
loadApiServerStatus,
|
||||
loadMcpConfig,
|
||||
loadMcpServerStatus,
|
||||
]);
|
||||
|
||||
const handleApiToggle = async (enabled: boolean) => {
|
||||
setIsApiStarting(true);
|
||||
try {
|
||||
if (enabled) {
|
||||
const port = await invoke<number>("start_api_server", {
|
||||
port: settings.api_port,
|
||||
});
|
||||
setApiServerPort(port);
|
||||
const next = await invoke<AppSettings>("save_app_settings", {
|
||||
settings: { ...settings, api_enabled: true },
|
||||
});
|
||||
setSettings(next);
|
||||
showSuccessToast(`API server started on port ${port}`);
|
||||
} else {
|
||||
await invoke("stop_api_server");
|
||||
setApiServerPort(null);
|
||||
const next = await invoke<AppSettings>("save_app_settings", {
|
||||
settings: { ...settings, api_enabled: false, api_token: null },
|
||||
});
|
||||
setSettings(next);
|
||||
showSuccessToast("API server stopped");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle API:", e);
|
||||
showErrorToast("Failed to toggle API server", {
|
||||
description: e instanceof Error ? e.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMcpToggle = async (enabled: boolean) => {
|
||||
setIsMcpStarting(true);
|
||||
try {
|
||||
if (enabled) {
|
||||
const port = await invoke<number>("start_mcp_server");
|
||||
const next = await invoke<AppSettings>("save_app_settings", {
|
||||
settings: { ...settings, mcp_enabled: true, mcp_port: port },
|
||||
});
|
||||
setSettings(next);
|
||||
loadMcpConfig();
|
||||
showSuccessToast(`MCP server started on port ${port}`);
|
||||
} else {
|
||||
await invoke("stop_mcp_server");
|
||||
const next = await invoke<AppSettings>("save_app_settings", {
|
||||
settings: { ...settings, mcp_enabled: false },
|
||||
});
|
||||
setSettings(next);
|
||||
setMcpConfig(null);
|
||||
showSuccessToast("MCP server stopped");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle MCP server:", e);
|
||||
showErrorToast("Failed to toggle MCP server", {
|
||||
description: e instanceof Error ? e.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsMcpStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const obfuscateToken = (token: string) =>
|
||||
"•".repeat(Math.min(token.length, 32));
|
||||
|
||||
const getFormattedMcpConfig = () => {
|
||||
if (!mcpConfig) return "";
|
||||
return JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
"donut-browser": {
|
||||
url: `http://127.0.0.1:${mcpConfig.port}/mcp`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${mcpConfig.token}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
};
|
||||
|
||||
const getObfuscatedMcpConfig = () => {
|
||||
if (!mcpConfig) return "";
|
||||
return JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
"donut-browser": {
|
||||
url: `http://127.0.0.1:${mcpConfig.port}/mcp`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${obfuscateToken(mcpConfig.token)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Integrations</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="api" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="api">Local API</TabsTrigger>
|
||||
<TabsTrigger value="mcp">MCP (AI Assistants)</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="api" className="space-y-4 mt-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="api-enabled"
|
||||
checked={apiServerPort !== null}
|
||||
disabled={isApiStarting}
|
||||
onCheckedChange={handleApiToggle}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<Label
|
||||
htmlFor="api-enabled"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Enable Local API Server
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow managing profiles, groups, and proxies via REST API.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.api_enabled && (
|
||||
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Port</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
value={apiServerPort ?? settings.api_port}
|
||||
readOnly
|
||||
className="w-24 font-mono"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Server is running
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
Authentication Token
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showApiToken ? "text" : "password"}
|
||||
value={settings.api_token ?? ""}
|
||||
readOnly
|
||||
className="font-mono pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => setShowApiToken(!showApiToken)}
|
||||
>
|
||||
{showApiToken ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={settings.api_token ?? ""}
|
||||
successMessage="Token copied"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Include in Authorization header: Bearer {"<token>"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="mcp" className="space-y-4 mt-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="mcp-enabled"
|
||||
checked={settings.mcp_enabled && mcpConfig !== null}
|
||||
disabled={!termsAccepted || isMcpStarting}
|
||||
onCheckedChange={handleMcpToggle}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<Label
|
||||
htmlFor="mcp-enabled"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Enable MCP Server (Model Context Protocol)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow AI assistants like Claude Desktop to control browsers.
|
||||
{!termsAccepted && (
|
||||
<span className="ml-1 text-orange-600">
|
||||
(Accept Wayfern terms in Settings first)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mcpConfig && (
|
||||
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
Claude Desktop Configuration
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Copy this configuration to your Claude Desktop config file
|
||||
at{" "}
|
||||
<code className="bg-muted px-1 rounded">
|
||||
~/.config/claude/claude_desktop_config.json
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<pre className="p-3 text-xs font-mono rounded-md bg-background border overflow-x-auto whitespace-pre">
|
||||
{showMcpToken
|
||||
? getFormattedMcpConfig()
|
||||
: getObfuscatedMcpConfig()}
|
||||
</pre>
|
||||
<div className="absolute top-2 right-2 flex items-center space-x-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => setShowMcpToken(!showMcpToken)}
|
||||
>
|
||||
{showMcpToken ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<CopyToClipboard
|
||||
text={getFormattedMcpConfig()}
|
||||
successMessage="Configuration copied"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Available Tools</Label>
|
||||
<ul className="list-disc ml-5 space-y-0.5 text-xs text-muted-foreground">
|
||||
<li>list_profiles - List browser profiles</li>
|
||||
<li>run_profile - Launch a browser</li>
|
||||
<li>kill_profile - Stop a running browser</li>
|
||||
<li>get_profile_status - Check if browser is running</li>
|
||||
<li>list_groups, create_group, etc. - Manage groups</li>
|
||||
<li>list_proxies, create_proxy, etc. - Manage proxies</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
getBrowserIcon,
|
||||
getCurrentOS,
|
||||
} from "@/lib/browser-utils";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import { trimName } from "@/lib/name-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
@@ -1911,59 +1912,26 @@ export function ProfilesDataTable({
|
||||
id: "sync",
|
||||
header: "",
|
||||
size: 24,
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
|
||||
if (!profile.sync_enabled) {
|
||||
if (!profile.sync_enabled && profile.last_sync) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex justify-center items-center w-3 h-3">
|
||||
<span className="w-2 h-2 rounded-full bg-muted-foreground/30" />
|
||||
<span className="w-2 h-2 rounded-full bg-orange-500" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Sync disabled</TooltipContent>
|
||||
<TooltipContent>
|
||||
Sync is disabled, last sync{" "}
|
||||
{formatRelativeTime(profile.last_sync)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const syncStatus = meta.syncStatuses[profile.id];
|
||||
const isSyncing = syncStatus === "syncing";
|
||||
const isWaiting = syncStatus === "waiting";
|
||||
const isSynced =
|
||||
syncStatus === "synced" || (!syncStatus && profile.last_sync);
|
||||
const isError = syncStatus === "error";
|
||||
|
||||
let dotClass = "bg-yellow-500";
|
||||
let tooltipText = "Sync pending";
|
||||
|
||||
if (isSyncing) {
|
||||
dotClass = "bg-yellow-500 animate-pulse";
|
||||
tooltipText = "Syncing...";
|
||||
} else if (isWaiting) {
|
||||
dotClass = "bg-yellow-500";
|
||||
tooltipText = "Waiting for profile to stop";
|
||||
} else if (isError) {
|
||||
dotClass = "bg-red-500";
|
||||
tooltipText = "Sync error";
|
||||
} else if (isSynced) {
|
||||
dotClass = "bg-green-500";
|
||||
tooltipText = profile.last_sync
|
||||
? `Last synced: ${new Date(profile.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced";
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex justify-center items-center w-3 h-3">
|
||||
<span className={`w-2 h-2 rounded-full ${dotClass}`} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2031,25 +1999,6 @@ export function ProfilesDataTable({
|
||||
Copy Cookies to Profile
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{meta.onOpenProfileSyncDialog && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onOpenProfileSyncDialog?.(profile);
|
||||
}}
|
||||
>
|
||||
Sync Settings
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{meta.onToggleProfileSync && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onToggleProfileSync?.(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{profile.sync_enabled ? "Disable Sync" : "Enable Sync"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setProfileToDelete(profile);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { BsCamera, BsMic } from "react-icons/bs";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
ColorPicker,
|
||||
ColorPickerAlpha,
|
||||
@@ -40,7 +39,6 @@ import {
|
||||
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import {
|
||||
getThemeByColors,
|
||||
getThemeById,
|
||||
@@ -48,7 +46,6 @@ import {
|
||||
THEMES,
|
||||
} from "@/lib/themes";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { CopyToClipboard } from "./ui/copy-to-clipboard";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface AppSettings {
|
||||
@@ -76,9 +73,14 @@ interface PermissionInfo {
|
||||
interface SettingsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onIntegrationsOpen?: () => void;
|
||||
}
|
||||
|
||||
export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
export function SettingsDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onIntegrationsOpen,
|
||||
}: SettingsDialogProps) {
|
||||
const [settings, setSettings] = useState<AppSettings>({
|
||||
set_as_default_browser: false,
|
||||
theme: "system",
|
||||
@@ -109,7 +111,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
const [requestingPermission, setRequestingPermission] =
|
||||
useState<PermissionType | null>(null);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const [apiServerPort, setApiServerPort] = useState<number | null>(null);
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
const {
|
||||
@@ -117,10 +118,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
isMicrophoneAccessGranted,
|
||||
isCameraAccessGranted,
|
||||
} = usePermissions();
|
||||
const { termsAccepted } = useWayfernTerms();
|
||||
const { trialStatus } = useCommercialTrial();
|
||||
const [mcpEnabled, setMcpEnabled] = useState(false);
|
||||
const [isMcpStarting, setIsMcpStarting] = useState(false);
|
||||
|
||||
const getPermissionIcon = useCallback((type: PermissionType) => {
|
||||
switch (type) {
|
||||
@@ -352,48 +350,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Handle API server start/stop based on settings
|
||||
const wasApiEnabled = originalSettings.api_enabled;
|
||||
const isApiEnabled = settingsToSave.api_enabled;
|
||||
|
||||
if (isApiEnabled && !wasApiEnabled) {
|
||||
// Start API server
|
||||
try {
|
||||
const port = await invoke<number>("start_api_server", {
|
||||
port: settingsToSave.api_port,
|
||||
});
|
||||
setApiServerPort(port);
|
||||
showSuccessToast(`Local API started on port ${port}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to start API server:", error);
|
||||
showErrorToast("Failed to start API server", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
});
|
||||
// Revert the API enabled setting if start failed
|
||||
settingsToSave.api_enabled = false;
|
||||
const revertedSettings = await invoke<AppSettings>(
|
||||
"save_app_settings",
|
||||
{ settings: settingsToSave },
|
||||
);
|
||||
setSettings(revertedSettings);
|
||||
settingsToSave = revertedSettings;
|
||||
}
|
||||
} else if (!isApiEnabled && wasApiEnabled) {
|
||||
// Stop API server
|
||||
try {
|
||||
await invoke("stop_api_server");
|
||||
setApiServerPort(null);
|
||||
showSuccessToast("Local API stopped");
|
||||
} catch (error) {
|
||||
console.error("Failed to stop API server:", error);
|
||||
showErrorToast("Failed to stop API server", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setOriginalSettings(settingsToSave);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
@@ -401,7 +357,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [onClose, setTheme, settings, customThemeState, originalSettings]);
|
||||
}, [onClose, setTheme, settings, customThemeState]);
|
||||
|
||||
const updateSetting = useCallback(
|
||||
(
|
||||
@@ -413,26 +369,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
[],
|
||||
);
|
||||
|
||||
const loadApiServerStatus = useCallback(async () => {
|
||||
try {
|
||||
const port = await invoke<number | null>("get_api_server_status");
|
||||
setApiServerPort(port);
|
||||
} catch (error) {
|
||||
console.error("Failed to load API server status:", error);
|
||||
setApiServerPort(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMcpServerStatus = useCallback(async () => {
|
||||
try {
|
||||
const isRunning = await invoke<boolean>("get_mcp_server_status");
|
||||
setMcpEnabled(isRunning);
|
||||
} catch (error) {
|
||||
console.error("Failed to load MCP server status:", error);
|
||||
setMcpEnabled(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
// Restore original theme when closing without saving
|
||||
if (originalSettings.theme === "custom" && originalSettings.custom_theme) {
|
||||
@@ -470,8 +406,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
if (isOpen) {
|
||||
loadSettings().catch(console.error);
|
||||
checkDefaultBrowserStatus().catch(console.error);
|
||||
loadApiServerStatus().catch(console.error);
|
||||
loadMcpServerStatus().catch(console.error);
|
||||
|
||||
// Check if we're on macOS
|
||||
const userAgent = navigator.userAgent;
|
||||
@@ -492,14 +426,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}
|
||||
}, [
|
||||
isOpen,
|
||||
loadPermissions,
|
||||
checkDefaultBrowserStatus,
|
||||
loadSettings,
|
||||
loadApiServerStatus,
|
||||
loadMcpServerStatus,
|
||||
]);
|
||||
}, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]);
|
||||
|
||||
// Update permissions when the permission states change
|
||||
useEffect(() => {
|
||||
@@ -790,279 +717,20 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local API Section */}
|
||||
{/* Integrations Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Local API</Label>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="api-enabled"
|
||||
checked={settings.api_enabled}
|
||||
onCheckedChange={async (checked: boolean) => {
|
||||
updateSetting("api_enabled", checked);
|
||||
try {
|
||||
if (checked) {
|
||||
// Ask backend to enable API and return settings with token
|
||||
const next = await invoke<AppSettings>(
|
||||
"save_app_settings",
|
||||
{
|
||||
settings: { ...settings, api_enabled: true },
|
||||
},
|
||||
);
|
||||
setSettings(next);
|
||||
} else {
|
||||
const next = await invoke<AppSettings>(
|
||||
"save_app_settings",
|
||||
{
|
||||
settings: {
|
||||
...settings,
|
||||
api_enabled: false,
|
||||
api_token: null,
|
||||
},
|
||||
},
|
||||
);
|
||||
setSettings(next);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle API:", e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<Label
|
||||
htmlFor="api-enabled"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
(ALPHA) Enable Local API Server
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow managing the application data externally via REST API.
|
||||
Server will start on port 10108 or a random port if
|
||||
unavailable.
|
||||
{apiServerPort && (
|
||||
<span className="ml-1 font-medium text-green-600">
|
||||
(Currently running on port {apiServerPort})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.api_enabled && settings.api_token && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
API Authentication Token
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.api_token}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 font-mono text-sm rounded-md border bg-muted"
|
||||
/>
|
||||
<CopyToClipboard
|
||||
text={settings.api_token || ""}
|
||||
successMessage="API token copied to clipboard"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Include this token in the Authorization header as "Bearer{" "}
|
||||
{settings.api_token}" for all API requests.
|
||||
</p>
|
||||
{/* Temporary in-app API docs */}
|
||||
<div className="p-3 mt-3 space-y-2 text-xs leading-relaxed rounded-md border bg-muted/40">
|
||||
<div className="font-medium">
|
||||
Temporary in-app API docs (alpha)
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
Base URL:{" "}
|
||||
<code className="font-mono">{`http://127.0.0.1:${apiServerPort ?? settings.api_port ?? 10108}/v1`}</code>
|
||||
</div>
|
||||
<div>
|
||||
Auth:{" "}
|
||||
<code className="font-mono">
|
||||
Authorization: Bearer {settings.api_token}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">Profiles</div>
|
||||
<ul className="list-disc ml-5 space-y-0.5">
|
||||
<li>
|
||||
<code className="font-mono">GET /profiles</code> — list
|
||||
profiles
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
GET /profiles/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— get one
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">POST /profiles</code> —
|
||||
create
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
(required: name, browser, version; optional:
|
||||
release_type, proxy_id, camoufox_config, group_id,
|
||||
tags)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
PUT /profiles/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— update
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
(any of: name, version, proxy_id, camoufox_config,
|
||||
group_id, tags)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
DELETE /profiles/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— delete
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
POST /profiles/{"{"}id{"}"}/run
|
||||
</code>{" "}
|
||||
— launch with remote debugging
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
(body: {"{"}url?, headless?{"}"})
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
POST /profiles/{"{"}id{"}"}/open-url
|
||||
</code>{" "}
|
||||
— open URL in running profile
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
(body: {"{"}url{"}"})
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
POST /profiles/{"{"}id{"}"}/kill
|
||||
</code>{" "}
|
||||
— stop browser process
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">Groups</div>
|
||||
<ul className="list-disc ml-5 space-y-0.5">
|
||||
<li>
|
||||
<code className="font-mono">GET /groups</code> — list
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
GET /groups/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— get one
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">POST /groups</code> — create
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
(required: name)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
PUT /groups/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— rename
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
(required: name)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
DELETE /groups/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— delete
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">Tags</div>
|
||||
<ul className="list-disc ml-5 space-y-0.5">
|
||||
<li>
|
||||
<code className="font-mono">GET /tags</code> — list
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">Proxies</div>
|
||||
<ul className="list-disc ml-5 space-y-0.5">
|
||||
<li>
|
||||
<code className="font-mono">GET /proxies</code> — list
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
GET /proxies/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— get one
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">POST /proxies</code> —
|
||||
create
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
(required: name, proxy_settings object)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
PUT /proxies/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— update
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
(optional: name, proxy_settings)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
DELETE /proxies/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— delete
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">Browsers</div>
|
||||
<ul className="list-disc ml-5 space-y-0.5">
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
POST /browsers/download
|
||||
</code>{" "}
|
||||
— download
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
(required: browser, version)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
GET /browsers/{"{"}browser{"}"}/versions
|
||||
</code>{" "}
|
||||
— list versions
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
GET /browsers/{"{"}browser{"}"}/versions/{"{"}version
|
||||
{"}"}/downloaded
|
||||
</code>{" "}
|
||||
— is downloaded
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
These docs are temporary and will be replaced with full
|
||||
documentation later.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Label className="text-base font-medium">Integrations</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure Local API and MCP (Model Context Protocol) for
|
||||
integrating with external tools and AI assistants.
|
||||
</p>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={onIntegrationsOpen}
|
||||
>
|
||||
Open Integrations Settings
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{/* Commercial License Section */}
|
||||
@@ -1094,71 +762,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MCP Server Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">MCP Server</Label>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="mcp-enabled"
|
||||
checked={mcpEnabled}
|
||||
disabled={!termsAccepted || isMcpStarting}
|
||||
onCheckedChange={async (checked: boolean) => {
|
||||
setIsMcpStarting(true);
|
||||
try {
|
||||
if (checked) {
|
||||
await invoke("start_mcp_server");
|
||||
setMcpEnabled(true);
|
||||
showSuccessToast("MCP server started");
|
||||
} else {
|
||||
await invoke("stop_mcp_server");
|
||||
setMcpEnabled(false);
|
||||
showSuccessToast("MCP server stopped");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle MCP server:", e);
|
||||
showErrorToast("Failed to toggle MCP server", {
|
||||
description:
|
||||
e instanceof Error ? e.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsMcpStarting(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<Label
|
||||
htmlFor="mcp-enabled"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Enable MCP Server (Model Context Protocol)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow AI assistants to control Wayfern and Camoufox browsers
|
||||
via MCP.
|
||||
{!termsAccepted && (
|
||||
<span className="ml-1 text-orange-600">
|
||||
(Accept terms first)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mcpEnabled && (
|
||||
<div className="p-3 space-y-2 text-xs rounded-md border bg-muted/40">
|
||||
<div className="font-medium">Available MCP Tools</div>
|
||||
<ul className="list-disc ml-5 space-y-0.5 text-muted-foreground">
|
||||
<li>list_profiles - List Wayfern/Camoufox profiles</li>
|
||||
<li>run_profile - Launch a browser profile</li>
|
||||
<li>kill_profile - Stop a running browser</li>
|
||||
<li>get_profile - Get profile details</li>
|
||||
<li>list_proxies - List configured proxies</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Advanced</Label>
|
||||
|
||||
Reference in New Issue
Block a user