refactor: cleanup

This commit is contained in:
zhom committed 2026-09-23 05:20:41 +04:00
1 parent 1abed30af6
commit b7c8f56511
36 files changed
+2739 -583

No files matched your search

+70 -40
View File
@@ -27,6 +27,7 @@ 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 { McpMigrationDialog } from "@/components/mcp-migration-dialog";
import { ONBOARDING_TOUR } from "@/components/onboarding-provider";
import { PaidWelcomeDialog } from "@/components/paid-welcome-dialog";
import { PermissionDialog } from "@/components/permission-dialog";
@@ -66,6 +67,7 @@ import { useCommercialTrial } from "@/hooks/use-commercial-trial";
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
import { useGroupEvents } from "@/hooks/use-group-events";
import { useKonamiCode } from "@/hooks/use-konami-code";
import { useMcpMigrationOffer } from "@/hooks/use-mcp-migration-offer";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { useProfileEvents } from "@/hooks/use-profile-events";
@@ -353,22 +355,6 @@ export default function Home() {
trialStatus?.type === "Expired" &&
!trialAcknowledged &&
!crossOsUnlocked;
// Feature tips and the paid-plan welcome wait for a settled app: not the
// first-run session, terms accepted, nothing modal in the way.
const tipsFlow = useTips({
cloudUser,
loggedInAt: cloudLoggedInAt,
ready:
firstRunOnboarding === false &&
!profilesLoading &&
!welcomeOpen &&
!thankYouOpen &&
!isOnbordaVisible &&
!termsLoading &&
termsAccepted === true &&
!commercialTrialModalOpen,
});
const { openTips, closeTips } = tipsFlow;
// Bulk run/stop is a paid (browser automation) feature, matching the
// /v1/profiles/batch/run API gate. Free/solo users see the bulk Run/Stop
// actions disabled with a Pro badge.
@@ -377,10 +363,11 @@ export default function Home() {
// to the shared cookie-bot store too. It is a module singleton, so this costs
// one more listener and no extra request. This is also what starts the event
// stream for a user who signs in without restarting the app.
const { liveSessions: cookieBotLiveSessions } = useCookieBot(
canUseCookieBot(cloudUser),
cookieBotScopeFor(cloudUser),
);
const {
liveSessions: cookieBotLiveSessions,
schedules: cookieBotSchedules,
isLoading: cookieBotLoading,
} = useCookieBot(canUseCookieBot(cloudUser), cookieBotScopeFor(cloudUser));
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
useState(false);
@@ -404,6 +391,63 @@ export default function Home() {
const cloudBackupUnlocked = getEntitlements(cloudUser).cloudBackup;
const syncUnlocked = cloudBackupUnlocked || selfHostedSyncConfigured;
// Feature tips and the paid-plan welcome wait for a settled app: not the
// first-run session, terms accepted, nothing modal in the way. The usage
// lists let the automatic tip skip a feature the user already has in use.
const tipUsage = useMemo(
() => ({
loaded: !groupsLoading && !proxiesLoading && !cookieBotLoading,
profiles,
groupCount: groupsData.length,
extensionGroupCount: extensionGroups.length,
cookieBotEnrolled:
Object.keys(cookieBotSchedules).length > 0 ||
Object.keys(cookieBotLiveSessions).length > 0,
syncServerConfigured: selfHostedSyncConfigured,
proxyIds: storedProxies.map((proxy) => proxy.id),
}),
[
groupsLoading,
proxiesLoading,
cookieBotLoading,
profiles,
groupsData.length,
extensionGroups.length,
cookieBotSchedules,
cookieBotLiveSessions,
selfHostedSyncConfigured,
storedProxies,
],
);
const settledLaunch =
firstRunOnboarding === false &&
!profilesLoading &&
!welcomeOpen &&
!thankYouOpen &&
!isOnbordaVisible &&
!termsLoading &&
termsAccepted === true &&
!commercialTrialModalOpen;
// The remote MCP move and the automatic tip never open on the same launch:
// the move holds the tip back until it has decided, and an automatic tip
// that opened first keeps the move for another launch.
const [autoTipOpened, setAutoTipOpened] = useState(false);
const mcpMigration = useMcpMigrationOffer({
ready: settledLaunch && !autoTipOpened,
accountId: cloudUser?.id ?? null,
});
const tipsFlow = useTips({
cloudUser,
loggedInAt: cloudLoggedInAt,
ready: settledLaunch && !mcpMigration.open,
autoTipHeld: mcpMigration.holdsLaunch,
usage: tipUsage,
});
const { openTips, closeTips } = tipsFlow;
useEffect(() => {
if (tipsFlow.dialog.open && tipsFlow.dialog.auto) setAutoTipOpened(true);
}, [tipsFlow.dialog.open, tipsFlow.dialog.auto]);
const [currentPage, setCurrentPage] = useState<AppPage>("profiles");
const [accountDialogOpen, setAccountDialogOpen] = useState(false);
// Tracks which tab inside the shared proxy-management page should be active.
@@ -2050,7 +2094,6 @@ export default function Home() {
let unlistenWayfernBlocked: (() => void) | undefined;
let unlistenGenerationLimit: (() => void) | undefined;
let unlistenMcpLocalDeprecated: (() => void) | undefined;
let unlistenMcpLocalMigrated: (() => void) | undefined;
void (async () => {
unlistenRequired = await listen(
@@ -2149,23 +2192,6 @@ export default function Home() {
});
});
// Their clients were moved to remote MCP for them (paid accounts). A
// success note so the change is visible rather than silent.
unlistenMcpLocalMigrated = await listen<{ migrated?: number }>(
"mcp-local-migrated",
(event) => {
const migrated = event.payload?.migrated ?? 0;
if (migrated <= 0) return;
showToast({
id: "mcp-local-migrated",
type: "success",
title: t("mcpLocalMigrated.title"),
description: t("mcpLocalMigrated.description", { count: migrated }),
duration: 12000,
});
},
);
// If the effect was torn down mid-setup, the cleanup below already ran
// before these handles existed — unlisten them now so nothing leaks.
if (disposed) {
@@ -2176,7 +2202,6 @@ export default function Home() {
unlistenWayfernBlocked?.();
unlistenGenerationLimit?.();
unlistenMcpLocalDeprecated?.();
unlistenMcpLocalMigrated?.();
}
})();
@@ -2189,7 +2214,6 @@ export default function Home() {
unlistenWayfernBlocked?.();
unlistenGenerationLimit?.();
unlistenMcpLocalDeprecated?.();
unlistenMcpLocalMigrated?.();
};
}, [t]);
@@ -2624,6 +2648,12 @@ export default function Home() {
openTips(id);
}}
/>
<McpMigrationDialog
open={mcpMigration.open}
onOpenChange={(open) => {
if (!open) mcpMigration.close();
}}
/>
<CloneProfileDialog
isOpen={!!cloneProfile}
+137 -209
View File
@@ -5,25 +5,10 @@ import { listen } from "@tauri-apps/api/event";
import { Eye, EyeOff } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { IconType } from "react-icons";
import {
LuAppWindow,
LuCheck,
LuCloud,
LuCodeXml,
LuPlug,
LuTerminal,
LuTrash2,
LuZap,
} from "react-icons/lu";
import {
SiClaude,
SiCursor,
SiWindsurf,
SiZedindustries,
} from "react-icons/si";
import { VscVscode } from "react-icons/vsc";
import { LuCheck, LuCloud, LuPlug, LuTrash2, LuZap } from "react-icons/lu";
import { IntegrationDiagnostics } from "@/components/integration-diagnostics";
import { AgentIcon } from "@/components/mcp-agent-icon";
import { McpMigrationDialog } from "@/components/mcp-migration-dialog";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
import {
AnimatedTabs,
@@ -45,21 +30,23 @@ import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import { canUseRemoteControl } from "@/lib/entitlements";
import {
type AgentCategory,
credentialPrefixOf,
FX_AGENT_ID,
fxExportLine,
localMcpClients,
type McpAgentInfo,
type McpEndpoint,
type McpRemoteCredential,
type McpRemoteCredentialRotation,
type McpRemoteStatus,
REMOTE_MCP_URL,
} from "@/lib/mcp";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import { CopyToClipboard } from "./ui/copy-to-clipboard";
/** Where an agent points to drive this browser through Donut cloud. */
const REMOTE_MCP_URL = "https://api.donutbrowser.com/api/mcp";
/**
* fx refuses a literal Authorization header in its config and reads the
* bearer token from this variable instead, so its install cannot carry the
* credential and the user has to export it themselves.
*/
const FX_TOKEN_ENV = "DONUT_MCP_TOKEN";
const FX_AGENT_ID = "fx";
interface AppSettings {
api_enabled: boolean;
api_port: number;
@@ -77,57 +64,6 @@ interface McpConfig {
token: string;
}
interface McpRemoteStatus {
enabled: boolean;
connected: boolean;
instanceId: string;
lastError: string | null;
}
/**
* Only the prefix leaves the backend; the plaintext is installed into agent
* configs by the app itself. Both spellings are read because the spec writes
* the Rust field as `token_prefix` while the sibling status struct serialises
* camelCase, and a mismatch here would silently render a present credential
* as missing.
*/
interface McpRemoteCredential {
present: boolean;
tokenPrefix?: string | null;
token_prefix?: string | null;
}
/**
* What a rotation answers. The key is stored and installed by the time this
* arrives; `failed_clients` are the ids of the clients whose config could not
* be rewritten, for the user to retry, never a reason to mint again.
*/
interface McpRemoteCredentialRotation
extends Pick<McpRemoteCredential, "tokenPrefix" | "token_prefix"> {
failed_clients?: string[];
}
function credentialPrefixOf(
credential: Pick<McpRemoteCredential, "tokenPrefix" | "token_prefix"> | null,
): string | null {
return credential?.tokenPrefix ?? credential?.token_prefix ?? null;
}
type AgentCategory = "desktop-app" | "cli" | "editor" | "editor-ext";
/** The two places a client can be pointed at; the `target` of `add_mcp_to_agent`. */
type McpEndpoint = "local" | "remote";
interface McpAgentInfo {
id: string;
display_name: string;
category: AgentCategory;
connected: boolean;
detected: boolean;
/** Which Donut endpoint the agent's existing entry points at, when connected. */
endpoint?: McpEndpoint | null;
}
type IntegrationsTab = "api" | "mcp" | "remote";
function otherEndpoint(endpoint: McpEndpoint): McpEndpoint {
@@ -142,33 +78,6 @@ interface IntegrationsDialogProps {
initialTab?: IntegrationsTab;
}
function AgentIcon({ category, id }: { category: AgentCategory; id: string }) {
const className = "size-5 shrink-0 text-muted-foreground";
const marks: Record<string, IconType> = {
"claude-desktop": SiClaude,
"claude-code": SiClaude,
cursor: SiCursor,
vscode: VscVscode,
windsurf: SiWindsurf,
zed: SiZedindustries,
};
const brand = marks[id];
if (brand) {
const Mark = brand;
return <Mark aria-hidden="true" className={className} />;
}
switch (category) {
case "desktop-app":
return <LuAppWindow className={className} />;
case "editor":
return <LuCodeXml className={className} />;
case "editor-ext":
return <LuPlug className={className} />;
case "cli":
return <LuTerminal className={className} />;
}
}
function categoryLabel(
t: (k: string) => string,
category: AgentCategory,
@@ -220,6 +129,7 @@ export function IntegrationsDialog({
const [activeTab, setActiveTab] = useState<IntegrationsTab>(initialTab);
// Local MCP is removed; an in-app attempt to enable it opens this dialog.
const [localDeprecatedOpen, setLocalDeprecatedOpen] = useState(false);
const [migrationOpen, setMigrationOpen] = useState(false);
// Mod+I re-targets an open dialog through `initialTab`. The tabs are
// controlled (see `shownTab`), so a remount would not adopt the new value;
// this is React's adjust-state-on-prop-change form of the same thing.
@@ -247,7 +157,7 @@ export function IntegrationsDialog({
const [serverEntitled, setServerEntitled] = useState<boolean | null>(null);
const remoteEntitled = serverEntitled ?? canUseRemoteControl(user);
// The local cache is only a placeholder until the server answers; treating
// "not yet known" as "no" renders an upgrade prompt at somebody who has paid.
// "not yet known" as "no" hides the endpoint from somebody who has paid.
const remoteEntitlementKnown =
serverEntitled !== null || (!authLoading && user !== null);
@@ -629,9 +539,14 @@ export function IntegrationsDialog({
` -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \\`,
` ${REMOTE_MCP_URL}`,
].join("\n");
const fxExportLine = settings.mcp_remote_key
? `export ${FX_TOKEN_ENV}=${settings.mcp_remote_key}`
: null;
const fxLine = fxExportLine(settings.mcp_remote_key);
// The move off the local server is offered here whenever it applies, on the
// server's word only: the once-per-account automatic offer does not limit it.
const migrationOffered =
isLoggedIn &&
serverEntitled === true &&
(settings.mcp_enabled || localMcpClients(agents).length > 0);
// Remote control is not something a regular user is told about: the tab
// exists only for an account entitled to it, or while the bridge is already
@@ -782,12 +697,12 @@ export function IntegrationsDialog({
<p className="text-xs text-muted-foreground">
{t("integrations.remote.fxHint")}
</p>
{fxExportLine && (
{fxLine && (
<CopyToClipboard
variant="ghost"
size="sm"
className="shrink-0"
text={fxExportLine}
text={fxLine}
successMessage={t("integrations.remote.fxExportCopied")}
/>
)}
@@ -1130,99 +1045,87 @@ export function IntegrationsDialog({
)}
</div>
{remote?.enabled && (
<>
{/* Everything below is gated on entitlement: handing an
unentitled customer a URL, a credential or an "Add"
button that can only answer 402 is the same
wrong-diagnosis trap as telling them their desktop is
offline. The warning explains why it is not shown. */}
{remoteEntitlementKnown && !remoteEntitled && (
<div className="rounded-md border border-warning/50 bg-warning/10 p-4">
<p className="text-xs text-warning-text">
{t("integrations.remote.notEntitled")}
{/* Gated on entitlement: handing an unentitled customer a
URL, a credential or an "Add" button that can only answer
402 is the same wrong-diagnosis trap as telling them their
desktop is offline. */}
{remote?.enabled &&
(remoteEntitled || !remoteEntitlementKnown) && (
<>
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
<div className="flex items-center justify-between gap-3">
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("integrations.remote.credentialLabel")}
</Label>
<Button
size="sm"
variant="outline"
disabled={isRotatingCredential}
onClick={() => void handleRotateCredential()}
>
{credentialPresent
? t("integrations.remote.credentialRotate")
: t("integrations.remote.credentialCreate")}
</Button>
</div>
{credentialPresent ? (
credentialPrefix && (
<code className="w-fit rounded bg-muted px-2 py-1 font-mono text-[11px]">
{t("integrations.remote.credentialPrefix", {
prefix: credentialPrefix,
})}
</code>
)
) : (
<p className="text-xs text-muted-foreground">
{t("integrations.remote.credentialNone")}
</p>
)}
<p className="text-xs text-muted-foreground">
{t("integrations.remote.credentialHint")}
</p>
</div>
)}
{(remoteEntitled || !remoteEntitlementKnown) && (
<>
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
<div className="flex items-center justify-between gap-3">
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("integrations.remote.credentialLabel")}
</Label>
<Button
size="sm"
variant="outline"
disabled={isRotatingCredential}
onClick={() => void handleRotateCredential()}
>
{credentialPresent
? t("integrations.remote.credentialRotate")
: t("integrations.remote.credentialCreate")}
</Button>
</div>
{credentialPresent ? (
credentialPrefix && (
<code className="w-fit rounded bg-muted px-2 py-1 font-mono text-[11px]">
{t("integrations.remote.credentialPrefix", {
prefix: credentialPrefix,
})}
</code>
)
) : (
<p className="text-xs text-muted-foreground">
{t("integrations.remote.credentialNone")}
</p>
)}
<p className="text-xs text-muted-foreground">
{t("integrations.remote.credentialHint")}
</p>
</div>
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
<div className="flex items-center justify-between">
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("integrations.remote.endpointLabel")}
</Label>
<CopyToClipboard
text={REMOTE_MCP_URL}
successMessage={t(
"integrations.remote.endpointCopied",
)}
/>
</div>
<Input
value={REMOTE_MCP_URL}
readOnly
className="font-mono text-xs"
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
<div className="flex items-center justify-between">
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("integrations.remote.endpointLabel")}
</Label>
<CopyToClipboard
text={REMOTE_MCP_URL}
successMessage={t(
"integrations.remote.endpointCopied",
)}
/>
<p className="text-xs text-muted-foreground">
{t("integrations.remote.endpointDescription")}
</p>
</div>
<Input
value={REMOTE_MCP_URL}
readOnly
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
{t("integrations.remote.endpointDescription")}
</p>
</div>
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
<div className="flex items-center justify-between">
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("integrations.remote.exampleRequest")}
</Label>
<CopyToClipboard
text={remoteExampleRequest}
successMessage={t("common.buttons.copied")}
/>
</div>
<pre className="overflow-x-auto rounded bg-background p-3 font-mono text-[11px] whitespace-pre">
{remoteExampleRequest}
</pre>
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
<div className="flex items-center justify-between">
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("integrations.remote.exampleRequest")}
</Label>
<CopyToClipboard
text={remoteExampleRequest}
successMessage={t("common.buttons.copied")}
/>
</div>
<pre className="overflow-x-auto rounded bg-background p-3 font-mono text-[11px] whitespace-pre">
{remoteExampleRequest}
</pre>
</div>
{clientsGrid("remote")}
</>
)}
</>
)}
{clientsGrid("remote")}
</>
)}
</AnimatedTabsContent>
)}
@@ -1238,17 +1141,31 @@ export function IntegrationsDialog({
{t("integrations.mcp.deprecatedBannerBody")}
</p>
<div>
<Button
type="button"
size="sm"
variant="secondary"
className="mt-1"
onClick={() => {
setActiveTab("remote");
}}
>
{t("integrations.mcp.deprecatedCta")}
</Button>
{migrationOffered ? (
<Button
type="button"
size="sm"
className="mt-1"
data-slot="mcp-migration-open"
onClick={() => {
setMigrationOpen(true);
}}
>
{t("mcpMigration.start")}
</Button>
) : (
<Button
type="button"
size="sm"
variant="secondary"
className="mt-1"
onClick={() => {
setActiveTab("remote");
}}
>
{t("integrations.mcp.deprecatedCta")}
</Button>
)}
</div>
</div>
@@ -1356,6 +1273,17 @@ export function IntegrationsDialog({
</div>
</DialogContent>
</Dialog>
<McpMigrationDialog
open={migrationOpen}
onOpenChange={setMigrationOpen}
onChanged={() => {
void loadSettings();
void loadAgents();
void loadRemoteStatus();
void loadCredential();
void loadMcpConfig();
}}
/>
</>
);
}
+44
View File
@@ -0,0 +1,44 @@
import type { IconType } from "react-icons";
import { LuAppWindow, LuCodeXml, LuPlug, LuTerminal } from "react-icons/lu";
import {
SiClaude,
SiCursor,
SiWindsurf,
SiZedindustries,
} from "react-icons/si";
import { VscVscode } from "react-icons/vsc";
import type { AgentCategory } from "@/lib/mcp";
const BRAND_MARKS: Record<string, IconType> = {
"claude-desktop": SiClaude,
"claude-code": SiClaude,
cursor: SiCursor,
vscode: VscVscode,
windsurf: SiWindsurf,
zed: SiZedindustries,
};
/** The client's own mark when it has one, otherwise a mark for its kind. */
export function AgentIcon({
category,
id,
}: {
category: AgentCategory;
id: string;
}) {
const className = "size-5 shrink-0 text-muted-foreground";
const Mark = BRAND_MARKS[id];
if (Mark) {
return <Mark aria-hidden="true" className={className} />;
}
switch (category) {
case "desktop-app":
return <LuAppWindow aria-hidden="true" className={className} />;
case "editor":
return <LuCodeXml aria-hidden="true" className={className} />;
case "editor-ext":
return <LuPlug aria-hidden="true" className={className} />;
case "cli":
return <LuTerminal aria-hidden="true" className={className} />;
}
}
+521
View File
@@ -0,0 +1,521 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuCheck } from "react-icons/lu";
import { AgentIcon } from "@/components/mcp-agent-icon";
import { Button } from "@/components/ui/button";
import { CopyToClipboard } from "@/components/ui/copy-to-clipboard";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { OperationFlow } from "@/components/ui/operation-flow";
import { translateBackendError } from "@/lib/backend-errors";
import {
credentialPrefixOf,
FX_AGENT_ID,
fxExportLine,
localMcpClients,
type McpAgentInfo,
type McpRemoteCredential,
type McpRemoteCredentialRotation,
type McpRemoteStatus,
REMOTE_MCP_URL,
} from "@/lib/mcp";
import { showErrorToast } from "@/lib/toast-utils";
type ClientResult =
| { state: "moving" }
| { state: "moved" }
| { state: "failed"; message: string };
/** The stations of the move: remote MCP on, a credential, the clients. */
const STAGE_BRIDGE = 0;
const STAGE_CREDENTIAL = 1;
const STAGE_CLIENTS = 2;
interface SettingsSlice {
mcp_enabled: boolean;
mcp_remote_key?: string | null;
}
interface MigrationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/**
* Something on disk changed (clients moved, the local server turned off),
* so the owner can reload what it shows.
*/
onChanged?: () => void;
}
/**
* Moves every client off the removed local MCP server to remote MCP, in the
* app: remote MCP on, a credential, then each client rewritten, each with its
* own result. The body mounts with the dialog, so every opening starts fresh.
*/
export function McpMigrationDialog({
open,
onOpenChange,
onChanged,
}: MigrationDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
data-slot="mcp-migration"
className="p-5 sm:max-w-lg sm:p-6"
>
<MigrationBody
onClose={() => onOpenChange(false)}
onChanged={onChanged}
/>
</DialogContent>
</Dialog>
);
}
function MigrationBody({
onClose,
onChanged,
}: {
onClose: () => void;
onChanged?: () => void;
}) {
const { t } = useTranslation();
// Fixed when the dialog opens, so a moved client keeps its row.
const [clients, setClients] = useState<McpAgentInfo[] | null>(null);
const [results, setResults] = useState<Record<string, ClientResult>>({});
const [bridgeOn, setBridgeOn] = useState(false);
const [credential, setCredential] = useState<{
prefix: string | null;
} | null>(null);
const [remoteKey, setRemoteKey] = useState<string | null>(null);
const [localServerOn, setLocalServerOn] = useState(false);
const [phase, setPhase] = useState<"intro" | "running" | "finished">("intro");
const [stage, setStage] = useState(STAGE_BRIDGE);
const [stageError, setStageError] = useState<string | null>(null);
const [turningOff, setTurningOff] = useState(false);
const [turnedOff, setTurnedOff] = useState(false);
useEffect(() => {
let cancelled = false;
const quiet = <T,>(read: Promise<T>) =>
read.catch((error: unknown) => {
console.error("Failed to read the remote MCP state:", error);
return null;
});
void Promise.all([
quiet(invoke<McpAgentInfo[]>("list_mcp_agents")),
quiet(invoke<SettingsSlice>("get_app_settings")),
quiet(invoke<McpRemoteStatus>("get_mcp_remote_status")),
quiet(invoke<McpRemoteCredential>("get_mcp_remote_credential")),
]).then(([agents, settings, remote, stored]) => {
if (cancelled) return;
setClients(localMcpClients(agents ?? []));
setLocalServerOn(settings?.mcp_enabled ?? false);
setRemoteKey(settings?.mcp_remote_key ?? null);
setBridgeOn(remote?.enabled ?? false);
if (stored?.present)
setCredential({ prefix: credentialPrefixOf(stored) });
});
return () => {
cancelled = true;
};
}, []);
const setResult = (id: string, result: ClientResult) =>
setResults((previous) => ({ ...previous, [id]: result }));
/**
* One pass of the move. A retry runs it again: the bridge and the
* credential are skipped once they are in place, and a client that moved
* is not written twice.
*/
const run = async () => {
if (!clients) return;
setPhase("running");
setStageError(null);
try {
if (!bridgeOn) {
setStage(STAGE_BRIDGE);
const status = await invoke<McpRemoteStatus>("start_mcp_remote_bridge");
setBridgeOn(status.enabled);
}
if (!credential) {
setStage(STAGE_CREDENTIAL);
// Asked again rather than trusted from the opening: minting when a
// key exists would retire the key other clients already carry.
const stored = await invoke<McpRemoteCredential>(
"get_mcp_remote_credential",
);
if (stored.present) {
setCredential({ prefix: credentialPrefixOf(stored) });
} else {
const minted = await invoke<McpRemoteCredentialRotation>(
"rotate_mcp_remote_credential",
);
setCredential({ prefix: credentialPrefixOf(minted) });
}
}
} catch (error) {
setStageError(translateBackendError(t, error));
setPhase("finished");
return;
}
setStage(STAGE_CLIENTS);
for (const client of clients) {
if (results[client.id]?.state === "moved") continue;
setResult(client.id, { state: "moving" });
try {
await invoke("add_mcp_to_agent", {
agentId: client.id,
target: "remote",
});
setResult(client.id, { state: "moved" });
} catch (error) {
setResult(client.id, {
state: "failed",
message: translateBackendError(t, error),
});
}
}
// The plaintext behind the credential copy and the fx export line.
try {
const settings = await invoke<SettingsSlice>("get_app_settings");
setRemoteKey(settings.mcp_remote_key ?? null);
setLocalServerOn(settings.mcp_enabled);
} catch (error) {
console.error("Failed to reload the settings after the move:", error);
}
setPhase("finished");
onChanged?.();
};
const turnOffLocalServer = async () => {
setTurningOff(true);
try {
await invoke("turn_off_local_mcp_server");
setLocalServerOn(false);
setTurnedOff(true);
onChanged?.();
} catch (error) {
showErrorToast(t("mcpMigration.localServerOffFailed"), {
description: translateBackendError(t, error),
});
} finally {
setTurningOff(false);
}
};
const total = clients?.length ?? 0;
const countIn = (state: ClientResult["state"]) =>
clients?.filter((client) => results[client.id]?.state === state).length ??
0;
const moved = countIn("moved");
const failed = countIn("failed");
const running = phase === "running";
const finished = phase === "finished";
const succeeded = finished && stageError === null && failed === 0;
const waiting = t("appFeedback.waiting");
const fxLine = fxExportLine(remoteKey);
const steps = [
{
id: "bridge",
label: t("mcpMigration.stepBridge"),
detail: bridgeOn
? t("mcpMigration.bridgeOn")
: running && stage === STAGE_BRIDGE
? t("mcpMigration.bridgeWorking")
: waiting,
},
{
id: "credential",
label: t("integrations.remote.credentialLabel"),
detail: credential ? (
credential.prefix ? (
<span className="font-mono">
{t("integrations.remote.credentialPrefix", {
prefix: credential.prefix,
})}
</span>
) : (
t("appFeedback.configured")
)
) : running && stage === STAGE_CREDENTIAL ? (
t("mcpMigration.credentialWorking")
) : (
waiting
),
},
{
id: "clients",
label: t("mcpMigration.stepClients"),
detail:
total === 0
? t("mcpMigration.clientsNone")
: t("mcpMigration.clientsProgress", { moved, total }),
},
];
return (
<div className="flex min-w-0 flex-col gap-5">
<DialogHeader>
<DialogTitle>
{succeeded ? t("mcpMigration.doneTitle") : t("mcpMigration.title")}
</DialogTitle>
{phase === "intro" && (
<DialogDescription className="text-pretty">
{t("mcpMigration.body")}
</DialogDescription>
)}
</DialogHeader>
{phase !== "intro" && (
<OperationFlow
label={t("mcpMigration.title")}
steps={steps}
active={finished && stageError === null ? STAGE_CLIENTS : stage}
failed={finished && !succeeded}
busy={running}
/>
)}
{clients !== null && (
<div className="flex min-w-0 flex-col gap-2">
{phase === "intro" && (
<p className="text-sm text-pretty">
{total > 0
? t("mcpMigration.changes")
: t("mcpMigration.changesNone")}
</p>
)}
{total > 0 && (
<ul data-slot="mcp-migration-clients" className="flex flex-col">
{clients.map((client) => {
const result = results[client.id];
return (
<li
key={client.id}
data-slot="mcp-migration-client"
data-agent-id={client.id}
data-state={result?.state ?? "waiting"}
className="flex min-w-0 items-start gap-3 py-1.5"
>
<AgentIcon id={client.id} category={client.category} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{client.display_name}
</p>
{result?.state === "failed" && (
<p className="text-xs break-words text-destructive-text">
{result.message}
</p>
)}
{client.id === FX_AGENT_ID &&
result?.state === "moved" && (
<div className="flex items-center gap-2">
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
{t("integrations.remote.fxHint")}
</p>
{fxLine && (
<CopyToClipboard
variant="ghost"
className="size-7 shrink-0"
text={fxLine}
successMessage={t(
"integrations.remote.fxExportCopied",
)}
/>
)}
</div>
)}
</div>
{phase !== "intro" && (
<ClientStatus result={result} waiting={waiting} />
)}
</li>
);
})}
</ul>
)}
</div>
)}
{/* Mounted from the start, so the outcome is announced when it lands. */}
<div
aria-live="polite"
className="flex min-w-0 flex-col gap-3 empty:hidden"
>
{finished && stageError && (
<p className="text-sm break-words text-destructive-text">
{stageError}
</p>
)}
{finished && stageError === null && (moved > 0 || failed > 0) && (
<p data-slot="mcp-migration-summary" className="text-sm">
{failed > 0
? t("mcpMigration.failed", { count: failed })
: t("mcpMigration.summary", { count: moved })}
</p>
)}
{finished && stageError === null && credential && (
<div
data-slot="mcp-migration-manual"
className="flex min-w-0 flex-col gap-2 rounded-md bg-muted/50 px-3 py-2.5"
>
<dl className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1">
<dt className="text-xs text-muted-foreground">
{t("integrations.remote.endpointLabel")}
</dt>
<dd className="font-mono text-xs break-all">{REMOTE_MCP_URL}</dd>
<dd>
<CopyToClipboard
variant="ghost"
className="size-7"
text={REMOTE_MCP_URL}
successMessage={t("integrations.remote.endpointCopied")}
/>
</dd>
{remoteKey && (
<>
<dt className="text-xs text-muted-foreground">
{t("integrations.remote.credentialLabel")}
</dt>
<dd className="font-mono text-xs break-all">
{t("integrations.remote.credentialPrefix", {
prefix: credential.prefix ?? remoteKey.slice(0, 12),
})}
</dd>
<dd>
<CopyToClipboard
variant="ghost"
className="size-7"
text={remoteKey}
successMessage={t("mcpMigration.credentialCopied")}
/>
</dd>
</>
)}
</dl>
<p className="text-xs text-pretty text-muted-foreground">
{t("mcpMigration.manual")}
</p>
</div>
)}
{succeeded && (localServerOn || turnedOff) && (
<div
data-slot="mcp-migration-local"
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2"
>
{turnedOff ? (
<p className="inline-flex items-center gap-1.5 text-xs font-medium">
<LuCheck aria-hidden="true" className="size-3.5" />
{t("mcpMigration.localServerIsOff")}
</p>
) : (
<>
<p className="min-w-0 flex-1 text-xs text-pretty text-muted-foreground">
{t("mcpMigration.localServer")}
</p>
<Button
type="button"
size="sm"
variant="outline"
data-slot="mcp-migration-local-off"
disabled={turningOff}
onClick={() => void turnOffLocalServer()}
>
{t("mcpMigration.localServerOff")}
</Button>
</>
)}
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
{(phase === "intro" || (finished && !succeeded)) && (
<Button
type="button"
variant="ghost"
size="sm"
data-slot="mcp-migration-dismiss"
className="text-muted-foreground hover:text-foreground"
onClick={onClose}
>
{phase === "intro"
? t("mcpMigration.notNow")
: t("common.buttons.close")}
</Button>
)}
{succeeded ? (
<Button
type="button"
size="sm"
data-slot="mcp-migration-done"
className="ml-auto"
onClick={onClose}
>
{t("mcpMigration.done")}
</Button>
) : (
<Button
type="button"
size="sm"
data-slot="mcp-migration-start"
className="ml-auto"
disabled={clients === null || running}
onClick={() => void run()}
>
{finished ? t("common.buttons.retry") : t("mcpMigration.start")}
</Button>
)}
</div>
</div>
);
}
function ClientStatus({
result,
waiting,
}: {
result: ClientResult | undefined;
waiting: string;
}) {
const { t } = useTranslation();
switch (result?.state) {
case "moved":
return (
<span className="inline-flex shrink-0 items-center gap-1 text-xs leading-5 font-medium text-foreground">
<LuCheck aria-hidden="true" className="size-3.5" />
{t("mcpMigration.clientMoved")}
</span>
);
case "failed":
return (
<span className="shrink-0 text-xs leading-5 text-destructive-text">
{t("mcpMigration.clientFailed")}
</span>
);
case "moving":
return (
<span className="shrink-0 text-xs leading-5 text-muted-foreground">
{t("mcpMigration.clientMoving")}
</span>
);
default:
return (
<span className="shrink-0 text-xs leading-5 text-muted-foreground">
{waiting}
</span>
);
}
}
+52 -39
View File
@@ -25,7 +25,10 @@ export type TipsDialogMode = "browse" | "single";
interface TipsDialogProps {
open: boolean;
/** `browse` shows the whole catalog beside the tip; `single` is one card. */
/**
* `browse` shows the whole catalog beside the tip and pages through it;
* `single` is one card and nothing more, the way the automatic flow opens.
*/
mode: TipsDialogMode;
tips: TipDefinition[];
seen: string[];
@@ -50,7 +53,8 @@ function isTypingTarget(target: EventTarget | null): boolean {
/**
* Feature tips: a drawing of the feature in motion, a few lines on what it
* does for the user, and a button into the place it lives. The catalog on
* the left is only there in browse mode; the automatic flow is one card.
* the left and the pager are only there in browse mode; the automatic flow
* is one card that closes when the user is done with it.
*/
export function TipsDialog({
open,
@@ -79,6 +83,7 @@ export function TipsDialog({
const [index, setIndex] = useState(initialIndex);
const [direction, setDirection] = useState<1 | -1>(1);
const tip = tips[Math.min(index, Math.max(0, total - 1))];
const single = mode === "single";
const mod = isMacOS() ? "⌘" : "Ctrl";
// Each tip is reported once as it comes on screen, so the automatic flow
@@ -93,8 +98,9 @@ export function TipsDialog({
if (!tip) return null;
const keys = tipTextKeys(tip.id);
const isLast = index >= total - 1;
const finishes = single || index >= total - 1;
const go = (next: number) => {
if (single) return;
const clamped = Math.max(0, Math.min(total - 1, next));
if (clamped === index) return;
setDirection(clamped > index ? 1 : -1);
@@ -153,39 +159,46 @@ export function TipsDialog({
</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="size-8 p-0 text-muted-foreground hover:text-foreground"
aria-label={t("tips.previous")}
data-slot="tip-previous"
disabled={index === 0}
onClick={() => go(index - 1)}
>
<LuChevronLeft className="size-4" />
</Button>
<span
className="min-w-[5ch] text-center text-xs text-muted-foreground tabular-nums"
aria-live="polite"
>
{t("tips.count", { current: index + 1, total })}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="size-8 p-0 text-muted-foreground hover:text-foreground"
aria-label={t("tips.next")}
data-slot="tip-next"
disabled={isLast}
onClick={() => go(index + 1)}
>
<LuChevronRight className="size-4" />
</Button>
</div>
<div
className={cn(
"flex flex-wrap items-center gap-3",
single ? "justify-end" : "justify-between",
)}
>
{!single && (
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="size-8 p-0 text-muted-foreground hover:text-foreground"
aria-label={t("tips.previous")}
data-slot="tip-previous"
disabled={index === 0}
onClick={() => go(index - 1)}
>
<LuChevronLeft className="size-4" />
</Button>
<span
className="min-w-[5ch] text-center text-xs text-muted-foreground tabular-nums"
aria-live="polite"
>
{t("tips.count", { current: index + 1, total })}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="size-8 p-0 text-muted-foreground hover:text-foreground"
aria-label={t("tips.next")}
data-slot="tip-next"
disabled={index >= total - 1}
onClick={() => go(index + 1)}
>
<LuChevronRight className="size-4" />
</Button>
</div>
)}
<div className="flex items-center gap-2">
<Button
type="button"
@@ -201,11 +214,11 @@ export function TipsDialog({
size="sm"
data-slot="tip-advance"
onClick={() => {
if (isLast) onOpenChange(false);
if (finishes) onOpenChange(false);
else go(index + 1);
}}
>
{t(isLast ? "tips.done" : "tips.next")}
{t(finishes ? "tips.done" : "tips.next")}
</Button>
</div>
</div>
@@ -237,7 +250,7 @@ export function TipsDialog({
mode === "browse" ? "sm:max-w-2xl" : "sm:max-w-md",
)}
onKeyDown={(event) => {
if (isTypingTarget(event.target)) return;
if (single || isTypingTarget(event.target)) return;
if (event.key === "ArrowRight") {
event.preventDefault();
go(index + 1);
+106
View File
@@ -0,0 +1,106 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useRef, useState } from "react";
import type { McpMigrationOffer } from "@/lib/mcp";
/** How often a due offer looks again for a moment with no dialog open. */
const QUIET_POLL_MS = 500;
/**
* pending: nothing decided yet. checking: asking the backend. clear: nothing
* to offer on this launch so far. due: waiting for no other dialog. open and
* closed: the offer took this launch.
*/
type Phase = "pending" | "checking" | "clear" | "due" | "open" | "closed";
/**
* Any open dialog, popover or palette. Read from the page rather than from
* each owner's state: dialogs set their own `data-slot`, but every one of
* them carries the role. Sub-pages are in-flow pages and carry none.
*/
function anyDialogOpen(): boolean {
return (
document.querySelector('[role="dialog"], [role="alertdialog"]') !== null
);
}
/**
* The move from the removed local MCP server to remote MCP, offered by
* itself at most once per account (the backend remembers who had it) and at
* most once per launch. It asks again when a different account signs in.
*
* Until it has decided, it holds the automatic tip back, and once it takes a
* launch it keeps holding it: the two never open on the same launch.
*/
export function useMcpMigrationOffer({
ready,
accountId,
}: {
/** The launch is settled and no automatic tip has opened on it. */
ready: boolean;
/** The signed-in cloud user, or null. */
accountId: string | null;
}) {
const [phase, setPhase] = useState<Phase>("pending");
// Read by the effects without re-running them: the check sets `checking`
// itself, and an effect keyed on the phase would cancel its own answer.
const phaseRef = useRef<Phase>("pending");
const checkedForRef = useRef<string | null>(null);
const moveTo = useCallback((next: Phase) => {
phaseRef.current = next;
setPhase(next);
}, []);
useEffect(() => {
if (!ready) return;
const current = phaseRef.current;
if (current !== "pending" && current !== "clear") return;
if (!accountId) {
if (current === "pending") moveTo("clear");
return;
}
if (checkedForRef.current === accountId) return;
checkedForRef.current = accountId;
moveTo("checking");
invoke<McpMigrationOffer>("get_mcp_migration_offer")
.then((offer) => {
if (phaseRef.current === "checking") {
moveTo(offer.due ? "due" : "clear");
}
})
.catch((error: unknown) => {
console.error("Failed to check the remote MCP offer:", error);
if (phaseRef.current === "checking") moveTo("clear");
});
}, [ready, accountId, moveTo]);
// A due offer waits for the paid welcome, a tip the user opened, or any
// other dialog to close, then opens and is remembered as offered.
useEffect(() => {
if (phase !== "due" || !ready) return;
const tryOpen = () => {
if (anyDialogOpen()) return false;
moveTo("open");
invoke("mark_mcp_migration_offered").catch((error: unknown) => {
console.error("Failed to remember the remote MCP offer:", error);
});
return true;
};
if (tryOpen()) return;
const timer = window.setInterval(() => {
if (tryOpen()) window.clearInterval(timer);
}, QUIET_POLL_MS);
return () => window.clearInterval(timer);
}, [phase, ready, moveTo]);
const close = useCallback(() => {
if (phaseRef.current === "open") moveTo("closed");
}, [moveTo]);
return {
open: phase === "open",
/** The automatic tip must wait, or skip this launch. */
holdsLaunch: phase !== "clear",
close,
};
}
+175 -14
View File
@@ -1,14 +1,18 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TipsDialogMode } from "@/components/tips-dialog";
import { getAgentRuns } from "@/lib/agent";
import { effectivePlanOf, getEntitlements } from "@/lib/entitlements";
import { getCurrentOS } from "@/lib/platform";
import {
type FeatureUsage,
FRESH_LOGIN_WINDOW_MS,
isPlanTip,
pickAutoTip,
TIP_AUTO_DELAY_MS,
type TipDefinition,
type TipId,
type TipProfile,
tipsFor,
} from "@/lib/tips";
import type { CloudUser } from "@/types";
@@ -18,9 +22,118 @@ export interface TipsState {
auto_show: boolean;
seen: string[];
last_auto_shown_at: number | null;
next_auto_show_at: number | null;
auto_due: boolean;
}
/** What the app has already loaded that tells which features are in use. */
export interface TipUsageSource {
/** False while any of the lists below is still loading. */
loaded: boolean;
profiles: readonly TipProfile[];
groupCount: number;
extensionGroupCount: number;
cookieBotEnrolled: boolean;
syncServerConfigured: boolean;
proxyIds: readonly string[];
}
/** The settings fields the relevance checks read. */
interface UsageSettings {
api_enabled?: boolean;
mcp_enabled?: boolean;
mcp_remote_enabled?: boolean;
fingerprint_gate_disabled?: boolean;
}
/** A slow read must not hold the tip back for long. */
const USAGE_READ_TIMEOUT_MS = 5000;
const PROXY_HISTORY_BATCH = 16;
/**
* The answer of one usage read, or undefined when it fails or is too slow.
* An unknown fact counts as "not in use", so it never hides a tip.
*/
function known<T>(read: Promise<T>): Promise<T | undefined> {
return new Promise((resolve) => {
const timer = window.setTimeout(
() => resolve(undefined),
USAGE_READ_TIMEOUT_MS,
);
read
.then(resolve, (error: unknown) => {
console.warn("Failed to read a tip usage fact:", error);
resolve(undefined);
})
.finally(() => window.clearTimeout(timer));
});
}
/** Whether any proxy has a check on record, stopping at the first one. */
async function anyProxyChecked(proxyIds: readonly string[]): Promise<boolean> {
for (let start = 0; start < proxyIds.length; start += PROXY_HISTORY_BATCH) {
const histories = await Promise.all(
proxyIds
.slice(start, start + PROXY_HISTORY_BATCH)
.map((proxyId) =>
invoke<unknown[]>("get_proxy_check_history", { proxyId }),
),
);
if (histories.some((history) => history.length > 0)) return true;
}
return false;
}
/**
* The usage facts for the tips in `candidates`. The backend is asked only
* for what a candidate's relevance check reads, so a user who has seen the
* proxy tip never pays for a read of every proxy's check history.
*/
async function loadFeatureUsage(
source: TipUsageSource,
inTeam: boolean,
candidates: ReadonlySet<TipId>,
): Promise<FeatureUsage> {
const wants = (...ids: TipId[]) => ids.some((id) => candidates.has(id));
const [settings, proxyChecked, isDefaultBrowser, trashUsed, agentUsed] =
await Promise.all([
wants("localApi", "automation", "remoteControl", "fingerprintGate")
? known(invoke<UsageSettings>("get_app_settings"))
: undefined,
wants("proxyCheck") ? known(anyProxyChecked(source.proxyIds)) : undefined,
wants("defaultBrowser")
? known(invoke<boolean>("is_default_browser"))
: undefined,
wants("trash")
? known(
invoke<unknown[]>("list_trashed_profiles").then(
(trashed) => trashed.length > 0,
),
)
: undefined,
wants("agent")
? known(getAgentRuns({ limit: 1 }).then((page) => page.runs.length > 0))
: undefined,
]);
return {
profiles: source.profiles,
currentOs: getCurrentOS(),
groupCount: source.groupCount,
extensionGroupCount: source.extensionGroupCount,
inTeam,
cookieBotEnrolled: source.cookieBotEnrolled,
syncServerConfigured: source.syncServerConfigured,
apiEnabled: settings?.api_enabled,
mcpEnabled: settings?.mcp_enabled,
remoteControlEnabled: settings?.mcp_remote_enabled,
fingerprintGateChanged: settings?.fingerprint_gate_disabled,
proxyChecked,
isDefaultBrowser,
trashUsed,
agentUsed,
};
}
export interface TipsDialogState {
open: boolean;
mode: TipsDialogMode;
@@ -43,14 +156,28 @@ interface UseTipsOptions {
* first-run session, terms accepted, nothing else blocking.
*/
ready: boolean;
/**
* Another launch dialog has this launch (the remote MCP move), or has not
* decided yet. The automatic tip waits, and skips the launch it loses.
*/
autoTipHeld?: boolean;
/** Read when the automatic tip is chosen, to skip features already in use. */
usage: TipUsageSource;
}
/**
* The tips flow: which tips this install may see, what has been seen, the
* one tip a day that opens by itself, and the welcome for an account that
* just turned paid. State lives in the app settings; this hook only decides.
* single tip that opens by itself every few days, and the welcome for an
* account that just turned paid. State lives in the app settings; this hook
* only decides.
*/
export function useTips({ cloudUser, loggedInAt, ready }: UseTipsOptions) {
export function useTips({
cloudUser,
loggedInAt,
ready,
autoTipHeld = false,
usage,
}: UseTipsOptions) {
const [state, setState] = useState<TipsState | null>(null);
const [dialog, setDialog] = useState<TipsDialogState>({
open: false,
@@ -133,22 +260,56 @@ export function useTips({ cloudUser, loggedInAt, ready }: UseTipsOptions) {
setPaidWelcome({ plan: paidWelcome.plan, status: "open" });
}, [ready, dialog.open, paidWelcome]);
// The automatic tip: one unseen tip, a moment after the app settles, and
// never on top of the paid welcome. Marked handled only once it opens, so a
// welcome arriving during the delay simply takes its place.
// The usage lists change all the time (a profile starts, a group count
// moves); they are read when the tip is chosen, not watched, so a change
// does not restart the delay below.
const usageRef = useRef(usage);
useEffect(() => {
usageRef.current = usage;
});
const inTeam = Boolean(cloudUser?.teamId);
// The automatic tip: one random unseen tip for a feature the user does not
// use yet, a moment after the app settles, never on top of the paid
// welcome. Marked handled only once it opens, so a welcome arriving during
// the delay simply takes its place.
const autoHandledRef = useRef(false);
useEffect(() => {
if (!ready || !state || autoHandledRef.current) return;
if (!state.auto_due || dialog.open) return;
if (!ready || !usage.loaded || !state || autoHandledRef.current) return;
if (autoTipHeld || !state.auto_due || dialog.open) return;
if (paidWelcome && paidWelcome.status !== "done") return;
const tip = pickAutoTip(tips, state.seen);
if (!tip) return;
const unseen = tips.filter((tip) => !state.seen.includes(tip.id));
if (unseen.length === 0) return;
let cancelled = false;
const timer = window.setTimeout(() => {
autoHandledRef.current = true;
openTips(tip.id, { mode: "single", auto: true });
void (async () => {
const facts = await loadFeatureUsage(
usageRef.current,
inTeam,
new Set(unseen.map((tip) => tip.id)),
);
if (cancelled || autoHandledRef.current) return;
const tip = pickAutoTip(tips, state.seen, facts);
if (!tip) return;
autoHandledRef.current = true;
openTips(tip.id, { mode: "single", auto: true });
})();
}, TIP_AUTO_DELAY_MS);
return () => window.clearTimeout(timer);
}, [ready, state, dialog.open, paidWelcome, tips, openTips]);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [
ready,
autoTipHeld,
usage.loaded,
state,
dialog.open,
paidWelcome,
tips,
inTeam,
openTips,
]);
const markSeen = useCallback(async (id: TipId, auto: boolean) => {
try {
+34 -9
View File
@@ -875,7 +875,6 @@
"enabled": "Remote control enabled",
"disabled": "Remote control disabled",
"toggleFailed": "Could not change remote control",
"notEntitled": "Remote control needs the Enterprise plan. Contact us to enable remote control.",
"endpointLabel": "Remote MCP endpoint",
"endpointCopied": "Endpoint copied",
"endpointDescription": "Point an MCP client here with the credential above as its bearer token. One instance per account: the first app to connect keeps the slot.",
@@ -2297,7 +2296,7 @@
"mcpRemoteRequiresSignIn": "Sign in to your Donut account before enabling remote control.",
"mcpRemoteUnauthorized": "Donut cloud refused this device's credentials. Sign out and sign in again.",
"mcpRemoteSlotTaken": "Another Donut instance on this account is already connected. Only one at a time can be controlled remotely.",
"mcpRemoteNotEntitled": "This plan does not include remote control. Contact us to enable it.",
"mcpRemoteNotEntitled": "Remote MCP requires the Pro plan or higher.",
"mcpRemoteUnreachable": "Cannot reach Donut cloud right now. Reconnecting automatically.",
"mcpRemoteKeyMissing": "This device has no remote MCP credential yet. Create one first, then add the client again.",
"mcpRemoteKeyLimit": "This account already has the most MCP credentials it can hold. Revoke one on the account page and try again.",
@@ -2315,7 +2314,7 @@
"remoteSessionNotDrivable": "This remote session is not ready to be driven yet. Wait until it reports live.",
"remotePlatformUnsupported": "Remote sessions run on macOS, Windows or Linux hosts only. This profile's platform is {{platform}}.",
"remoteProfileNotSynced": "This profile has not finished uploading to cloud sync yet. Wait for its first sync to complete, then try again.",
"mcpLocalRemoved": "Donut's local MCP server has been removed. Use remote MCP instead — set it up in Settings > Integrations and connect to it from anywhere.",
"mcpLocalRemoved": "Donut's local MCP server has been removed. MCP is now remote and requires the Pro plan or higher.",
"trashEntryNotFound": "This profile is no longer in the trash.",
"trashRestoreConflict": "A profile with the same ID already exists, so this one cannot be restored.",
"agentNotEntitled": "Your plan does not include the agent.",
@@ -2560,7 +2559,7 @@
"proxy": "Proxy Support (HTTP/SOCKS5)",
"vpn": "VPN Support (WireGuard)",
"profiles": "Unlimited Local Profiles",
"api": "Profile Management API & MCP",
"api": "Profile Management API",
"openSource": "Open Source",
"groups": "Profile Groups",
"cookies": "Cookie Import & Export"
@@ -3036,9 +3035,35 @@
"title": "Local MCP has been removed",
"description": "Donut now uses remote MCP, which you can connect to from anywhere. Set it up under the Remote MCP tab and point your AI clients there."
},
"mcpLocalMigrated": {
"title": "Moved to remote MCP",
"description": "{{count}} MCP client was moved from the removed local server to remote MCP."
"mcpMigration": {
"title": "Move to remote MCP",
"doneTitle": "Moved to remote MCP",
"body": "The local MCP server is gone. Remote MCP connects your AI clients to this browser through Donut cloud, so they keep working from anywhere while Donut is open.",
"changes": "Donut points these clients at the remote endpoint with a private credential. Nothing else in their settings changes.",
"changesNone": "No AI client on this computer uses the local server. Donut turns on remote MCP and creates a credential for the clients you set up yourself.",
"start": "Move to remote MCP",
"notNow": "Not now",
"done": "Done",
"stepBridge": "Remote MCP",
"stepClients": "Clients",
"bridgeWorking": "Turning on",
"bridgeOn": "On",
"credentialWorking": "Creating",
"clientsProgress": "{{moved}} of {{total}} moved",
"clientsNone": "None to move",
"clientMoving": "Moving",
"clientMoved": "Moved",
"clientFailed": "Not changed",
"summary_one": "{{count}} client now uses remote MCP.",
"summary_other": "{{count}} clients now use remote MCP.",
"failed_one": "{{count}} client was not changed. Retry, or set it up by hand with the details below.",
"failed_other": "{{count}} clients were not changed. Retry, or set them up by hand with the details below.",
"manual": "For any other client, use this endpoint with the credential as its bearer token.",
"credentialCopied": "Credential copied",
"localServer": "The local server now only tells old clients that it moved. You can turn it off.",
"localServerOff": "Turn off local server",
"localServerIsOff": "Local server is off",
"localServerOffFailed": "Could not turn off the local server"
},
"appFeedback": {
"saveFailed": "Could not save settings",
@@ -3361,7 +3386,7 @@
"previous": "Previous tip",
"next": "Next tip",
"done": "Done",
"autoShow": "Show a tip when Donut starts",
"autoShow": "Show a tip every few days",
"items": {
"dnsBlocklist": {
"label": "DNS blocking",
@@ -3432,7 +3457,7 @@
"localApi": {
"label": "API and MCP",
"title": "Automate Donut from scripts and agents",
"body": "The local REST API and MCP server let scripts and AI agents list, create and configure profiles. Turn them on under Integrations and copy the token.",
"body": "The local REST API lets scripts list, create and configure profiles. Turn it on under Integrations and copy the token.",
"action": "Open Integrations"
},
"importProfiles": {
+36 -9
View File
@@ -879,7 +879,6 @@
"enabled": "Control remoto activado",
"disabled": "Control remoto desactivado",
"toggleFailed": "No se pudo cambiar el control remoto",
"notEntitled": "El control remoto requiere el plan Enterprise. Contáctanos para habilitar el control remoto.",
"endpointLabel": "Punto final MCP remoto",
"endpointCopied": "Punto final copiado",
"endpointDescription": "Apunta un cliente MCP aquí usando la credencial de arriba como token bearer. Una instancia por cuenta: la primera aplicación que se conecta conserva la ranura.",
@@ -2307,7 +2306,7 @@
"mcpRemoteRequiresSignIn": "Inicia sesión en tu cuenta de Donut antes de activar el control remoto.",
"mcpRemoteUnauthorized": "Donut cloud rechazó las credenciales de este dispositivo. Cierra sesión y vuelve a iniciarla.",
"mcpRemoteSlotTaken": "Ya hay otra instancia de Donut conectada en esta cuenta. Solo una puede controlarse de forma remota a la vez.",
"mcpRemoteNotEntitled": "Este plan no incluye el control remoto. Contáctanos para activarlo.",
"mcpRemoteNotEntitled": "El MCP remoto requiere el plan Pro o superior.",
"mcpRemoteUnreachable": "No se puede conectar con Donut cloud ahora mismo. Reconectando automáticamente.",
"mcpRemoteKeyMissing": "Este dispositivo aún no tiene una credencial MCP remota. Crea una primero y vuelve a añadir el cliente.",
"mcpRemoteKeyLimit": "Esta cuenta ya tiene el máximo de credenciales MCP que puede tener. Revoca una en la página de la cuenta e inténtalo de nuevo.",
@@ -2325,7 +2324,7 @@
"remoteSessionNotDrivable": "Esta sesión remota aún no está lista para controlarse. Espera a que esté activa.",
"remotePlatformUnsupported": "Las sesiones remotas solo se ejecutan en hosts macOS, Windows o Linux. La plataforma de este perfil es {{platform}}.",
"remoteProfileNotSynced": "Este perfil aún no ha terminado de subirse a la sincronización en la nube. Espera a que se complete su primera sincronización e inténtalo de nuevo.",
"mcpLocalRemoved": "El servidor MCP local de Donut se ha eliminado. Usa MCP remoto en su lugar: configúralo en Ajustes > Integraciones y conéctate desde cualquier lugar.",
"mcpLocalRemoved": "El servidor MCP local de Donut se ha eliminado. Ahora MCP es remoto y requiere el plan Pro o superior.",
"trashEntryNotFound": "Este perfil ya no está en la papelera.",
"trashRestoreConflict": "Ya existe un perfil con el mismo ID, por lo que este no se puede restaurar.",
"agentNotEntitled": "Tu plan no incluye el agente.",
@@ -2570,7 +2569,7 @@
"proxy": "Compatibilidad con proxy (HTTP/SOCKS5)",
"vpn": "Compatibilidad con VPN (WireGuard)",
"profiles": "Perfiles locales ilimitados",
"api": "API de gestión de perfiles y MCP",
"api": "API de gestión de perfiles",
"openSource": "Código abierto",
"groups": "Grupos de perfiles",
"cookies": "Importar y exportar cookies"
@@ -3066,9 +3065,37 @@
"title": "El MCP local se ha eliminado",
"description": "Donut ahora usa MCP remoto, al que puedes conectarte desde cualquier lugar. Configúralo en la pestaña MCP remoto y apunta tus clientes de IA allí."
},
"mcpLocalMigrated": {
"title": "Migrado a MCP remoto",
"description": "{{count}} cliente MCP se migró del servidor local eliminado al MCP remoto."
"mcpMigration": {
"title": "Migrar a MCP remoto",
"doneTitle": "Migrado a MCP remoto",
"body": "El servidor MCP local ya no existe. MCP remoto conecta tus clientes de IA a este navegador a través de la nube de Donut, así que siguen funcionando desde cualquier lugar mientras Donut esté abierto.",
"changes": "Donut apunta estos clientes al punto final remoto con una credencial privada. Nada más cambia en su configuración.",
"changesNone": "Ningún cliente de IA de este equipo usa el servidor local. Donut activa MCP remoto y crea una credencial para los clientes que configures tú.",
"start": "Migrar a MCP remoto",
"notNow": "Ahora no",
"done": "Listo",
"stepBridge": "MCP remoto",
"stepClients": "Clientes",
"bridgeWorking": "Activando",
"bridgeOn": "Activado",
"credentialWorking": "Creando",
"clientsProgress": "{{moved}} de {{total}} migrados",
"clientsNone": "Nada que migrar",
"clientMoving": "Migrando",
"clientMoved": "Migrado",
"clientFailed": "Sin cambios",
"summary_one": "{{count}} cliente ahora usa MCP remoto.",
"summary_other": "{{count}} clientes ahora usan MCP remoto.",
"summary_many": "{{count}} clientes ahora usan MCP remoto.",
"failed_one": "{{count}} cliente no se cambió. Reinténtalo o configúralo a mano con los datos de abajo.",
"failed_other": "{{count}} clientes no se cambiaron. Reinténtalo o configúralos a mano con los datos de abajo.",
"failed_many": "{{count}} clientes no se cambiaron. Reinténtalo o configúralos a mano con los datos de abajo.",
"manual": "Para cualquier otro cliente, usa este punto final con la credencial como token bearer.",
"credentialCopied": "Credencial copiada",
"localServer": "El servidor local ahora solo avisa a los clientes antiguos de que se ha movido. Puedes desactivarlo.",
"localServerOff": "Desactivar servidor local",
"localServerIsOff": "El servidor local está desactivado",
"localServerOffFailed": "No se pudo desactivar el servidor local"
},
"appFeedback": {
"saveFailed": "No se pudieron guardar los ajustes",
@@ -3394,7 +3421,7 @@
"previous": "Consejo anterior",
"next": "Siguiente consejo",
"done": "Listo",
"autoShow": "Mostrar un consejo al iniciar Donut",
"autoShow": "Mostrar un consejo cada pocos días",
"items": {
"dnsBlocklist": {
"label": "Bloqueo DNS",
@@ -3465,7 +3492,7 @@
"localApi": {
"label": "API y MCP",
"title": "Automatiza Donut desde scripts y agentes",
"body": "La API REST local y el servidor MCP permiten que scripts y agentes de IA listen, creen y configuren perfiles. Actívalos en Integraciones y copia el token.",
"body": "La API REST local permite que los scripts listen, creen y configuren perfiles. Actívala en Integraciones y copia el token.",
"action": "Abrir Integraciones"
},
"importProfiles": {
+36 -9
View File
@@ -879,7 +879,6 @@
"enabled": "Contrôle à distance activé",
"disabled": "Contrôle à distance désactivé",
"toggleFailed": "Impossible de modifier le contrôle à distance",
"notEntitled": "Le contrôle à distance nécessite le plan Enterprise. Contactez-nous pour activer le contrôle à distance.",
"endpointLabel": "Point de terminaison MCP distant",
"endpointCopied": "Point de terminaison copié",
"endpointDescription": "Dirigez un client MCP ici avec l'identifiant ci-dessus comme jeton bearer. Une instance par compte : la première application connectée garde la place.",
@@ -2307,7 +2306,7 @@
"mcpRemoteRequiresSignIn": "Connectez-vous à votre compte Donut avant d'activer le contrôle à distance.",
"mcpRemoteUnauthorized": "Donut cloud a refusé les identifiants de cet appareil. Déconnectez-vous puis reconnectez-vous.",
"mcpRemoteSlotTaken": "Une autre instance de Donut est déjà connectée sur ce compte. Une seule peut être contrôlée à distance à la fois.",
"mcpRemoteNotEntitled": "Ce forfait n'inclut pas le contrôle à distance. Contactez-nous pour l'activer.",
"mcpRemoteNotEntitled": "Le MCP distant nécessite le forfait Pro ou supérieur.",
"mcpRemoteUnreachable": "Donut cloud est injoignable pour le moment. Reconnexion automatique.",
"mcpRemoteKeyMissing": "Cet appareil n'a pas encore d'identifiant MCP distant. Créez-en un d'abord, puis ajoutez de nouveau le client.",
"mcpRemoteKeyLimit": "Ce compte a déjà atteint le nombre maximal d'identifiants MCP. Révoquez-en un sur la page du compte, puis réessayez.",
@@ -2325,7 +2324,7 @@
"remoteSessionNotDrivable": "Cette session distante n'est pas encore prête à être pilotée. Attendez qu'elle soit active.",
"remotePlatformUnsupported": "Les sessions distantes ne fonctionnent que sur des hôtes macOS, Windows ou Linux. La plateforme de ce profil est {{platform}}.",
"remoteProfileNotSynced": "Ce profil n'a pas encore fini d'être téléversé vers la synchronisation cloud. Attendez la fin de sa première synchronisation, puis réessayez.",
"mcpLocalRemoved": "Le serveur MCP local de Donut a été supprimé. Utilisez le MCP distant à la place : configurez-le dans Paramètres > Intégrations et connectez-vous depuis n'importe où.",
"mcpLocalRemoved": "Le serveur MCP local de Donut a été supprimé. Le MCP est désormais distant et nécessite le forfait Pro ou supérieur.",
"trashEntryNotFound": "Ce profil n'est plus dans la corbeille.",
"trashRestoreConflict": "Un profil avec le même identifiant existe déjà, celui-ci ne peut donc pas être restauré.",
"agentNotEntitled": "Votre forfait n'inclut pas l'agent.",
@@ -2570,7 +2569,7 @@
"proxy": "Prise en charge des proxys (HTTP/SOCKS5)",
"vpn": "Prise en charge du VPN (WireGuard)",
"profiles": "Profils locaux illimités",
"api": "API de gestion des profils et MCP",
"api": "API de gestion des profils",
"openSource": "Open source",
"groups": "Groupes de profils",
"cookies": "Import et export de cookies"
@@ -3066,9 +3065,37 @@
"title": "Le MCP local a été supprimé",
"description": "Donut utilise désormais le MCP distant, accessible depuis n'importe où. Configurez-le dans l'onglet MCP distant et dirigez-y vos clients IA."
},
"mcpLocalMigrated": {
"title": "Migré vers le MCP distant",
"description": "{{count}} client MCP a été déplacé du serveur local supprimé vers le MCP distant."
"mcpMigration": {
"title": "Migrer vers le MCP distant",
"doneTitle": "Migré vers le MCP distant",
"body": "Le serveur MCP local n'existe plus. Le MCP distant relie vos clients IA à ce navigateur via le cloud Donut : ils continuent de fonctionner depuis n'importe où tant que Donut est ouvert.",
"changes": "Donut dirige ces clients vers le point de terminaison distant avec un identifiant privé. Rien d'autre ne change dans leurs réglages.",
"changesNone": "Aucun client IA de cet ordinateur n'utilise le serveur local. Donut active le MCP distant et crée un identifiant pour les clients que vous configurez vous-même.",
"start": "Migrer vers le MCP distant",
"notNow": "Pas maintenant",
"done": "Terminé",
"stepBridge": "MCP distant",
"stepClients": "Clients",
"bridgeWorking": "Activation",
"bridgeOn": "Activé",
"credentialWorking": "Création",
"clientsProgress": "{{moved}} sur {{total}} migrés",
"clientsNone": "Rien à migrer",
"clientMoving": "Migration",
"clientMoved": "Migré",
"clientFailed": "Inchangé",
"summary_one": "{{count}} client utilise désormais le MCP distant.",
"summary_other": "{{count}} clients utilisent désormais le MCP distant.",
"summary_many": "{{count}} clients utilisent désormais le MCP distant.",
"failed_one": "{{count}} client n'a pas été modifié. Réessayez, ou configurez-le à la main avec les informations ci-dessous.",
"failed_other": "{{count}} clients n'ont pas été modifiés. Réessayez, ou configurez-les à la main avec les informations ci-dessous.",
"failed_many": "{{count}} clients n'ont pas été modifiés. Réessayez, ou configurez-les à la main avec les informations ci-dessous.",
"manual": "Pour tout autre client, utilisez ce point de terminaison avec l'identifiant comme jeton bearer.",
"credentialCopied": "Identifiant copié",
"localServer": "Le serveur local indique seulement aux anciens clients qu'il a été déplacé. Vous pouvez le désactiver.",
"localServerOff": "Désactiver le serveur local",
"localServerIsOff": "Le serveur local est désactivé",
"localServerOffFailed": "Impossible de désactiver le serveur local"
},
"appFeedback": {
"saveFailed": "Impossible d’enregistrer les paramètres",
@@ -3394,7 +3421,7 @@
"previous": "Astuce précédente",
"next": "Astuce suivante",
"done": "Terminé",
"autoShow": "Afficher une astuce au démarrage de Donut",
"autoShow": "Afficher une astuce de temps en temps",
"items": {
"dnsBlocklist": {
"label": "Blocage DNS",
@@ -3465,7 +3492,7 @@
"localApi": {
"label": "API et MCP",
"title": "Automatisez Donut depuis des scripts et des agents",
"body": "L'API REST locale et le serveur MCP permettent aux scripts et aux agents IA de lister, créer et configurer des profils. Activez-les dans Intégrations et copiez le jeton.",
"body": "L'API REST locale permet aux scripts de lister, créer et configurer des profils. Activez-la dans Intégrations et copiez le jeton.",
"action": "Ouvrir Intégrations"
},
"importProfiles": {
+34 -9
View File
@@ -875,7 +875,6 @@
"enabled": "リモート制御を有効にしました",
"disabled": "リモート制御を無効にしました",
"toggleFailed": "リモート制御を変更できませんでした",
"notEntitled": "リモート操作には Enterprise プランが必要です。リモート操作の有効化についてはお問い合わせください。",
"endpointLabel": "リモート MCP エンドポイント",
"endpointCopied": "エンドポイントをコピーしました",
"endpointDescription": "MCP クライアントをここに向け、上の資格情報を Bearer トークンとして使用してください。1 アカウントにつき 1 インスタンスです。最初に接続したアプリが枠を保持します。",
@@ -2297,7 +2296,7 @@
"mcpRemoteRequiresSignIn": "リモート制御を有効にする前に Donut アカウントにサインインしてください。",
"mcpRemoteUnauthorized": "Donut クラウドがこのデバイスの資格情報を拒否しました。サインアウトして、もう一度サインインしてください。",
"mcpRemoteSlotTaken": "このアカウントの別の Donut インスタンスがすでに接続されています。リモート制御できるのは同時に 1 台だけです。",
"mcpRemoteNotEntitled": "このプランにはリモート制御が含まれていません。有効化はお問い合わせください。",
"mcpRemoteNotEntitled": "リモート MCP には Pro プラン以上が必要です。",
"mcpRemoteUnreachable": "現在 Donut クラウドに接続できません。自動的に再接続します。",
"mcpRemoteKeyMissing": "このデバイスにはまだリモート MCP 資格情報がありません。先に作成してから、クライアントをもう一度追加してください。",
"mcpRemoteKeyLimit": "このアカウントはすでに保持できる MCP 認証情報の上限に達しています。アカウントページで 1 つ取り消してから、もう一度お試しください。",
@@ -2315,7 +2314,7 @@
"remoteSessionNotDrivable": "このリモートセッションはまだ操作できる状態ではありません。ライブになるまでお待ちください。",
"remotePlatformUnsupported": "リモートセッションは macOS、Windows、Linux のホストでのみ実行できます。このプロファイルのプラットフォームは {{platform}} です。",
"remoteProfileNotSynced": "このプロファイルはクラウド同期へのアップロードがまだ完了していません。最初の同期が完了してから、もう一度お試しください。",
"mcpLocalRemoved": "Donut のローカル MCP サーバーは削除されました。代わりにリモート MCP を使用してください。設定 > 連携 で設定すれば、どこからでも接続できます。",
"mcpLocalRemoved": "Donut のローカル MCP サーバーは削除されました。MCP は現在リモートで提供され、Pro プラン以上が必要です。",
"trashEntryNotFound": "このプロファイルはもうゴミ箱にありません。",
"trashRestoreConflict": "同じIDのプロファイルがすでに存在するため、復元できません。",
"agentNotEntitled": "現在のプランにはエージェントが含まれていません。",
@@ -2560,7 +2559,7 @@
"proxy": "プロキシ対応(HTTP/SOCKS5)",
"vpn": "VPN対応(WireGuard)",
"profiles": "無制限のローカルプロファイル",
"api": "プロファイル管理APIとMCP",
"api": "プロファイル管理API",
"openSource": "オープンソース",
"groups": "プロファイルグループ",
"cookies": "Cookieのインポート・エクスポート"
@@ -3036,9 +3035,35 @@
"title": "ローカル MCP は削除されました",
"description": "Donut は今後、どこからでも接続できるリモート MCP を使用します。「リモート MCP」タブで設定し、AI クライアントをそちらに向けてください。"
},
"mcpLocalMigrated": {
"title": "リモート MCP に移行しました",
"description": "{{count}} 個の MCP クライアントが、削除されたローカルサーバーからリモート MCP に移行されました。"
"mcpMigration": {
"title": "リモート MCP に移行",
"doneTitle": "リモート MCP に移行しました",
"body": "ローカル MCP サーバーは廃止されました。リモート MCP は Donut クラウド経由で AI クライアントをこのブラウザーに接続するため、Donut を開いている間はどこからでも使えます。",
"changes": "Donut はこれらのクライアントを、専用の資格情報とともにリモートエンドポイントに向けます。その他の設定は変わりません。",
"changesNone": "このコンピューターでローカルサーバーを使っている AI クライアントはありません。Donut はリモート MCP をオンにし、手動で設定するクライアント用の資格情報を作成します。",
"start": "リモート MCP に移行",
"notNow": "今はしない",
"done": "完了",
"stepBridge": "リモート MCP",
"stepClients": "クライアント",
"bridgeWorking": "オンにしています",
"bridgeOn": "オン",
"credentialWorking": "作成中",
"clientsProgress": "{{total}} 件中 {{moved}} 件を移行",
"clientsNone": "移行対象なし",
"clientMoving": "移行中",
"clientMoved": "移行済み",
"clientFailed": "未変更",
"summary_one": "{{count}} 件のクライアントがリモート MCP を使用しています。",
"summary_other": "{{count}} 件のクライアントがリモート MCP を使用しています。",
"failed_one": "{{count}} 件のクライアントを変更できませんでした。再試行するか、下の情報で手動設定してください。",
"failed_other": "{{count}} 件のクライアントを変更できませんでした。再試行するか、下の情報で手動設定してください。",
"manual": "その他のクライアントでは、このエンドポイントを使い、資格情報をベアラートークンとして設定してください。",
"credentialCopied": "資格情報をコピーしました",
"localServer": "ローカルサーバーは、移行したことを古いクライアントに伝えるだけです。オフにできます。",
"localServerOff": "ローカルサーバーをオフにする",
"localServerIsOff": "ローカルサーバーはオフです",
"localServerOffFailed": "ローカルサーバーをオフにできませんでした"
},
"appFeedback": {
"saveFailed": "設定を保存できませんでした",
@@ -3361,7 +3386,7 @@
"previous": "前のヒント",
"next": "次のヒント",
"done": "完了",
"autoShow": "Donut の起動時にヒントを表示",
"autoShow": "数日ごとにヒントを表示",
"items": {
"dnsBlocklist": {
"label": "DNS ブロック",
@@ -3432,7 +3457,7 @@
"localApi": {
"label": "API と MCP",
"title": "スクリプトやエージェントから Donut を自動化",
"body": "ローカルの REST API と MCP サーバーで、スクリプトや AI エージェントがプロファイルの一覧取得、作成、設定を行えます。「連携」で有効にしてトークンをコピーしてください。",
"body": "ローカルの REST API で、スクリプトがプロファイルの一覧取得、作成、設定を行えます。「連携」で有効にしてトークンをコピーしてください。",
"action": "連携を開く"
},
"importProfiles": {
+34 -9
View File
@@ -875,7 +875,6 @@
"enabled": "원격 제어를 켰습니다",
"disabled": "원격 제어를 껐습니다",
"toggleFailed": "원격 제어를 변경하지 못했습니다",
"notEntitled": "원격 제어에는 Enterprise 요금제가 필요합니다. 원격 제어 활성화는 문의해 주세요.",
"endpointLabel": "원격 MCP 엔드포인트",
"endpointCopied": "엔드포인트를 복사했습니다",
"endpointDescription": "MCP 클라이언트를 여기로 지정하고 위의 자격 증명을 Bearer 토큰으로 사용하세요. 계정당 인스턴스 하나입니다. 먼저 연결한 앱이 자리를 유지합니다.",
@@ -2297,7 +2296,7 @@
"mcpRemoteRequiresSignIn": "원격 제어를 켜기 전에 Donut 계정에 로그인하세요.",
"mcpRemoteUnauthorized": "Donut 클라우드가 이 기기의 자격 증명을 거부했습니다. 로그아웃한 뒤 다시 로그인하세요.",
"mcpRemoteSlotTaken": "이 계정의 다른 Donut 인스턴스가 이미 연결되어 있습니다. 한 번에 하나만 원격 제어할 수 있습니다.",
"mcpRemoteNotEntitled": "이 요금제에는 원격 제어가 포함되지 않습니다. 활성화하려면 문의하세요.",
"mcpRemoteNotEntitled": "원격 MCP는 Pro 요금제 이상이 필요합니다.",
"mcpRemoteUnreachable": "지금은 Donut 클라우드에 연결할 수 없습니다. 자동으로 다시 연결합니다.",
"mcpRemoteKeyMissing": "이 기기에는 아직 원격 MCP 자격 증명이 없습니다. 먼저 하나를 만든 다음 클라이언트를 다시 추가하세요.",
"mcpRemoteKeyLimit": "이 계정은 보유할 수 있는 MCP 자격 증명 수의 상한에 이미 도달했습니다. 계정 페이지에서 하나를 취소한 뒤 다시 시도하세요.",
@@ -2315,7 +2314,7 @@
"remoteSessionNotDrivable": "이 원격 세션은 아직 제어할 준비가 되지 않았습니다. 활성 상태가 될 때까지 기다리세요.",
"remotePlatformUnsupported": "원격 세션은 macOS, Windows 또는 Linux 호스트에서만 실행됩니다. 이 프로필의 플랫폼은 {{platform}}입니다.",
"remoteProfileNotSynced": "이 프로필은 아직 클라우드 동기화 업로드가 완료되지 않았습니다. 첫 동기화가 끝난 후 다시 시도하세요.",
"mcpLocalRemoved": "Donut의 로컬 MCP 서버가 제거되었습니다. 대신 원격 MCP를 사용하세요. 설정 > 통합에서 설정하면 어디서든 연결할 수 있습니다.",
"mcpLocalRemoved": "Donut의 로컬 MCP 서버가 제거되었습니다. 이제 MCP는 원격으로 제공되며 Pro 요금제 이상이 필요합니다.",
"trashEntryNotFound": "이 프로필은 더 이상 휴지통에 없습니다.",
"trashRestoreConflict": "같은 ID의 프로필이 이미 있어 복원할 수 없습니다.",
"agentNotEntitled": "현재 요금제에는 에이전트가 포함되어 있지 않습니다.",
@@ -2560,7 +2559,7 @@
"proxy": "프록시 지원 (HTTP/SOCKS5)",
"vpn": "VPN 지원 (WireGuard)",
"profiles": "무제한 로컬 프로필",
"api": "프로필 관리 API 및 MCP",
"api": "프로필 관리 API",
"openSource": "오픈 소스",
"groups": "프로필 그룹",
"cookies": "쿠키 가져오기 및 내보내기"
@@ -3036,9 +3035,35 @@
"title": "로컬 MCP가 제거되었습니다",
"description": "이제 Donut은 어디서나 연결할 수 있는 원격 MCP를 사용합니다. 원격 MCP 탭에서 설정하고 AI 클라이언트를 그쪽으로 지정하세요."
},
"mcpLocalMigrated": {
"title": "원격 MCP로 이동됨",
"description": "MCP 클라이언트 {{count}}개가 제거된 로컬 서버에서 원격 MCP로 이동되었습니다."
"mcpMigration": {
"title": "원격 MCP로 이동",
"doneTitle": "원격 MCP로 이동됨",
"body": "로컬 MCP 서버는 제거되었습니다. 원격 MCP는 Donut 클라우드를 통해 AI 클라이언트를 이 브라우저에 연결하므로, Donut이 열려 있는 동안 어디서나 계속 작동합니다.",
"changes": "Donut은 이 클라이언트들이 전용 자격 증명으로 원격 엔드포인트를 사용하도록 바꿉니다. 다른 설정은 바뀌지 않습니다.",
"changesNone": "이 컴퓨터에서 로컬 서버를 사용하는 AI 클라이언트가 없습니다. Donut이 원격 MCP를 켜고, 직접 설정할 클라이언트를 위한 자격 증명을 만듭니다.",
"start": "원격 MCP로 이동",
"notNow": "지금은 안 함",
"done": "완료",
"stepBridge": "원격 MCP",
"stepClients": "클라이언트",
"bridgeWorking": "켜는 중",
"bridgeOn": "켜짐",
"credentialWorking": "만드는 중",
"clientsProgress": "{{total}}개 중 {{moved}}개 이동됨",
"clientsNone": "이동할 항목 없음",
"clientMoving": "이동 중",
"clientMoved": "이동됨",
"clientFailed": "변경 안 됨",
"summary_one": "이제 클라이언트 {{count}}개가 원격 MCP를 사용합니다.",
"summary_other": "이제 클라이언트 {{count}}개가 원격 MCP를 사용합니다.",
"failed_one": "클라이언트 {{count}}개를 변경하지 못했습니다. 다시 시도하거나 아래 정보로 직접 설정하세요.",
"failed_other": "클라이언트 {{count}}개를 변경하지 못했습니다. 다시 시도하거나 아래 정보로 직접 설정하세요.",
"manual": "다른 클라이언트에는 이 엔드포인트를 사용하고 자격 증명을 베어러 토큰으로 넣으세요.",
"credentialCopied": "자격 증명을 복사했습니다",
"localServer": "로컬 서버는 이제 이전 클라이언트에 이동 사실만 알립니다. 끌 수 있습니다.",
"localServerOff": "로컬 서버 끄기",
"localServerIsOff": "로컬 서버가 꺼졌습니다",
"localServerOffFailed": "로컬 서버를 끄지 못했습니다"
},
"appFeedback": {
"saveFailed": "설정을 저장하지 못했습니다",
@@ -3361,7 +3386,7 @@
"previous": "이전 팁",
"next": "다음 팁",
"done": "완료",
"autoShow": "Donut 시작 시 팁 표시",
"autoShow": "며칠마다 팁 표시",
"items": {
"dnsBlocklist": {
"label": "DNS 차단",
@@ -3432,7 +3457,7 @@
"localApi": {
"label": "API와 MCP",
"title": "스크립트와 에이전트로 Donut 자동화",
"body": "로컬 REST API와 MCP 서버로 스크립트와 AI 에이전트가 프로필을 나열, 생성, 설정할 수 있습니다. 통합에서 켜고 토큰을 복사하세요.",
"body": "로컬 REST API로 스크립트가 프로필을 나열, 생성, 설정할 수 있습니다. 통합에서 켜고 토큰을 복사하세요.",
"action": "통합 열기"
},
"importProfiles": {
+36 -9
View File
@@ -879,7 +879,6 @@
"enabled": "Controlo remoto ativado",
"disabled": "Controlo remoto desativado",
"toggleFailed": "Não foi possível alterar o controlo remoto",
"notEntitled": "O controle remoto requer o plano Enterprise. Fale conosco para habilitar o controle remoto.",
"endpointLabel": "Ponto final MCP remoto",
"endpointCopied": "Ponto final copiado",
"endpointDescription": "Aponte um cliente MCP para aqui usando a credencial acima como token bearer. Uma instância por conta: a primeira aplicação a ligar-se mantém o lugar.",
@@ -2307,7 +2306,7 @@
"mcpRemoteRequiresSignIn": "Inicie sessão na sua conta Donut antes de ativar o controlo remoto.",
"mcpRemoteUnauthorized": "A Donut cloud recusou as credenciais deste dispositivo. Termine a sessão e inicie-a novamente.",
"mcpRemoteSlotTaken": "Já existe outra instância do Donut ligada nesta conta. Só uma pode ser controlada remotamente de cada vez.",
"mcpRemoteNotEntitled": "Este plano não inclui controlo remoto. Contacte-nos para o ativar.",
"mcpRemoteNotEntitled": "O MCP remoto requer o plano Pro ou superior.",
"mcpRemoteUnreachable": "Não é possível contactar a Donut cloud neste momento. A reconectar automaticamente.",
"mcpRemoteKeyMissing": "Este dispositivo ainda não tem uma credencial MCP remota. Crie uma primeiro e adicione o cliente novamente.",
"mcpRemoteKeyLimit": "Esta conta já tem o máximo de credenciais MCP que pode manter. Revogue uma na página da conta e tente novamente.",
@@ -2325,7 +2324,7 @@
"remoteSessionNotDrivable": "Esta sessão remota ainda não está pronta para ser controlada. Aguarde até ficar ativa.",
"remotePlatformUnsupported": "As sessões remotas funcionam apenas em hosts macOS, Windows ou Linux. A plataforma deste perfil é {{platform}}.",
"remoteProfileNotSynced": "Este perfil ainda não terminou de ser enviado para a sincronização na nuvem. Aguarde a conclusão da primeira sincronização e tente novamente.",
"mcpLocalRemoved": "O servidor MCP local do Donut foi removido. Use o MCP remoto: configure em Configurações > Integrações e conecte-se de qualquer lugar.",
"mcpLocalRemoved": "O servidor MCP local do Donut foi removido. Agora o MCP é remoto e requer o plano Pro ou superior.",
"trashEntryNotFound": "Este perfil não está mais na lixeira.",
"trashRestoreConflict": "Já existe um perfil com o mesmo ID, então este não pode ser restaurado.",
"agentNotEntitled": "O seu plano não inclui o agente.",
@@ -2570,7 +2569,7 @@
"proxy": "Suporte a proxy (HTTP/SOCKS5)",
"vpn": "Suporte a VPN (WireGuard)",
"profiles": "Perfis locais ilimitados",
"api": "API de gerenciamento de perfis e MCP",
"api": "API de gerenciamento de perfis",
"openSource": "Código aberto",
"groups": "Grupos de perfis",
"cookies": "Importar e exportar cookies"
@@ -3066,9 +3065,37 @@
"title": "O MCP local foi removido",
"description": "O Donut agora usa MCP remoto, ao qual você pode se conectar de qualquer lugar. Configure na aba MCP remoto e aponte seus clientes de IA para lá."
},
"mcpLocalMigrated": {
"title": "Migrado para o MCP remoto",
"description": "{{count}} cliente MCP foi movido do servidor local removido para o MCP remoto."
"mcpMigration": {
"title": "Migrar para o MCP remoto",
"doneTitle": "Migrado para o MCP remoto",
"body": "O servidor MCP local foi removido. O MCP remoto conecta seus clientes de IA a este navegador pela nuvem do Donut, e eles continuam funcionando de qualquer lugar enquanto o Donut estiver aberto.",
"changes": "O Donut aponta estes clientes para o ponto final remoto com uma credencial privada. Nada mais muda nas configurações deles.",
"changesNone": "Nenhum cliente de IA deste computador usa o servidor local. O Donut ativa o MCP remoto e cria uma credencial para os clientes que você configurar.",
"start": "Migrar para o MCP remoto",
"notNow": "Agora não",
"done": "Concluído",
"stepBridge": "MCP remoto",
"stepClients": "Clientes",
"bridgeWorking": "Ativando",
"bridgeOn": "Ativado",
"credentialWorking": "Criando",
"clientsProgress": "{{moved}} de {{total}} migrados",
"clientsNone": "Nada para migrar",
"clientMoving": "Migrando",
"clientMoved": "Migrado",
"clientFailed": "Sem alteração",
"summary_one": "{{count}} cliente agora usa o MCP remoto.",
"summary_other": "{{count}} clientes agora usam o MCP remoto.",
"summary_many": "{{count}} clientes agora usam o MCP remoto.",
"failed_one": "{{count}} cliente não foi alterado. Tente de novo ou configure-o manualmente com os dados abaixo.",
"failed_other": "{{count}} clientes não foram alterados. Tente de novo ou configure-os manualmente com os dados abaixo.",
"failed_many": "{{count}} clientes não foram alterados. Tente de novo ou configure-os manualmente com os dados abaixo.",
"manual": "Para qualquer outro cliente, use este ponto final com a credencial como token bearer.",
"credentialCopied": "Credencial copiada",
"localServer": "O servidor local agora só avisa os clientes antigos de que foi movido. Você pode desativá-lo.",
"localServerOff": "Desativar servidor local",
"localServerIsOff": "O servidor local está desativado",
"localServerOffFailed": "Não foi possível desativar o servidor local"
},
"appFeedback": {
"saveFailed": "Não foi possível salvar as configurações",
@@ -3394,7 +3421,7 @@
"previous": "Dica anterior",
"next": "Próxima dica",
"done": "Concluído",
"autoShow": "Mostrar uma dica ao iniciar o Donut",
"autoShow": "Mostrar uma dica a cada poucos dias",
"items": {
"dnsBlocklist": {
"label": "Bloqueio DNS",
@@ -3465,7 +3492,7 @@
"localApi": {
"label": "API e MCP",
"title": "Automatize o Donut com scripts e agentes",
"body": "A API REST local e o servidor MCP permitem que scripts e agentes de IA listem, criem e configurem perfis. Ative-os em Integrações e copie o token.",
"body": "A API REST local permite que scripts listem, criem e configurem perfis. Ative-a em Integrações e copie o token.",
"action": "Abrir Integrações"
},
"importProfiles": {
+38 -9
View File
@@ -883,7 +883,6 @@
"enabled": "Удалённое управление включено",
"disabled": "Удалённое управление выключено",
"toggleFailed": "Не удалось изменить удалённое управление",
"notEntitled": "Удалённое управление доступно на плане Enterprise. Напишите нам, чтобы включить удалённое управление.",
"endpointLabel": "Удалённая конечная точка MCP",
"endpointCopied": "Конечная точка скопирована",
"endpointDescription": "Направьте клиент MCP сюда, используя учётные данные выше как bearer-токен. Один экземпляр на аккаунт: место занимает приложение, подключившееся первым.",
@@ -2317,7 +2316,7 @@
"mcpRemoteRequiresSignIn": "Войдите в аккаунт Donut, прежде чем включать удалённое управление.",
"mcpRemoteUnauthorized": "Donut cloud отклонил учётные данные этого устройства. Выйдите и войдите снова.",
"mcpRemoteSlotTaken": "К этому аккаунту уже подключён другой экземпляр Donut. Удалённо управлять можно только одним одновременно.",
"mcpRemoteNotEntitled": "Этот план не включает удалённое управление. Свяжитесь с нами, чтобы его включить.",
"mcpRemoteNotEntitled": "Для удалённого MCP нужен план Pro или выше.",
"mcpRemoteUnreachable": "Сейчас не удаётся связаться с Donut cloud. Переподключение выполняется автоматически.",
"mcpRemoteKeyMissing": "На этом устройстве ещё нет учётных данных удалённого MCP. Сначала создайте их, затем снова добавьте клиент.",
"mcpRemoteKeyLimit": "У этого аккаунта уже максимальное число учётных данных MCP. Отзовите одни на странице аккаунта и попробуйте снова.",
@@ -2335,7 +2334,7 @@
"remoteSessionNotDrivable": "Эта удалённая сессия ещё не готова к управлению. Дождитесь статуса «в сети».",
"remotePlatformUnsupported": "Удалённые сессии работают только на хостах macOS, Windows или Linux. Платформа этого профиля — {{platform}}.",
"remoteProfileNotSynced": "Этот профиль ещё не загрузился в облачную синхронизацию. Дождитесь завершения первой синхронизации и попробуйте снова.",
"mcpLocalRemoved": "Локальный MCP-сервер Donut удалён. Используйте удалённый MCP: настройте его в «Настройки > Интеграции» и подключайтесь откуда угодно.",
"mcpLocalRemoved": "Локальный MCP-сервер Donut удалён. Теперь MCP работает удалённо, и для него нужен план Pro или выше.",
"trashEntryNotFound": "Этого профиля больше нет в корзине.",
"trashRestoreConflict": "Профиль с таким же ID уже существует, поэтому этот восстановить нельзя.",
"agentNotEntitled": "Ваш тариф не включает агента.",
@@ -2580,7 +2579,7 @@
"proxy": "Поддержка прокси (HTTP/SOCKS5)",
"vpn": "Поддержка VPN (WireGuard)",
"profiles": "Неограниченное число локальных профилей",
"api": "API управления профилями и MCP",
"api": "API управления профилями",
"openSource": "Открытый исходный код",
"groups": "Группы профилей",
"cookies": "Импорт и экспорт cookie"
@@ -3096,9 +3095,39 @@
"title": "Локальный MCP удалён",
"description": "Теперь Donut использует удалённый MCP, к которому можно подключиться откуда угодно. Настройте его на вкладке «Удалённый MCP» и направьте туда своих ИИ-клиентов."
},
"mcpLocalMigrated": {
"title": "Переведено на удалённый MCP",
"description": "{{count}} MCP-клиент перенесён с удалённого локального сервера на удалённый MCP."
"mcpMigration": {
"title": "Перейти на удалённый MCP",
"doneTitle": "Переведено на удалённый MCP",
"body": "Локального MCP-сервера больше нет. Удалённый MCP подключает ваших ИИ-клиентов к этому браузеру через облако Donut, поэтому они работают откуда угодно, пока Donut открыт.",
"changes": "Donut направит эти клиенты на удалённую конечную точку с личными учётными данными. Остальные их настройки не изменятся.",
"changesNone": "На этом компьютере нет ИИ-клиентов, которые используют локальный сервер. Donut включит удалённый MCP и создаст учётные данные для клиентов, которые вы настроите сами.",
"start": "Перейти на удалённый MCP",
"notNow": "Не сейчас",
"done": "Готово",
"stepBridge": "Удалённый MCP",
"stepClients": "Клиенты",
"bridgeWorking": "Включение",
"bridgeOn": "Включён",
"credentialWorking": "Создание",
"clientsProgress": "Перенесено {{moved}} из {{total}}",
"clientsNone": "Нечего переносить",
"clientMoving": "Перенос",
"clientMoved": "Перенесён",
"clientFailed": "Не изменён",
"summary_one": "{{count}} клиент теперь использует удалённый MCP.",
"summary_few": "{{count}} клиента теперь используют удалённый MCP.",
"summary_many": "{{count}} клиентов теперь используют удалённый MCP.",
"summary_other": "{{count}} клиента теперь используют удалённый MCP.",
"failed_one": "{{count}} клиент не изменён. Повторите попытку или настройте его вручную по данным ниже.",
"failed_few": "{{count}} клиента не изменены. Повторите попытку или настройте их вручную по данным ниже.",
"failed_many": "{{count}} клиентов не изменены. Повторите попытку или настройте их вручную по данным ниже.",
"failed_other": "{{count}} клиента не изменены. Повторите попытку или настройте их вручную по данным ниже.",
"manual": "Для любого другого клиента используйте эту конечную точку, а учётные данные укажите как bearer-токен.",
"credentialCopied": "Учётные данные скопированы",
"localServer": "Локальный сервер теперь только сообщает старым клиентам, что он перенесён. Его можно выключить.",
"localServerOff": "Выключить локальный сервер",
"localServerIsOff": "Локальный сервер выключен",
"localServerOffFailed": "Не удалось выключить локальный сервер"
},
"appFeedback": {
"saveFailed": "Не удалось сохранить настройки",
@@ -3427,7 +3456,7 @@
"previous": "Предыдущая подсказка",
"next": "Следующая подсказка",
"done": "Готово",
"autoShow": "Показывать подсказку при запуске Donut",
"autoShow": "Показывать подсказку раз в несколько дней",
"items": {
"dnsBlocklist": {
"label": "DNS-блокировка",
@@ -3498,7 +3527,7 @@
"localApi": {
"label": "API и MCP",
"title": "Автоматизируйте Donut скриптами и агентами",
"body": "Локальный REST API и сервер MCP позволяют скриптам и ИИ-агентам просматривать, создавать и настраивать профили. Включите их в разделе «Интеграции» и скопируйте токен.",
"body": "Локальный REST API позволяет скриптам просматривать, создавать и настраивать профили. Включите его в разделе «Интеграции» и скопируйте токен.",
"action": "Открыть Интеграции"
},
"importProfiles": {
+34 -9
View File
@@ -875,7 +875,6 @@
"enabled": "Uzaktan kontrol açıldı",
"disabled": "Uzaktan kontrol kapatıldı",
"toggleFailed": "Uzaktan kontrol değiştirilemedi",
"notEntitled": "Uzaktan kontrol için Enterprise planı gerekir. Uzaktan kontrolü etkinleştirmek için bize ulaşın.",
"endpointLabel": "Uzak MCP uç noktası",
"endpointCopied": "Uç nokta kopyalandı",
"endpointDescription": "Bir MCP istemcisini yukarıdaki kimlik bilgisini bearer belirteci olarak kullanarak buraya yönlendirin. Hesap başına bir örnek: yeri ilk bağlanan uygulama tutar.",
@@ -2297,7 +2296,7 @@
"mcpRemoteRequiresSignIn": "Uzaktan kontrolü açmadan önce Donut hesabınıza giriş yapın.",
"mcpRemoteUnauthorized": "Donut cloud bu cihazın kimlik bilgilerini reddetti. Çıkış yapıp yeniden giriş yapın.",
"mcpRemoteSlotTaken": "Bu hesapta başka bir Donut örneği zaten bağlı. Aynı anda yalnızca biri uzaktan kontrol edilebilir.",
"mcpRemoteNotEntitled": "Bu plan uzaktan kontrolü içermiyor. Etkinleştirmek için bize ulaşın.",
"mcpRemoteNotEntitled": "Uzak MCP, Pro veya daha üst bir plan gerektirir.",
"mcpRemoteUnreachable": "Şu anda Donut cloud'a ulaşılamıyor. Otomatik olarak yeniden bağlanılıyor.",
"mcpRemoteKeyMissing": "Bu cihazda henüz uzak MCP kimlik bilgisi yok. Önce bir tane oluşturun, ardından istemciyi yeniden ekleyin.",
"mcpRemoteKeyLimit": "Bu hesap tutabileceği en fazla MCP kimlik bilgisine zaten sahip. Hesap sayfasından birini iptal edip yeniden deneyin.",
@@ -2315,7 +2314,7 @@
"remoteSessionNotDrivable": "Bu uzak oturum henüz yönetilmeye hazır değil. Etkin duruma gelmesini bekleyin.",
"remotePlatformUnsupported": "Uzak oturumlar yalnızca macOS, Windows veya Linux sunucularında çalışır. Bu profilin platformu {{platform}}.",
"remoteProfileNotSynced": "Bu profil buluta yüklenmeyi henüz tamamlamadı. İlk eşitlemenin bitmesini bekleyip tekrar deneyin.",
"mcpLocalRemoved": "Donut'un yerel MCP sunucusu kaldırıldı. Bunun yerine uzak MCP kullanın: Ayarlar > Entegrasyonlar'dan kurun ve her yerden bağlanın.",
"mcpLocalRemoved": "Donut'un yerel MCP sunucusu kaldırıldı. MCP artık uzaktan çalışıyor ve Pro veya daha üst bir plan gerektiriyor.",
"trashEntryNotFound": "Bu profil artık çöp kutusunda değil.",
"trashRestoreConflict": "Aynı kimliğe sahip bir profil zaten var, bu nedenle bu profil geri yüklenemez.",
"agentNotEntitled": "Planınız ajanı içermiyor.",
@@ -2560,7 +2559,7 @@
"proxy": "Proxy Desteği (HTTP/SOCKS5)",
"vpn": "VPN Desteği (WireGuard)",
"profiles": "Sınırsız Yerel Profil",
"api": "Profil Yönetimi API'si ve MCP",
"api": "Profil Yönetimi API'si",
"openSource": "Açık Kaynak",
"groups": "Profil Grupları",
"cookies": "Çerez İçe/Dışa Aktarma"
@@ -3036,9 +3035,35 @@
"title": "Yerel MCP kaldırıldı",
"description": "Donut artık her yerden bağlanabileceğiniz uzak MCP kullanıyor. Uzak MCP sekmesinden kurun ve yapay zeka istemcilerinizi oraya yönlendirin."
},
"mcpLocalMigrated": {
"title": "Uzak MCP'ye taşındı",
"description": "{{count}} MCP istemcisi, kaldırılan yerel sunucudan uzak MCP'ye taşındı."
"mcpMigration": {
"title": "Uzak MCP'ye geç",
"doneTitle": "Uzak MCP'ye taşındı",
"body": "Yerel MCP sunucusu kaldırıldı. Uzak MCP, yapay zeka istemcilerinizi Donut bulutu üzerinden bu tarayıcıya bağlar; böylece Donut açık olduğu sürece her yerden çalışmaya devam ederler.",
"changes": "Donut bu istemcileri özel bir kimlik bilgisiyle uzak uç noktaya yönlendirir. Ayarlarında başka hiçbir şey değişmez.",
"changesNone": "Bu bilgisayarda yerel sunucuyu kullanan yapay zeka istemcisi yok. Donut uzak MCP'yi açar ve kendiniz kuracağınız istemciler için bir kimlik bilgisi oluşturur.",
"start": "Uzak MCP'ye geç",
"notNow": "Şimdi değil",
"done": "Bitti",
"stepBridge": "Uzak MCP",
"stepClients": "İstemciler",
"bridgeWorking": "Açılıyor",
"bridgeOn": "Açık",
"credentialWorking": "Oluşturuluyor",
"clientsProgress": "{{total}} istemciden {{moved}} tanesi taşındı",
"clientsNone": "Taşınacak istemci yok",
"clientMoving": "Taşınıyor",
"clientMoved": "Taşındı",
"clientFailed": "Değişmedi",
"summary_one": "{{count}} istemci artık uzak MCP kullanıyor.",
"summary_other": "{{count}} istemci artık uzak MCP kullanıyor.",
"failed_one": "{{count}} istemci değiştirilemedi. Yeniden deneyin veya aşağıdaki bilgilerle elle kurun.",
"failed_other": "{{count}} istemci değiştirilemedi. Yeniden deneyin veya aşağıdaki bilgilerle elle kurun.",
"manual": "Diğer istemciler için bu uç noktayı kullanın ve kimlik bilgisini bearer belirteci olarak girin.",
"credentialCopied": "Kimlik bilgisi kopyalandı",
"localServer": "Yerel sunucu artık eski istemcilere yalnızca taşındığını bildiriyor. Kapatabilirsiniz.",
"localServerOff": "Yerel sunucuyu kapat",
"localServerIsOff": "Yerel sunucu kapalı",
"localServerOffFailed": "Yerel sunucu kapatılamadı"
},
"appFeedback": {
"saveFailed": "Ayarlar kaydedilemedi",
@@ -3361,7 +3386,7 @@
"previous": "Önceki ipucu",
"next": "Sonraki ipucu",
"done": "Bitti",
"autoShow": "Donut açılırken bir ipucu göster",
"autoShow": "Birkaç günde bir ipucu göster",
"items": {
"dnsBlocklist": {
"label": "DNS engelleme",
@@ -3432,7 +3457,7 @@
"localApi": {
"label": "API ve MCP",
"title": "Donut'u betikler ve ajanlarla otomatikleştirin",
"body": "Yerel REST API ve MCP sunucusu, betiklerin ve yapay zekâ ajanlarının profilleri listelemesine, oluşturmasına ve yapılandırmasına izin verir. Entegrasyonlar'dan açın ve belirteci kopyalayın.",
"body": "Yerel REST API, betiklerin profilleri listelemesine, oluşturmasına ve yapılandırmasına izin verir. Entegrasyonlar'dan açın ve belirteci kopyalayın.",
"action": "Entegrasyonları aç"
},
"importProfiles": {
+34 -9
View File
@@ -875,7 +875,6 @@
"enabled": "Đã bật điều khiển từ xa",
"disabled": "Đã tắt điều khiển từ xa",
"toggleFailed": "Không thể thay đổi điều khiển từ xa",
"notEntitled": "Điều khiển từ xa cần gói Enterprise. Hãy liên hệ với chúng tôi để bật điều khiển từ xa.",
"endpointLabel": "Điểm cuối MCP từ xa",
"endpointCopied": "Đã sao chép điểm cuối",
"endpointDescription": "Trỏ một máy khách MCP tới đây và dùng thông tin xác thực ở trên làm bearer token. Một phiên bản cho mỗi tài khoản: ứng dụng kết nối đầu tiên giữ chỗ.",
@@ -2297,7 +2296,7 @@
"mcpRemoteRequiresSignIn": "Đăng nhập tài khoản Donut trước khi bật điều khiển từ xa.",
"mcpRemoteUnauthorized": "Donut cloud đã từ chối thông tin xác thực của thiết bị này. Hãy đăng xuất rồi đăng nhập lại.",
"mcpRemoteSlotTaken": "Một phiên bản Donut khác trên tài khoản này đã kết nối. Mỗi lần chỉ điều khiển từ xa được một máy.",
"mcpRemoteNotEntitled": "Gói này không bao gồm điều khiển từ xa. Liên hệ với chúng tôi để bật.",
"mcpRemoteNotEntitled": "MCP từ xa yêu cầu gói Pro trở lên.",
"mcpRemoteUnreachable": "Hiện không kết nối được tới Donut cloud. Đang tự động kết nối lại.",
"mcpRemoteKeyMissing": "Thiết bị này chưa có thông tin xác thực MCP từ xa. Hãy tạo một thông tin trước, rồi thêm lại ứng dụng khách.",
"mcpRemoteKeyLimit": "Tài khoản này đã có số thông tin xác thực MCP tối đa có thể giữ. Hãy thu hồi một cái trên trang tài khoản rồi thử lại.",
@@ -2315,7 +2314,7 @@
"remoteSessionNotDrivable": "Phiên từ xa này chưa sẵn sàng để điều khiển. Hãy đợi đến khi phiên hoạt động.",
"remotePlatformUnsupported": "Phiên từ xa chỉ chạy trên máy chủ macOS, Windows hoặc Linux. Nền tảng của hồ sơ này là {{platform}}.",
"remoteProfileNotSynced": "Hồ sơ này chưa tải lên xong dịch vụ đồng bộ đám mây. Hãy đợi lần đồng bộ đầu tiên hoàn tất rồi thử lại.",
"mcpLocalRemoved": "Máy chủ MCP cục bộ của Donut đã bị gỡ bỏ. Hãy dùng MCP từ xa: thiết lập trong Cài đặt > Tích hợp và kết nối từ bất cứ đâu.",
"mcpLocalRemoved": "Máy chủ MCP cục bộ của Donut đã bị gỡ bỏ. MCP giờ chạy từ xa và yêu cầu gói Pro trở lên.",
"trashEntryNotFound": "Hồ sơ này không còn trong thùng rác.",
"trashRestoreConflict": "Đã tồn tại một hồ sơ có cùng ID nên không thể khôi phục hồ sơ này.",
"agentNotEntitled": "Gói của bạn không bao gồm tác nhân.",
@@ -2560,7 +2559,7 @@
"proxy": "Hỗ trợ Proxy (HTTP/SOCKS5)",
"vpn": "Hỗ trợ VPN (WireGuard)",
"profiles": "Hồ sơ cục bộ không giới hạn",
"api": "API quản lý hồ sơ & MCP",
"api": "API quản lý hồ sơ",
"openSource": "Mã nguồn mở",
"groups": "Nhóm hồ sơ",
"cookies": "Nhập & xuất cookie"
@@ -3036,9 +3035,35 @@
"title": "MCP cục bộ đã bị gỡ bỏ",
"description": "Donut hiện dùng MCP từ xa, bạn có thể kết nối từ bất cứ đâu. Thiết lập trong tab MCP từ xa và trỏ các client AI của bạn tới đó."
},
"mcpLocalMigrated": {
"title": "Đã chuyển sang MCP từ xa",
"description": "{{count}} client MCP đã được chuyển từ máy chủ cục bộ đã gỡ sang MCP từ xa."
"mcpMigration": {
"title": "Chuyển sang MCP từ xa",
"doneTitle": "Đã chuyển sang MCP từ xa",
"body": "Máy chủ MCP cục bộ đã bị gỡ bỏ. MCP từ xa kết nối các client AI của bạn với trình duyệt này qua đám mây Donut, nên chúng vẫn hoạt động từ bất cứ đâu khi Donut đang mở.",
"changes": "Donut trỏ các client này tới điểm cuối từ xa với một thông tin xác thực riêng. Các cài đặt khác của chúng không thay đổi.",
"changesNone": "Không có client AI nào trên máy này dùng máy chủ cục bộ. Donut sẽ bật MCP từ xa và tạo thông tin xác thực cho các client bạn tự thiết lập.",
"start": "Chuyển sang MCP từ xa",
"notNow": "Để sau",
"done": "Xong",
"stepBridge": "MCP từ xa",
"stepClients": "Client",
"bridgeWorking": "Đang bật",
"bridgeOn": "Đã bật",
"credentialWorking": "Đang tạo",
"clientsProgress": "Đã chuyển {{moved}}/{{total}}",
"clientsNone": "Không có gì để chuyển",
"clientMoving": "Đang chuyển",
"clientMoved": "Đã chuyển",
"clientFailed": "Chưa đổi",
"summary_one": "{{count}} client hiện dùng MCP từ xa.",
"summary_other": "{{count}} client hiện dùng MCP từ xa.",
"failed_one": "{{count}} client chưa được thay đổi. Hãy thử lại, hoặc tự thiết lập bằng thông tin bên dưới.",
"failed_other": "{{count}} client chưa được thay đổi. Hãy thử lại, hoặc tự thiết lập bằng thông tin bên dưới.",
"manual": "Với client khác, hãy dùng điểm cuối này và dùng thông tin xác thực làm bearer token.",
"credentialCopied": "Đã sao chép thông tin xác thực",
"localServer": "Máy chủ cục bộ giờ chỉ báo cho client cũ rằng nó đã chuyển đi. Bạn có thể tắt nó.",
"localServerOff": "Tắt máy chủ cục bộ",
"localServerIsOff": "Máy chủ cục bộ đã tắt",
"localServerOffFailed": "Không thể tắt máy chủ cục bộ"
},
"appFeedback": {
"saveFailed": "Không thể lưu cài đặt",
@@ -3361,7 +3386,7 @@
"previous": "Mẹo trước",
"next": "Mẹo tiếp theo",
"done": "Xong",
"autoShow": "Hiện một mẹo khi Donut khởi động",
"autoShow": "Hiện một mẹo sau mỗi vài ngày",
"items": {
"dnsBlocklist": {
"label": "Chặn DNS",
@@ -3432,7 +3457,7 @@
"localApi": {
"label": "API và MCP",
"title": "Tự động hóa Donut từ script và tác nhân",
"body": "REST API cục bộ và máy chủ MCP cho phép script và tác nhân AI liệt kê, tạo và cấu hình hồ sơ. Bật chúng trong Tích hợp và sao chép mã thông báo.",
"body": "REST API cục bộ cho phép script liệt kê, tạo và cấu hình hồ sơ. Bật nó trong Tích hợp và sao chép mã thông báo.",
"action": "Mở Tích hợp"
},
"importProfiles": {
+34 -9
View File
@@ -875,7 +875,6 @@
"enabled": "已启用远程控制",
"disabled": "已停用远程控制",
"toggleFailed": "无法更改远程控制",
"notEntitled": "远程控制需要 Enterprise 套餐。请联系我们以启用远程控制。",
"endpointLabel": "远程 MCP 端点",
"endpointCopied": "已复制端点",
"endpointDescription": "将 MCP 客户端指向此处,并使用上方的凭据作为 Bearer 令牌。每个账户一个实例:先连接的应用保留该名额。",
@@ -2297,7 +2296,7 @@
"mcpRemoteRequiresSignIn": "启用远程控制前,请先登录 Donut 账户。",
"mcpRemoteUnauthorized": "Donut 云拒绝了此设备的凭据。请退出登录后重新登录。",
"mcpRemoteSlotTaken": "此账户已有另一个 Donut 实例连接。同一时间只能远程控制一个。",
"mcpRemoteNotEntitled": "此方案不包含远程控制。请联系我们开通。",
"mcpRemoteNotEntitled": "远程 MCP 需要 Pro 或更高方案。",
"mcpRemoteUnreachable": "当前无法连接 Donut 云。正在自动重新连接。",
"mcpRemoteKeyMissing": "此设备尚未保存远程 MCP 凭证。请先创建一个,然后重新添加客户端。",
"mcpRemoteKeyLimit": "此账户持有的 MCP 凭据已达上限。请在账户页面撤销一个后再试。",
@@ -2315,7 +2314,7 @@
"remoteSessionNotDrivable": "此远程会话尚未就绪,无法控制。请等待其变为在线状态。",
"remotePlatformUnsupported": "远程会话仅在 macOS、Windows 或 Linux 主机上运行。此配置文件的平台是 {{platform}}。",
"remoteProfileNotSynced": "此配置文件尚未完成上传到云同步。请等待首次同步完成后再试。",
"mcpLocalRemoved": "Donut 的本地 MCP 服务器已移除。请改用远程 MCP:在“设置 > 集成”中设置,即可从任何地方连接。",
"mcpLocalRemoved": "Donut 的本地 MCP 服务器已移除。MCP 现在以远程方式提供,需要 Pro 或更高方案。",
"trashEntryNotFound": "此配置文件已不在回收站中。",
"trashRestoreConflict": "已存在相同 ID 的配置文件,因此无法恢复。",
"agentNotEntitled": "你的套餐不包含智能体。",
@@ -2560,7 +2559,7 @@
"proxy": "代理支持(HTTP/SOCKS5)",
"vpn": "VPN 支持(WireGuard)",
"profiles": "无限本地配置文件",
"api": "配置文件管理 API 和 MCP",
"api": "配置文件管理 API",
"openSource": "开源",
"groups": "配置文件分组",
"cookies": "Cookie 导入与导出"
@@ -3036,9 +3035,35 @@
"title": "本地 MCP 已移除",
"description": "Donut 现在使用远程 MCP,你可以从任何地方连接。请在“远程 MCP”标签页中设置,并将你的 AI 客户端指向它。"
},
"mcpLocalMigrated": {
"title": "已迁移到远程 MCP",
"description": "已将 {{count}} 个 MCP 客户端从已移除的本地服务器迁移到远程 MCP。"
"mcpMigration": {
"title": "迁移到远程 MCP",
"doneTitle": "已迁移到远程 MCP",
"body": "本地 MCP 服务器已移除。远程 MCP 通过 Donut 云将你的 AI 客户端连接到此浏览器,只要 Donut 保持打开,它们在任何地方都能继续工作。",
"changes": "Donut 会使用专属凭据,将这些客户端指向远程端点。它们的其他设置不会改变。",
"changesNone": "此电脑上没有使用本地服务器的 AI 客户端。Donut 会开启远程 MCP,并为你手动配置的客户端创建凭据。",
"start": "迁移到远程 MCP",
"notNow": "暂不",
"done": "完成",
"stepBridge": "远程 MCP",
"stepClients": "客户端",
"bridgeWorking": "正在开启",
"bridgeOn": "已开启",
"credentialWorking": "正在创建",
"clientsProgress": "已迁移 {{moved}}/{{total}}",
"clientsNone": "无需迁移",
"clientMoving": "正在迁移",
"clientMoved": "已迁移",
"clientFailed": "未更改",
"summary_one": "{{count}} 个客户端现已使用远程 MCP。",
"summary_other": "{{count}} 个客户端现已使用远程 MCP。",
"failed_one": "{{count}} 个客户端未能更改。请重试,或使用下方信息手动配置。",
"failed_other": "{{count}} 个客户端未能更改。请重试,或使用下方信息手动配置。",
"manual": "对于其他客户端,请使用此端点,并将凭据作为 Bearer 令牌。",
"credentialCopied": "凭据已复制",
"localServer": "本地服务器现在只会告诉旧客户端它已迁移。你可以将其关闭。",
"localServerOff": "关闭本地服务器",
"localServerIsOff": "本地服务器已关闭",
"localServerOffFailed": "无法关闭本地服务器"
},
"appFeedback": {
"saveFailed": "无法保存设置",
@@ -3361,7 +3386,7 @@
"previous": "上一条",
"next": "下一条",
"done": "完成",
"autoShow": "Donut 启动时显示一条小贴士",
"autoShow": "每隔几天显示一条小贴士",
"items": {
"dnsBlocklist": {
"label": "DNS 拦截",
@@ -3432,7 +3457,7 @@
"localApi": {
"label": "API 与 MCP",
"title": "用脚本和智能体自动化 Donut",
"body": "本地 REST API 和 MCP 服务器可让脚本和 AI 智能体列出、创建和配置配置文件。在“集成”中开启并复制令牌。",
"body": "本地 REST API 可让脚本列出、创建和配置配置文件。在“集成”中开启并复制令牌。",
"action": "打开集成"
},
"importProfiles": {
+4 -6
View File
@@ -51,7 +51,7 @@ const PLAN_CAPABILITIES: Record<string, Capabilities> = {
teamCollaboration: false,
cookieBot: true,
remoteInteractive: true,
remoteControl: false,
remoteControl: true,
agentAutomation: true,
},
team: {
@@ -61,10 +61,9 @@ const PLAN_CAPABILITIES: Record<string, Capabilities> = {
teamCollaboration: true,
cookieBot: true,
remoteInteractive: true,
remoteControl: false,
remoteControl: true,
agentAutomation: true,
},
// The only tier that may drive this desktop from donutbrowser.com.
enterprise: {
browserAutomation: true,
crossOsFingerprints: true,
@@ -78,9 +77,8 @@ const PLAN_CAPABILITIES: Record<string, Capabilities> = {
};
// Unknown paid plan -> pro-level (never team), the conservative reading.
// remoteControl is the one exception and is withheld: nobody is paying for a
// capability that has no price, and an unrecognised plan string must not open
// an internet-facing hook into this machine.
// remoteControl is the one exception and is withheld: an unrecognised plan
// string must not open an internet-facing hook into this machine.
const DEFAULT_PAID: Capabilities = {
browserAutomation: true,
crossOsFingerprints: true,
+81
View File
@@ -0,0 +1,81 @@
/** Where an agent points to drive this browser through Donut cloud. */
export const REMOTE_MCP_URL = "https://api.donutbrowser.com/api/mcp";
/**
* fx refuses a literal Authorization header in its config and reads the
* bearer token from this variable instead, so its install cannot carry the
* credential and the user has to export it themselves.
*/
export const FX_TOKEN_ENV = "DONUT_MCP_TOKEN";
export const FX_AGENT_ID = "fx";
/** The line that hands fx the credential, or null while there is none. */
export function fxExportLine(key: string | null | undefined): string | null {
return key ? `export ${FX_TOKEN_ENV}=${key}` : null;
}
export type AgentCategory = "desktop-app" | "cli" | "editor" | "editor-ext";
/** The two places a client can be pointed at; the `target` of `add_mcp_to_agent`. */
export type McpEndpoint = "local" | "remote";
export interface McpAgentInfo {
id: string;
display_name: string;
category: AgentCategory;
connected: boolean;
detected: boolean;
/** Which Donut endpoint the agent's existing entry points at, when connected. */
endpoint?: McpEndpoint | null;
}
export interface McpRemoteStatus {
enabled: boolean;
connected: boolean;
instanceId: string;
lastError: string | null;
}
/**
* Only the prefix leaves the backend; the plaintext is installed into agent
* configs by the app itself. Both spellings are read because the spec writes
* the Rust field as `token_prefix` while the sibling status struct serialises
* camelCase, and a mismatch here would silently render a present credential
* as missing.
*/
export interface McpRemoteCredential {
present: boolean;
tokenPrefix?: string | null;
token_prefix?: string | null;
}
/**
* What a rotation answers. The key is stored and installed by the time this
* arrives; `failed_clients` are the ids of the clients whose config could not
* be rewritten, for the user to retry, never a reason to mint again.
*/
export interface McpRemoteCredentialRotation
extends Pick<McpRemoteCredential, "tokenPrefix" | "token_prefix"> {
failed_clients?: string[];
}
export function credentialPrefixOf(
credential: Pick<McpRemoteCredential, "tokenPrefix" | "token_prefix"> | null,
): string | null {
return credential?.tokenPrefix ?? credential?.token_prefix ?? null;
}
/** Mirror of `mcp_migration::McpMigrationOffer`. */
export interface McpMigrationOffer {
eligible: boolean;
due: boolean;
local_server_enabled: boolean;
local_clients: string[];
}
/** Clients whose Donut entry still points at the removed local server. */
export function localMcpClients(agents: McpAgentInfo[]): McpAgentInfo[] {
return agents.filter(
(agent) => agent.connected && agent.endpoint === "local",
);
}
+160 -9
View File
@@ -3,11 +3,43 @@ import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { isPlanTip, pickAutoTip, TIPS, tipsFor } from "./tips.ts";
import {
autoTipCandidates,
isPlanTip,
pickAutoTip,
TIPS,
tipsFor,
} from "./tips.ts";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const LOCALES = path.join(HERE, "..", "i18n", "locales");
/** An install that uses nothing a tip talks about. */
const IDLE = {
profiles: [],
currentOs: "macos",
groupCount: 0,
extensionGroupCount: 0,
inTeam: false,
cookieBotEnrolled: false,
syncServerConfigured: false,
};
const ALL_PLAN = {
active: true,
cloudBackup: true,
cookieBot: true,
crossOsFingerprints: true,
browserAutomation: true,
agentAutomation: true,
teamCollaboration: true,
remoteControl: true,
};
function ids(tips) {
return tips.map((tip) => tip.id);
}
const NONE = {
active: false,
cloudBackup: false,
@@ -66,15 +98,134 @@ test("a plan tip needs an active plan with that capability", () => {
);
});
test("the automatic flow picks the first unseen tip in catalog order", () => {
test("the automatic flow picks one unseen tip at random", () => {
const offered = tipsFor(NONE);
assert.equal(pickAutoTip(offered, []), offered[0]);
assert.equal(pickAutoTip(offered, [offered[0].id]), offered[1]);
assert.equal(
pickAutoTip(
offered,
offered.map((tip) => tip.id),
),
null,
pickAutoTip(offered, [], IDLE, () => 0),
offered[0],
);
assert.equal(
pickAutoTip(offered, [], IDLE, () => 0.999_999),
offered.at(-1),
"the top of the random range lands on the last candidate",
);
assert.equal(
pickAutoTip(offered, [offered[0].id], IDLE, () => 0),
offered[1],
"a seen tip is never picked",
);
assert.equal(pickAutoTip(offered, ids(offered), IDLE), null);
const picked = new Set();
for (let roll = 0; roll < offered.length; roll += 1) {
picked.add(pickAutoTip(offered, [], IDLE, () => roll / offered.length)?.id);
}
assert.equal(
picked.size,
offered.length,
"every candidate can come up, not only the first in catalog order",
);
});
test("an install that uses nothing yet is offered every unseen tip", () => {
const offered = tipsFor(ALL_PLAN);
assert.deepEqual(ids(autoTipCandidates(offered, [], IDLE)), ids(offered));
});
test("a tip for a feature already in use is skipped", () => {
const offered = tipsFor(ALL_PLAN);
const skipped = (usage) =>
ids(offered).filter(
(id) => !ids(autoTipCandidates(offered, [], usage)).includes(id),
);
const withProfile = (profile) => ({ ...IDLE, profiles: [profile] });
assert.deepEqual(skipped(withProfile({ dns_blocklist: "pro" })), [
"dnsBlocklist",
]);
assert.deepEqual(
skipped(withProfile({ dns_blocklist: null })),
[],
"a profile with DNS blocking off does not count",
);
assert.deepEqual(skipped(withProfile({ password_protected: true })), [
"profilePassword",
]);
assert.deepEqual(skipped(withProfile({ clear_on_close: true })), [
"clearOnClose",
]);
assert.deepEqual(skipped(withProfile({ group_id: "g1" })), ["groups"]);
assert.deepEqual(skipped({ ...IDLE, groupCount: 2 }), ["groups"]);
assert.deepEqual(skipped(withProfile({ extension_group_id: "e1" })), [
"extensionGroups",
]);
assert.deepEqual(skipped({ ...IDLE, extensionGroupCount: 1 }), [
"extensionGroups",
]);
assert.deepEqual(skipped(withProfile({ sync_mode: "Encrypted" })), [
"selfHostedSync",
"cloudBackup",
]);
assert.deepEqual(skipped(withProfile({ sync_mode: "Disabled" })), []);
assert.deepEqual(skipped({ ...IDLE, syncServerConfigured: true }), [
"selfHostedSync",
]);
assert.deepEqual(
skipped(
withProfile({ host_os: "macos", wayfern_config: { os: "windows" } }),
),
["crossOs"],
);
assert.deepEqual(
skipped(withProfile({ wayfern_config: { os: "windows" } })),
["crossOs"],
"without a recorded host, this desktop's OS is the native one",
);
assert.deepEqual(
skipped(withProfile({ host_os: "macos", wayfern_config: { os: "macos" } })),
[],
"a native fingerprint is not a cross-OS one",
);
assert.deepEqual(skipped({ ...IDLE, proxyChecked: true }), ["proxyCheck"]);
assert.deepEqual(skipped({ ...IDLE, fingerprintGateChanged: true }), [
"fingerprintGate",
]);
assert.deepEqual(skipped({ ...IDLE, isDefaultBrowser: true }), [
"defaultBrowser",
]);
assert.deepEqual(skipped({ ...IDLE, trashUsed: true }), ["trash"]);
assert.deepEqual(skipped({ ...IDLE, apiEnabled: true }), [
"localApi",
"automation",
]);
assert.deepEqual(skipped({ ...IDLE, mcpEnabled: true }), [
"localApi",
"automation",
]);
assert.deepEqual(skipped({ ...IDLE, cookieBotEnrolled: true }), [
"cookieBot",
]);
assert.deepEqual(skipped({ ...IDLE, agentUsed: true }), ["agent"]);
assert.deepEqual(skipped({ ...IDLE, inTeam: true }), ["team"]);
assert.deepEqual(skipped({ ...IDLE, remoteControlEnabled: true }), [
"remoteControl",
]);
});
test("only the command palette and import tips have no usage signal", () => {
assert.deepEqual(ids(TIPS.filter((tip) => tip.inUse === undefined)), [
"commandPalette",
"importProfiles",
]);
});
test("the pick skips tips in use and gives up when nothing is left", () => {
const offered = tipsFor(NONE);
const unseen = ["dnsBlocklist", "trash"];
const seen = ids(offered).filter((id) => !unseen.includes(id));
const dnsOn = { ...IDLE, profiles: [{ dns_blocklist: "light" }] };
for (const roll of [0, 0.5, 0.99]) {
assert.equal(pickAutoTip(offered, seen, dnsOn, () => roll)?.id, "trash");
}
assert.equal(pickAutoTip(offered, seen, { ...dnsOn, trashUsed: true }), null);
});
+157 -16
View File
@@ -1,5 +1,5 @@
import type { AppPage } from "@/components/rail-nav";
import type { Entitlements } from "@/types";
import type { BrowserProfile, Entitlements } from "@/types";
/**
* The plan capabilities a tip can be gated on. A tip that names one of these
@@ -45,71 +45,189 @@ export type TipId =
| "team"
| "remoteControl";
/** The profile fields the relevance checks read. */
export type TipProfile = Pick<
BrowserProfile,
| "dns_blocklist"
| "password_protected"
| "clear_on_close"
| "group_id"
| "extension_group_id"
| "sync_mode"
| "host_os"
> & { wayfern_config?: { os?: string } };
/**
* What this install already does, as far as the tips care. The first group
* is data the app has loaded anyway; the optional facts are read from the
* backend only when a tip that needs them is a candidate, and a fact that is
* missing (not read, or the read failed) counts as "not in use".
*/
export interface FeatureUsage {
profiles: readonly TipProfile[];
/** The OS of this desktop, to tell a cross-OS fingerprint from a native one. */
currentOs: string;
groupCount: number;
extensionGroupCount: number;
inTeam: boolean;
cookieBotEnrolled: boolean;
syncServerConfigured: boolean;
apiEnabled?: boolean;
mcpEnabled?: boolean;
remoteControlEnabled?: boolean;
/** The user turned the pre-launch fingerprint gate off, so they know it. */
fingerprintGateChanged?: boolean;
proxyChecked?: boolean;
isDefaultBrowser?: boolean;
trashUsed?: boolean;
agentUsed?: boolean;
}
export interface TipDefinition {
id: TipId;
action: TipAction;
requires?: TipRequirement;
/**
* True when the user already uses the feature, so the automatic flow skips
* the tip. Absent when nothing on this install tells: the command palette
* and import leave no trace.
*/
inUse?: (usage: FeatureUsage) => boolean;
}
function anyProfile(
usage: FeatureUsage,
test: (profile: TipProfile) => boolean,
): boolean {
return usage.profiles.some(test);
}
function syncsProfiles(usage: FeatureUsage): boolean {
return anyProfile(
usage,
(profile) => profile.sync_mode != null && profile.sync_mode !== "Disabled",
);
}
/**
* Every tip, in the order the automatic flow offers them. The essentials come
* first because they apply to every install; the plan tips follow, and each
* one is skipped for a plan that lacks the capability.
* Every tip, in the order the catalog lists them. The essentials come first
* because they apply to every install; the plan tips follow, and each one is
* skipped for a plan that lacks the capability. The automatic flow does not
* follow this order: it picks at random (see `pickAutoTip`).
*
* The copy lives under `tips.items.<id>` in every locale: `label`, `title`,
* `body` and `action`. `src/lib/tips.test.mjs` checks that no tip is missing its text.
*/
export const TIPS: readonly TipDefinition[] = [
{ id: "dnsBlocklist", action: { kind: "settings", section: "dns" } },
{ id: "proxyCheck", action: { kind: "page", page: "proxies" } },
{ id: "groups", action: { kind: "page", page: "groups" } },
{
id: "dnsBlocklist",
action: { kind: "settings", section: "dns" },
inUse: (usage) => anyProfile(usage, (profile) => !!profile.dns_blocklist),
},
{
id: "proxyCheck",
action: { kind: "page", page: "proxies" },
inUse: (usage) => usage.proxyChecked === true,
},
{
id: "groups",
action: { kind: "page", page: "groups" },
inUse: (usage) =>
usage.groupCount > 0 ||
anyProfile(usage, (profile) => !!profile.group_id),
},
{ id: "commandPalette", action: { kind: "palette" } },
{
id: "fingerprintGate",
action: { kind: "settings", section: "advanced" },
inUse: (usage) => usage.fingerprintGateChanged === true,
},
{
id: "profilePassword",
action: { kind: "page", page: "profiles" },
inUse: (usage) =>
anyProfile(usage, (profile) => profile.password_protected === true),
},
{
id: "clearOnClose",
action: { kind: "page", page: "profiles" },
inUse: (usage) =>
anyProfile(usage, (profile) => profile.clear_on_close === true),
},
{
id: "defaultBrowser",
action: { kind: "settings", section: "default" },
inUse: (usage) => usage.isDefaultBrowser === true,
},
{
id: "extensionGroups",
action: { kind: "page", page: "extensions" },
inUse: (usage) =>
usage.extensionGroupCount > 0 ||
anyProfile(usage, (profile) => !!profile.extension_group_id),
},
{
id: "selfHostedSync",
action: { kind: "page", page: "account" },
inUse: (usage) => usage.syncServerConfigured || syncsProfiles(usage),
},
{
id: "trash",
action: { kind: "page", page: "trash" },
inUse: (usage) => usage.trashUsed === true,
},
{
id: "localApi",
action: { kind: "page", page: "integrations" },
inUse: (usage) => usage.apiEnabled === true || usage.mcpEnabled === true,
},
{ id: "profilePassword", action: { kind: "page", page: "profiles" } },
{ id: "clearOnClose", action: { kind: "page", page: "profiles" } },
{ id: "defaultBrowser", action: { kind: "settings", section: "default" } },
{ id: "extensionGroups", action: { kind: "page", page: "extensions" } },
{ id: "selfHostedSync", action: { kind: "page", page: "account" } },
{ id: "trash", action: { kind: "page", page: "trash" } },
{ id: "localApi", action: { kind: "page", page: "integrations" } },
{ id: "importProfiles", action: { kind: "page", page: "import" } },
{
id: "cloudBackup",
action: { kind: "page", page: "account" },
requires: "cloudBackup",
inUse: syncsProfiles,
},
{
id: "cookieBot",
action: { kind: "page", page: "cookieBot" },
requires: "cookieBot",
inUse: (usage) => usage.cookieBotEnrolled,
},
{
id: "crossOs",
action: { kind: "page", page: "profiles" },
requires: "crossOsFingerprints",
inUse: (usage) =>
anyProfile(usage, (profile) => {
const reported = profile.wayfern_config?.os;
const native = profile.host_os ?? usage.currentOs;
return !!reported && reported !== native;
}),
},
{
id: "automation",
action: { kind: "page", page: "integrations" },
requires: "browserAutomation",
inUse: (usage) => usage.apiEnabled === true || usage.mcpEnabled === true,
},
{
id: "agent",
action: { kind: "page", page: "agent" },
requires: "agentAutomation",
inUse: (usage) => usage.agentUsed === true,
},
{
id: "team",
action: { kind: "page", page: "account" },
requires: "teamCollaboration",
inUse: (usage) => usage.inTeam,
},
{
id: "remoteControl",
action: { kind: "page", page: "integrations" },
requires: "remoteControl",
inUse: (usage) => usage.remoteControlEnabled === true,
},
];
@@ -137,12 +255,35 @@ export function tipsFor(
);
}
/** The first tip the user has not seen yet, or null when they have seen them all. */
/** The tips the automatic flow may still open: unseen, and not in use. */
export function autoTipCandidates(
tips: readonly TipDefinition[],
seen: readonly string[],
usage: FeatureUsage,
): TipDefinition[] {
return tips.filter(
(tip) => !seen.includes(tip.id) && !(tip.inUse?.(usage) ?? false),
);
}
/**
* The one tip the automatic flow opens: a random unseen tip for a feature
* the user does not use yet, or null when none is left. `random` returns a
* number in [0, 1), like `Math.random`.
*/
export function pickAutoTip(
tips: readonly TipDefinition[],
seen: readonly string[],
usage: FeatureUsage,
random: () => number = Math.random,
): TipDefinition | null {
return tips.find((tip) => !seen.includes(tip.id)) ?? null;
const candidates = autoTipCandidates(tips, seen, usage);
if (candidates.length === 0) return null;
const index = Math.min(
candidates.length - 1,
Math.floor(random() * candidates.length),
);
return candidates[index];
}
export function tipTextKeys(id: TipId): {
+1 -1
View File
@@ -133,7 +133,7 @@ export interface Entitlements {
remoteInteractive: boolean;
/**
* May drive this desktop from donutbrowser.com, the remote MCP endpoint and
* the API in front of it. Enterprise only.
* the API in front of it. Pro, Team and Enterprise.
*
* Read by the UI to explain why a connected desktop cannot be driven. The
* bridge itself never gates on it: the server decides who may send work.