mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-19 22:17:28 +02:00
refactor: cleanup
This commit is contained in:
1 parent
1abed30af6
commit
b7c8f56511
36 files changed
+2739
-583
No files matched your search
@@ -52,6 +52,7 @@ donutbrowser/
|
||||
│ │ ├── mcp_server.rs # MCP protocol server (tool engine + local loopback listener)
|
||||
│ │ ├── mcp_remote.rs # Remote MCP bridge: outbound websocket to Donut cloud (Enterprise remote control)
|
||||
│ │ ├── mcp_integrations.rs # 20-client MCP installer: local URL or remote endpoint with bearer, format-preserving JSONC/TOML edits
|
||||
│ │ ├── mcp_migration.rs # Local-to-remote MCP move: once-per-account offer, local server switch-off, removal notice
|
||||
│ │ ├── automation_rate_limiter.rs # Shared REST/MCP automation quota
|
||||
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
|
||||
│ │ ├── vpn/ # WireGuard tunnels
|
||||
@@ -275,23 +276,39 @@ Reference implementations: `proxy-management-dialog.tsx`, `extension-management-
|
||||
|
||||
Tips are short feature walkthroughs: a looping SVG scene, a title, two or
|
||||
three lines of copy, and a button into the feature. The catalog is
|
||||
`src/lib/tips.ts` (ids, deep-link actions, the plan capability a tip needs);
|
||||
scenes live in `src/components/tips/scenes-*.tsx` and are mapped in
|
||||
`scene-for.tsx`; the dialog is `src/components/tips-dialog.tsx`; the flow
|
||||
(what to open when) is `src/hooks/use-tips.ts`. State (`tips_auto_show`,
|
||||
`tips_seen`, `tips_last_auto_shown_at`, `paid_welcome_seen_for`,
|
||||
`src/lib/tips.ts` (ids, deep-link actions, the plan capability a tip needs,
|
||||
the `inUse` relevance check); scenes live in `src/components/tips/scenes-*.tsx`
|
||||
and are mapped in `scene-for.tsx`; the dialog is
|
||||
`src/components/tips-dialog.tsx`; the flow (what to open when) is
|
||||
`src/hooks/use-tips.ts`. State (`tips_auto_show`, `tips_seen`,
|
||||
`tips_last_auto_shown_at`, `tips_next_auto_show_at`, `paid_welcome_seen_for`,
|
||||
`cloud_plan_memory`) is in `AppSettings`, behind the `get_tips_state`,
|
||||
`mark_tip_seen`, `set_tips_auto_show` and `observe_cloud_plan` commands.
|
||||
|
||||
- One unseen tip opens by itself at most once a day, only after a settled
|
||||
launch (onboarding done, terms accepted, nothing modal open), never in the
|
||||
first-run session. The E2E harness seeds `tips_auto_show: false`; a test
|
||||
that wants the automatic tip passes `settings: { tips_auto_show: true }`.
|
||||
- The automatic tip is one card (`mode: "single"`): no catalog, no pager, and
|
||||
its button is Done. The catalog with the pager opens only from the rail,
|
||||
the shortcut, the command palette and the paid welcome (`mode: "browse"`).
|
||||
- It opens only after a settled launch (onboarding done, terms accepted,
|
||||
nothing modal open), never in the first-run session. When one opens, the
|
||||
backend draws the next time one may open, a random 2 to 5 days later
|
||||
(`next_auto_tip_at`), and stores it in `tips_next_auto_show_at`, so a
|
||||
restart keeps the same wait. The E2E harness seeds `tips_auto_show: false`;
|
||||
a test that wants the automatic tip passes `settings: { tips_auto_show: true }`
|
||||
or calls `set_tips_auto_show`.
|
||||
- The automatic flow picks at random among the unseen tips whose feature is
|
||||
not in use (`pickAutoTip`). Each tip's `inUse` reads a `FeatureUsage`
|
||||
snapshot: profile fields, groups, extension groups, team, Cookie Bot
|
||||
enrolments and the sync server come from data `page.tsx` has loaded; API,
|
||||
MCP, remote control, fingerprint gate, proxy check history, default
|
||||
browser, trash and agent runs are read by `use-tips.ts` through existing
|
||||
commands, and only for the tips still in the running. An unknown fact
|
||||
never hides a tip. The command palette and import tips have no signal.
|
||||
- Plan tips carry `requires`; they are listed only when the signed-in plan
|
||||
grants the capability. The paid welcome opens once per account when the
|
||||
backend sees it turn paid (free -> paid, or a paid account first seen right
|
||||
after signing in); `paid_welcome_due` in `settings_manager.rs` is the rule.
|
||||
- Adding a tip: append to `TIPS`, write the scene, add
|
||||
- Adding a tip: append to `TIPS` with an `inUse` check when the install
|
||||
shows whether the feature is used, write the scene, add
|
||||
`tips.items.<id>.{label,title,body,action}` to every locale, and run
|
||||
`pnpm test:tips`, which checks every locale carries every tip.
|
||||
- Scenes are decorative and loop on their own clock (`useScene`); they show
|
||||
|
||||
Generated
+1
-1
@@ -1720,7 +1720,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.30.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
@@ -246,6 +246,15 @@ export const commandCoverage = {
|
||||
"synchronizer::arrange_sync_windows",
|
||||
],
|
||||
},
|
||||
mcpMigration: {
|
||||
suite: "ui",
|
||||
level: "integration",
|
||||
commands: [
|
||||
"mcp_migration::get_mcp_migration_offer",
|
||||
"mcp_migration::mark_mcp_migration_offered",
|
||||
"mcp_migration::turn_off_local_mcp_server",
|
||||
],
|
||||
},
|
||||
syncAndEncryption: {
|
||||
suite: "sync",
|
||||
level: "integration",
|
||||
|
||||
@@ -70,11 +70,13 @@ test("fresh app renders, completes onboarding, persists settings, and never touc
|
||||
assert.ok(system && typeof system === "object");
|
||||
assert.equal(typeof (await app.invoke("read_log_files")), "string");
|
||||
|
||||
// Feature tips: what was seen, the one-a-day pacing, and the decision
|
||||
// behind the paid-plan welcome all live in the settings file.
|
||||
// Feature tips: what was seen, the random 2-5 day wait between
|
||||
// automatic tips, and the decision behind the paid-plan welcome all
|
||||
// live in the settings file.
|
||||
const tips = await app.invoke("get_tips_state");
|
||||
assert.equal(tips.auto_show, true);
|
||||
assert.deepEqual(tips.seen, []);
|
||||
assert.equal(tips.next_auto_show_at, null);
|
||||
assert.equal(tips.auto_due, true, "a fresh install owes its first tip");
|
||||
const marked = await app.invoke("mark_tip_seen", {
|
||||
tipId: "dnsBlocklist",
|
||||
@@ -82,7 +84,12 @@ test("fresh app renders, completes onboarding, persists settings, and never touc
|
||||
});
|
||||
assert.deepEqual(marked.seen, ["dnsBlocklist"]);
|
||||
assert.equal(typeof marked.last_auto_shown_at, "number");
|
||||
assert.equal(marked.auto_due, false, "one automatic tip a day");
|
||||
const gap = marked.next_auto_show_at - marked.last_auto_shown_at;
|
||||
assert.ok(
|
||||
gap >= 2 * 24 * 60 * 60 && gap <= 5 * 24 * 60 * 60,
|
||||
`the next automatic tip waits 2 to 5 days, not ${gap} seconds`,
|
||||
);
|
||||
assert.equal(marked.auto_due, false, "the wait has only started");
|
||||
const browsed = await app.invoke("mark_tip_seen", {
|
||||
tipId: "proxyCheck",
|
||||
auto: false,
|
||||
@@ -93,6 +100,7 @@ test("fresh app renders, completes onboarding, persists settings, and never touc
|
||||
marked.last_auto_shown_at,
|
||||
"a browsed tip must not restart the pacing",
|
||||
);
|
||||
assert.equal(browsed.next_auto_show_at, marked.next_auto_show_at);
|
||||
const quiet = await app.invoke("set_tips_auto_show", { enabled: false });
|
||||
assert.equal(quiet.auto_show, false);
|
||||
assert.equal(quiet.auto_due, false);
|
||||
@@ -149,6 +157,11 @@ test("fresh app renders, completes onboarding, persists settings, and never touc
|
||||
assert.equal(afterRestart.onboarding_completed, true);
|
||||
assert.deepEqual(afterRestart.tips_seen, ["dnsBlocklist", "proxyCheck"]);
|
||||
assert.equal(afterRestart.tips_auto_show, false);
|
||||
assert.equal(
|
||||
afterRestart.tips_next_auto_show_at,
|
||||
marked.next_auto_show_at,
|
||||
"the drawn wait survives a restart",
|
||||
);
|
||||
assert.deepEqual((await app.invoke("get_tips_state")).seen, [
|
||||
"dnsBlocklist",
|
||||
"proxyCheck",
|
||||
|
||||
+427
-16
@@ -349,7 +349,6 @@ test("the integrations page ships the Local API and MCP tabs and never names rem
|
||||
en.integrations.remote.enableLabel,
|
||||
en.integrations.remote.signInRequired,
|
||||
en.integrations.remote.endpointLabel,
|
||||
en.integrations.remote.notEntitled,
|
||||
]) {
|
||||
assert.ok(
|
||||
!html.includes(phrase),
|
||||
@@ -2507,10 +2506,23 @@ test("tips open from the rail, walk the catalog, and deep-link into the feature"
|
||||
});
|
||||
});
|
||||
|
||||
test("a tip opens by itself once the app settles, then waits a day", async () => {
|
||||
test("one tip for an unused feature opens by itself as a single card, then waits 2 to 5 days", async () => {
|
||||
// Every tip but two is seen already, and the only profile blocks ads, so
|
||||
// the DNS tip is in use and the trash tip is the one the automatic flow
|
||||
// may open. The flow is switched on only after that profile exists.
|
||||
const unseen = ["dnsBlocklist", "trash"];
|
||||
const seen = Object.keys(en.tips.items).filter((id) => !unseen.includes(id));
|
||||
await withApp(
|
||||
"ui-tips-auto",
|
||||
async (app) => {
|
||||
const profile = await createUiProfile(app, "Tips Blocker");
|
||||
await app.invoke("update_profile_dns_blocklist", {
|
||||
profileId: profile.id,
|
||||
dnsBlocklist: "light",
|
||||
});
|
||||
await app.invoke("set_tips_auto_show", { enabled: true });
|
||||
await app.restart();
|
||||
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
@@ -2519,28 +2531,50 @@ test("a tip opens by itself once the app settles, then waits a day", async () =>
|
||||
),
|
||||
{ description: "the automatic tip", timeoutMs: 30_000 },
|
||||
);
|
||||
assert.ok(
|
||||
await app.visibleTextIncludes(en.tips.items.dnsBlocklist.title),
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
`return document.querySelector('[data-slot="tip-detail"]')?.dataset.tipId;`,
|
||||
),
|
||||
"trash",
|
||||
"the DNS tip is skipped because a profile already blocks ads",
|
||||
);
|
||||
assert.ok(await app.visibleTextIncludes(en.tips.items.trash.title));
|
||||
assert.deepEqual(
|
||||
await app.execute(
|
||||
`return ['tips-list-item', 'tip-previous', 'tip-next'].map((slot) => document.querySelectorAll('[data-slot="' + slot + '"]').length);`,
|
||||
),
|
||||
[0, 0, 0],
|
||||
"one tip at a time: no catalog and no pager",
|
||||
);
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
`return document.querySelectorAll('[data-slot="tips-list-item"]').length;`,
|
||||
`return document.querySelector('[data-slot="tip-advance"]')?.textContent.trim();`,
|
||||
),
|
||||
0,
|
||||
"the single card carries no catalog",
|
||||
en.tips.done,
|
||||
);
|
||||
await app.capture("tips-auto");
|
||||
await app.waitFor(
|
||||
|
||||
const state = await app.waitFor(
|
||||
async () => {
|
||||
const state = await app.invoke("get_tips_state");
|
||||
return (
|
||||
state.seen.includes("dnsBlocklist") && state.auto_due === false
|
||||
);
|
||||
const current = await app.invoke("get_tips_state");
|
||||
return current.seen.includes("trash") && current.auto_due === false
|
||||
? current
|
||||
: null;
|
||||
},
|
||||
{ description: "the automatic tip recorded" },
|
||||
);
|
||||
assert.ok(
|
||||
!state.seen.includes("dnsBlocklist"),
|
||||
"the skipped tip stays unseen",
|
||||
);
|
||||
const gap = state.next_auto_show_at - state.last_auto_shown_at;
|
||||
assert.ok(
|
||||
gap >= 2 * 24 * 60 * 60 && gap <= 5 * 24 * 60 * 60,
|
||||
`the next automatic tip waits 2 to 5 days, not ${gap} seconds`,
|
||||
);
|
||||
|
||||
await dismissSurface(app);
|
||||
// Done closes the card instead of walking to another tip.
|
||||
await app.clickSelector('[data-slot="tip-advance"]');
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(`return !document.querySelector(arguments[0]);`, [
|
||||
@@ -2549,9 +2583,9 @@ test("a tip opens by itself once the app settles, then waits a day", async () =>
|
||||
{ description: "the dialog closed" },
|
||||
);
|
||||
|
||||
// A restart within the day shows nothing: one tip a day.
|
||||
// A restart inside the gap shows nothing, and keeps the same wait.
|
||||
await app.restart();
|
||||
await app.waitForText("No profiles yet");
|
||||
await app.waitForText("Tips Blocker");
|
||||
await new Promise((resolve) => setTimeout(resolve, 4_500));
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
@@ -2560,8 +2594,12 @@ test("a tip opens by itself once the app settles, then waits a day", async () =>
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
(await app.invoke("get_tips_state")).next_auto_show_at,
|
||||
state.next_auto_show_at,
|
||||
);
|
||||
},
|
||||
{ settings: { tips_auto_show: true } },
|
||||
{ settings: { tips_seen: seen } },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2670,3 +2708,376 @@ test("a freshly paid account is welcomed once and walked to its plan tips", asyn
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const MCP_MIGRATION = '[data-slot="mcp-migration"]';
|
||||
|
||||
/**
|
||||
* Fails `add_mcp_to_agent` for the listed agent ids with the given backend
|
||||
* error, the way the Rust command rejects, and answers the rest with success.
|
||||
* Installed on top of `stubCommand`, so `restoreStubs` removes it too.
|
||||
*/
|
||||
async function stubAgentInstall(app, failures) {
|
||||
await app.execute(
|
||||
`window.__donutInstallFailures = arguments[0];
|
||||
if (!window.__donutInstallStubbed) {
|
||||
window.__donutInstallStubbed = true;
|
||||
const next = window.fetch;
|
||||
window.fetch = function (input, init) {
|
||||
const url = String(
|
||||
typeof input === "string" ? input : (input && input.url) || "",
|
||||
);
|
||||
if (!url.endsWith("/add_mcp_to_agent")) {
|
||||
return next.apply(window, arguments);
|
||||
}
|
||||
const payload = JSON.parse((init && init.body) || "null");
|
||||
window.__donutStubbedCalls = window.__donutStubbedCalls ?? [];
|
||||
window.__donutStubbedCalls.push({ command: "add_mcp_to_agent", payload });
|
||||
const error = window.__donutInstallFailures[payload && payload.agentId];
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(error ?? null), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"Tauri-Response": error ? "error" : "ok",
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
}`,
|
||||
[failures],
|
||||
);
|
||||
}
|
||||
|
||||
const migrationShown = (app) =>
|
||||
app.execute(`return Boolean(document.querySelector(arguments[0]));`, [
|
||||
MCP_MIGRATION,
|
||||
]);
|
||||
|
||||
const migrationFlowState = (app) =>
|
||||
app.execute(
|
||||
`return document.querySelector(arguments[0] + ' [data-slot="operation-flow"]')?.dataset.state ?? null;`,
|
||||
[MCP_MIGRATION],
|
||||
);
|
||||
|
||||
const migrationClientStates = (app) =>
|
||||
app.execute(
|
||||
`return Object.fromEntries([...document.querySelectorAll('[data-slot="mcp-migration-client"]')].map((node) => [node.dataset.agentId, node.dataset.state]));`,
|
||||
);
|
||||
|
||||
test("local MCP on a desktop that cannot use remote MCP is never offered the move", async () => {
|
||||
await withApp(
|
||||
"ui-mcp-migration-not-entitled",
|
||||
async (app) => {
|
||||
await app.waitForText("No profiles yet");
|
||||
// A client still pointing at the removed loopback server, next to the
|
||||
// legacy flag: local MCP is in use. Nobody is signed in.
|
||||
const cursorConfig = path.join(app.root, "home", ".cursor", "mcp.json");
|
||||
await mkdir(path.dirname(cursorConfig), { recursive: true });
|
||||
await writeFile(
|
||||
cursorConfig,
|
||||
`${JSON.stringify({
|
||||
mcpServers: {
|
||||
"donut-browser": { url: "http://127.0.0.1:51080/mcp/e2e-token" },
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
assert.deepEqual(await app.invoke("get_mcp_migration_offer"), {
|
||||
eligible: false,
|
||||
due: false,
|
||||
local_server_enabled: true,
|
||||
local_clients: ["cursor"],
|
||||
});
|
||||
// Without an account there is nobody to remember the offer for.
|
||||
assert.match(
|
||||
await app.invokeError("mark_mcp_migration_offered"),
|
||||
/"code":"MCP_REMOTE_REQUIRES_SIGN_IN"/,
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await app.invoke("get_app_settings")).mcp_migration_offered_for,
|
||||
[],
|
||||
);
|
||||
|
||||
// Well past the moment a due offer opens, and nothing did.
|
||||
await new Promise((resolve) => setTimeout(resolve, 3_000));
|
||||
assert.equal(await migrationShown(app), false);
|
||||
|
||||
// The MCP tab keeps its plain removal banner: no move, no plan pitch.
|
||||
await app.clickSelector('[aria-label="Integrations"]');
|
||||
await app.waitForText(en.integrations.tabMcp);
|
||||
await app.clickText(en.integrations.tabMcp, { roles: ["tab"] });
|
||||
await app.waitForText(en.integrations.mcp.deprecatedBannerTitle);
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
`return Boolean(document.querySelector('[data-slot="mcp-migration-open"]'));`,
|
||||
),
|
||||
false,
|
||||
"the move is offered only to an account that can use remote MCP",
|
||||
);
|
||||
assert.ok(
|
||||
await app.visibleTextIncludes(en.integrations.mcp.deprecatedCta),
|
||||
);
|
||||
await dismissSurface(app);
|
||||
|
||||
// Turning the local server off needs no account, clears the flag the
|
||||
// next launch reads, and is safe to repeat.
|
||||
await app.invoke("turn_off_local_mcp_server");
|
||||
assert.equal((await app.invoke("get_app_settings")).mcp_enabled, false);
|
||||
assert.equal(await app.invoke("get_mcp_server_status"), false);
|
||||
await app.invoke("turn_off_local_mcp_server");
|
||||
const offer = await app.invoke("get_mcp_migration_offer");
|
||||
assert.equal(offer.local_server_enabled, false);
|
||||
assert.deepEqual(offer.local_clients, ["cursor"]);
|
||||
},
|
||||
{ settings: { mcp_enabled: true } },
|
||||
);
|
||||
});
|
||||
|
||||
test("an account that can use remote MCP is offered the move once, and it runs client by client with a retry", async () => {
|
||||
await withApp("ui-mcp-migration", async (app) => {
|
||||
await app.waitForText("No profiles yet");
|
||||
const settings = await app.invoke("get_app_settings");
|
||||
try {
|
||||
// The cloud answers and the client rewrites are stubbed at the IPC
|
||||
// layer: the harness has no account and must not touch real clients.
|
||||
// Everything the dialog decides and shows runs for real.
|
||||
await stubCommand(app, "get_mcp_migration_offer", {
|
||||
eligible: true,
|
||||
due: true,
|
||||
local_server_enabled: true,
|
||||
local_clients: ["cursor", "fx"],
|
||||
});
|
||||
await stubCommand(app, "mark_mcp_migration_offered", null);
|
||||
await stubCommand(app, "list_mcp_agents", [
|
||||
{
|
||||
id: "cursor",
|
||||
display_name: "Cursor",
|
||||
category: "editor",
|
||||
connected: true,
|
||||
detected: true,
|
||||
endpoint: "local",
|
||||
token_env: null,
|
||||
},
|
||||
{
|
||||
id: "fx",
|
||||
display_name: "fx",
|
||||
category: "cli",
|
||||
connected: true,
|
||||
detected: true,
|
||||
endpoint: "local",
|
||||
token_env: "DONUT_MCP_TOKEN",
|
||||
},
|
||||
{
|
||||
id: "zed",
|
||||
display_name: "Zed",
|
||||
category: "editor",
|
||||
connected: false,
|
||||
detected: true,
|
||||
endpoint: null,
|
||||
token_env: null,
|
||||
},
|
||||
]);
|
||||
await stubCommand(app, "get_app_settings", {
|
||||
...settings,
|
||||
mcp_enabled: true,
|
||||
mcp_remote_key: "dmk_e2e0123456789abcdef",
|
||||
});
|
||||
const bridge = {
|
||||
enabled: false,
|
||||
connected: false,
|
||||
instanceId: "ui-mcp-migration",
|
||||
lastError: null,
|
||||
};
|
||||
await stubCommand(app, "get_mcp_remote_status", bridge);
|
||||
await stubCommand(app, "start_mcp_remote_bridge", {
|
||||
...bridge,
|
||||
enabled: true,
|
||||
});
|
||||
await stubCommand(app, "get_mcp_remote_credential", {
|
||||
present: false,
|
||||
token_prefix: null,
|
||||
});
|
||||
await stubCommand(app, "rotate_mcp_remote_credential", {
|
||||
token_prefix: "dmk_e2e01234",
|
||||
failed_clients: [],
|
||||
});
|
||||
await stubCommand(app, "turn_off_local_mcp_server", null);
|
||||
await stubCommand(app, "get_remote_control_entitlement", true);
|
||||
// Signed in two days ago, so this is not a fresh paid sign-in and the
|
||||
// paid welcome stays out of the way.
|
||||
await stubCommand(app, "cloud_get_user", {
|
||||
logged_in_at: new Date(
|
||||
Date.now() - 2 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
user: {
|
||||
id: "ui-mcp-migration",
|
||||
email: "remote@example.test",
|
||||
plan: "pro",
|
||||
planPeriod: "monthly",
|
||||
subscriptionStatus: "active",
|
||||
profileLimit: 50,
|
||||
cloudProfilesUsed: 0,
|
||||
proxyBandwidthLimitMb: 0,
|
||||
proxyBandwidthUsedMb: 0,
|
||||
proxyBandwidthExtraMb: 0,
|
||||
isPrimaryDevice: true,
|
||||
},
|
||||
});
|
||||
await stubAgentInstall(app, {
|
||||
cursor: JSON.stringify({
|
||||
code: "MCP_AGENT_INSTALL_FAILED",
|
||||
params: { detail: "the config file is read-only" },
|
||||
}),
|
||||
});
|
||||
|
||||
// Signing in settles the account; the offer opens by itself.
|
||||
await app.invoke("plugin:event|emit", {
|
||||
event: "cloud-auth-changed",
|
||||
payload: null,
|
||||
});
|
||||
await app.waitFor(() => migrationShown(app), {
|
||||
description: "the remote MCP move offer",
|
||||
});
|
||||
assert.deepEqual(
|
||||
await app.waitFor(
|
||||
async () => {
|
||||
const states = await migrationClientStates(app);
|
||||
return Object.keys(states).length > 0 ? states : null;
|
||||
},
|
||||
{ description: "the clients on the local server" },
|
||||
),
|
||||
{ cursor: "waiting", fx: "waiting" },
|
||||
"only the clients on the local server are listed",
|
||||
);
|
||||
assert.ok(await app.visibleTextIncludes(en.mcpMigration.title));
|
||||
assert.ok(await app.visibleTextIncludes(en.mcpMigration.changes));
|
||||
await app.capture("mcp-migration-offer");
|
||||
|
||||
await app.clickSelector('[data-slot="mcp-migration-start"]');
|
||||
await app.waitFor(
|
||||
async () => (await migrationFlowState(app)) === "failed",
|
||||
{ description: "the move to finish with one client failed" },
|
||||
);
|
||||
assert.deepEqual(await migrationClientStates(app), {
|
||||
cursor: "failed",
|
||||
fx: "moved",
|
||||
});
|
||||
assert.ok(
|
||||
await app.visibleTextIncludes(
|
||||
en.backendErrors.mcpAgentInstallFailed.replace(
|
||||
"{{detail}}",
|
||||
"the config file is read-only",
|
||||
),
|
||||
),
|
||||
"the failure is the translated backend error, per client",
|
||||
);
|
||||
assert.ok(await app.visibleTextIncludes(en.integrations.remote.fxHint));
|
||||
assert.ok(
|
||||
await app.visibleTextIncludes(
|
||||
en.mcpMigration.failed_one.replace("{{count}}", "1"),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
await app.visibleTextIncludes("https://api.donutbrowser.com/api/mcp"),
|
||||
"the endpoint is shown for clients set up by hand",
|
||||
);
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
`return Boolean(document.querySelector('[data-slot="mcp-migration-local-off"]'));`,
|
||||
),
|
||||
false,
|
||||
"the local server is not offered for switch-off while a client still uses it",
|
||||
);
|
||||
await app.capture("mcp-migration-partial");
|
||||
|
||||
// Retry moves only what is left.
|
||||
await stubAgentInstall(app, {});
|
||||
await app.clickSelector('[data-slot="mcp-migration-start"]');
|
||||
await app.waitFor(
|
||||
async () => (await migrationFlowState(app)) === "done",
|
||||
{ description: "the retried move to finish" },
|
||||
);
|
||||
assert.deepEqual(await migrationClientStates(app), {
|
||||
cursor: "moved",
|
||||
fx: "moved",
|
||||
});
|
||||
assert.ok(await app.visibleTextIncludes(en.mcpMigration.doneTitle));
|
||||
assert.ok(
|
||||
await app.visibleTextIncludes(
|
||||
en.mcpMigration.summary_other.replace("{{count}}", "2"),
|
||||
),
|
||||
);
|
||||
|
||||
await app.clickSelector('[data-slot="mcp-migration-local-off"]');
|
||||
await app.waitForText(en.mcpMigration.localServerIsOff);
|
||||
await app.capture("mcp-migration-done");
|
||||
|
||||
const calls = await app.execute(
|
||||
`return (window.__donutStubbedCalls ?? []).map((call) => ({ command: call.command, payload: call.payload }));`,
|
||||
);
|
||||
const called = (command) =>
|
||||
calls.filter((call) => call.command === command);
|
||||
assert.equal(called("mark_mcp_migration_offered").length, 1);
|
||||
assert.equal(called("start_mcp_remote_bridge").length, 1);
|
||||
assert.equal(
|
||||
called("rotate_mcp_remote_credential").length,
|
||||
1,
|
||||
"the retry must not mint a second credential",
|
||||
);
|
||||
assert.deepEqual(
|
||||
called("add_mcp_to_agent").map((call) => call.payload),
|
||||
[
|
||||
{ agentId: "cursor", target: "remote" },
|
||||
{ agentId: "fx", target: "remote" },
|
||||
{ agentId: "cursor", target: "remote" },
|
||||
],
|
||||
);
|
||||
assert.equal(called("turn_off_local_mcp_server").length, 1);
|
||||
|
||||
await app.clickSelector('[data-slot="mcp-migration-done"]');
|
||||
await app.waitFor(async () => !(await migrationShown(app)), {
|
||||
description: "the move dialog closed",
|
||||
});
|
||||
|
||||
// Once: the same account settling again does not reopen it.
|
||||
await app.invoke("plugin:event|emit", {
|
||||
event: "cloud-auth-changed",
|
||||
payload: null,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_500));
|
||||
assert.equal(await migrationShown(app), false);
|
||||
|
||||
// The MCP tab offers the same move by hand.
|
||||
await app.clickSelector('[aria-label="Integrations"]');
|
||||
await app.waitForText(en.integrations.tabMcp);
|
||||
await app.clickText(en.integrations.tabMcp, { roles: ["tab"] });
|
||||
await app.clickSelector('[data-slot="mcp-migration-open"]');
|
||||
assert.deepEqual(
|
||||
await app.waitFor(
|
||||
async () => {
|
||||
const states = await migrationClientStates(app);
|
||||
return Object.keys(states).length > 0 ? states : null;
|
||||
},
|
||||
{ description: "the move opened from the MCP tab" },
|
||||
),
|
||||
{ cursor: "waiting", fx: "waiting" },
|
||||
"every opening starts fresh",
|
||||
);
|
||||
await app.clickSelector('[data-slot="mcp-migration-dismiss"]');
|
||||
await app.waitFor(async () => !(await migrationShown(app)), {
|
||||
description: "the move dialog dismissed",
|
||||
});
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
`return (window.__donutStubbedCalls ?? []).filter((call) => call.command === "mark_mcp_migration_offered").length;`,
|
||||
),
|
||||
1,
|
||||
"opening it by hand is not the once-per-account offer",
|
||||
);
|
||||
await dismissSurface(app);
|
||||
} finally {
|
||||
await restoreStubs(app);
|
||||
await app.execute(`delete window.__donutInstallStubbed;
|
||||
delete window.__donutInstallFailures;`);
|
||||
}
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.31.0",
|
||||
"version": "0.31.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "pnpm licenses:generate",
|
||||
|
||||
Generated
+1
-1
@@ -1738,7 +1738,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.31.0"
|
||||
version = "0.31.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.31.0"
|
||||
version = "0.31.1"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -109,8 +109,8 @@ fn derive_entitlements(
|
||||
// one row where cookie_bot and browser_automation disagree, which is why
|
||||
// cookie_bot can no longer be derived from browser_automation below.
|
||||
//
|
||||
// remote_control is enterprise-only, and is withheld from the unrecognized
|
||||
// row rather than granted with the rest. Everything else here defaults
|
||||
// remote_control belongs to pro, team and enterprise, and is withheld from
|
||||
// the unrecognized row rather than granted with the rest. Everything else here defaults
|
||||
// generous so a comped account is never locked out of what it is paying for;
|
||||
// an internet-facing hook into this machine is the one capability where
|
||||
// guessing "probably yes" is not the safe direction to guess in.
|
||||
@@ -126,7 +126,8 @@ fn derive_entitlements(
|
||||
) = match plan {
|
||||
"solo" => (false, false, true, false, true, false, false, false),
|
||||
"enterprise" => (true, true, true, true, true, true, true, true),
|
||||
"team" => (true, true, true, true, true, true, false, true),
|
||||
"team" => (true, true, true, true, true, true, true, true),
|
||||
"pro" => (true, true, true, false, true, true, true, true),
|
||||
_ => (true, true, true, false, true, true, false, true),
|
||||
};
|
||||
Entitlements {
|
||||
@@ -1912,6 +1913,17 @@ mod tests {
|
||||
assert!(!derive_entitlements("pro", Some("monthly"), "canceled", 50).agent_automation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_control_is_granted_to_pro_team_and_enterprise_only() {
|
||||
for plan in ["pro", "team", "enterprise"] {
|
||||
let derived = derive_entitlements(plan, Some("monthly"), "active", 50);
|
||||
assert!(derived.remote_control, "{plan} should get remote control");
|
||||
}
|
||||
assert!(!active_solo().remote_control);
|
||||
assert!(!derive_entitlements("some-comped-plan", Some("monthly"), "active", 50).remote_control);
|
||||
assert!(!derive_entitlements("pro", Some("monthly"), "canceled", 50).remote_control);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backend_that_never_heard_of_the_agent_reports_no_agent() {
|
||||
// The whole point of `default` here: an older backend's entitlements object
|
||||
|
||||
+22
-97
@@ -118,6 +118,7 @@ mod cookie_manager;
|
||||
mod cookie_paste;
|
||||
pub mod events;
|
||||
mod mcp_integrations;
|
||||
mod mcp_migration;
|
||||
mod mcp_remote;
|
||||
mod mcp_server;
|
||||
mod tag_manager;
|
||||
@@ -1365,103 +1366,25 @@ async fn add_mcp_to_agent(
|
||||
.map_err(|e| backend_error_with_detail("MCP_AGENT_INSTALL_FAILED", e))
|
||||
}
|
||||
|
||||
/// Re-run the install for every client whose entry points at `endpoint`, so a
|
||||
/// rotated credential or a moved local port and token do not leave them
|
||||
/// talking to a dead target. Every client is attempted; the ids of the ones
|
||||
/// that could not be rewritten come back, each already logged with its reason.
|
||||
/// Ensure a remote MCP credential is stored, minting one when there is none.
|
||||
///
|
||||
/// Used to auto-migrate a paid user off the removed local endpoint. Mirrors the
|
||||
/// mint-then-store half of `rotate_mcp_remote_credential`; it does not rotate an
|
||||
/// existing key, because a working key is exactly what migration wants to keep.
|
||||
async fn ensure_remote_mcp_key() -> Result<(), String> {
|
||||
let settings_manager = settings_manager::SettingsManager::instance();
|
||||
let has_key = settings_manager
|
||||
.get_mcp_remote_key()
|
||||
.map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?
|
||||
.is_some_and(|stored| !stored.key.is_empty());
|
||||
if has_key {
|
||||
return Ok(());
|
||||
}
|
||||
let grant = cloud_auth::CLOUD_AUTH
|
||||
.create_mcp_key(&mcp_remote_key_label())
|
||||
.await?;
|
||||
settings_manager
|
||||
.store_mcp_remote_key(&grant.key, &grant.id)
|
||||
.map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))
|
||||
}
|
||||
|
||||
/// Migrate away from the removed local MCP endpoint, once per launch.
|
||||
///
|
||||
/// A paid, signed-in user has their clients that still point at local rewritten
|
||||
/// to remote MCP (minting a remote key if they have none), so their agents keep
|
||||
/// working from anywhere with no action. Anyone else who still has local
|
||||
/// installed, or the legacy `mcp_enabled` flag on, gets the deprecation event
|
||||
/// the desktop turns into a gentle "local MCP is going away" notice — there is
|
||||
/// no paid remote endpoint to move them to.
|
||||
pub async fn migrate_local_mcp_clients(app_handle: tauri::AppHandle) {
|
||||
let mut local_agents = mcp_integrations::agents_on_endpoint(mcp_integrations::McpEndpoint::Local);
|
||||
if claude_desktop_status().endpoint == Some(mcp_integrations::McpEndpoint::Local) {
|
||||
local_agents.push("claude-desktop".to_string());
|
||||
}
|
||||
let mcp_was_enabled = settings_manager::SettingsManager::instance()
|
||||
.load_settings()
|
||||
.map(|settings| settings.mcp_enabled)
|
||||
.unwrap_or(false);
|
||||
if local_agents.is_empty() && !mcp_was_enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
let paid = cloud_auth::CLOUD_AUTH.is_logged_in().await
|
||||
&& cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await;
|
||||
|
||||
if !local_agents.is_empty() && paid {
|
||||
if let Err(e) = ensure_remote_mcp_key().await {
|
||||
log::warn!("[mcp] Could not provision a remote MCP key to migrate local clients: {e}");
|
||||
let _ = crate::events::emit_empty(mcp_server::LOCAL_MCP_DEPRECATED_EVENT);
|
||||
return;
|
||||
}
|
||||
let target = match mcp_target_for(&app_handle, mcp_integrations::McpEndpoint::Remote).await {
|
||||
Ok(target) => target,
|
||||
Err(e) => {
|
||||
log::warn!("[mcp] Could not resolve the remote MCP target for migration: {e}");
|
||||
let _ = crate::events::emit_empty(mcp_server::LOCAL_MCP_DEPRECATED_EVENT);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut migrated = 0usize;
|
||||
let mut failed: Vec<String> = Vec::new();
|
||||
for agent in &local_agents {
|
||||
match install_mcp_agent(agent, &target) {
|
||||
Ok(()) => migrated += 1,
|
||||
Err(e) => {
|
||||
log::warn!("[mcp] Could not migrate {agent} to remote MCP: {e}");
|
||||
failed.push(agent.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"[mcp] Migrated {migrated} local MCP client(s) to remote MCP ({} could not be rewritten)",
|
||||
failed.len()
|
||||
);
|
||||
let _ = crate::events::emit(
|
||||
"mcp-local-migrated",
|
||||
serde_json::json!({ "migrated": migrated, "failed": failed }),
|
||||
);
|
||||
} else {
|
||||
// Free or signed-out: nothing paid to migrate them to, so tell them plainly.
|
||||
let _ = crate::events::emit_empty(mcp_server::LOCAL_MCP_DEPRECATED_EVENT);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reinstall_mcp_agents(
|
||||
app_handle: &tauri::AppHandle,
|
||||
endpoint: mcp_integrations::McpEndpoint,
|
||||
) -> Vec<String> {
|
||||
/// Ids of every client whose Donut entry points at `endpoint`, Claude Desktop
|
||||
/// included (its bundle is inspected here, not by `mcp_integrations`).
|
||||
fn mcp_clients_on(endpoint: mcp_integrations::McpEndpoint) -> Vec<String> {
|
||||
let mut agents = mcp_integrations::agents_on_endpoint(endpoint);
|
||||
if claude_desktop_status().endpoint == Some(endpoint) {
|
||||
agents.push("claude-desktop".to_string());
|
||||
}
|
||||
agents
|
||||
}
|
||||
|
||||
/// Re-run the install for every client whose entry points at `endpoint`, so a
|
||||
/// rotated credential or a moved local port and token do not leave them
|
||||
/// talking to a dead target. Every client is attempted; the ids of the ones
|
||||
/// that could not be rewritten come back, each already logged with its reason.
|
||||
pub async fn reinstall_mcp_agents(
|
||||
app_handle: &tauri::AppHandle,
|
||||
endpoint: mcp_integrations::McpEndpoint,
|
||||
) -> Vec<String> {
|
||||
let agents = mcp_clients_on(endpoint);
|
||||
let target = match mcp_target_for(app_handle, endpoint).await {
|
||||
Ok(target) => target,
|
||||
Err(e) => {
|
||||
@@ -2822,11 +2745,10 @@ pub fn run_with_builder(
|
||||
.await;
|
||||
});
|
||||
|
||||
// Local MCP is removed. Move anyone still on it forward: paid users are
|
||||
// migrated to remote MCP, everyone else is told it is going away.
|
||||
let migrate_handle = app.handle().clone();
|
||||
// Local MCP is removed. An account that can use remote MCP is offered
|
||||
// the move in the app; everyone else still on it is told it is gone.
|
||||
tauri::async_runtime::spawn(async move {
|
||||
migrate_local_mcp_clients(migrate_handle).await;
|
||||
mcp_migration::announce_local_mcp_removal().await;
|
||||
});
|
||||
|
||||
let settings_mgr = settings_manager::SettingsManager::instance();
|
||||
@@ -3627,6 +3549,9 @@ pub fn run_with_builder(
|
||||
get_mcp_remote_credential,
|
||||
rotate_mcp_remote_credential,
|
||||
forget_mcp_remote_credential,
|
||||
mcp_migration::get_mcp_migration_offer,
|
||||
mcp_migration::mark_mcp_migration_offered,
|
||||
mcp_migration::turn_off_local_mcp_server,
|
||||
// VPN commands
|
||||
import_vpn_config,
|
||||
list_vpn_configs,
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! The move from the removed local MCP server to remote MCP.
|
||||
//!
|
||||
//! Nothing here rewrites a client. The desktop offers the move in a dialog,
|
||||
//! at most once per cloud account, and the dialog runs it through the
|
||||
//! existing commands (`start_mcp_remote_bridge`, the credential commands and
|
||||
//! `add_mcp_to_agent`) so every client reports its own result.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::cloud_auth::CLOUD_AUTH;
|
||||
use crate::mcp_integrations::McpEndpoint;
|
||||
use crate::settings_manager::{AppSettings, SettingsManager};
|
||||
|
||||
/// What the desktop needs to decide whether to offer the move.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct McpMigrationOffer {
|
||||
/// Local MCP is in use here, and the signed-in account may use remote MCP.
|
||||
pub eligible: bool,
|
||||
/// `eligible`, and this account has not had the offer yet.
|
||||
pub due: bool,
|
||||
/// The legacy `mcp_enabled` flag, which keeps the local tombstone bound.
|
||||
pub local_server_enabled: bool,
|
||||
/// Ids of the clients whose Donut entry still points at the local server.
|
||||
pub local_clients: Vec<String>,
|
||||
}
|
||||
|
||||
fn local_mcp_in_use(settings: &AppSettings, local_clients: &[String]) -> bool {
|
||||
settings.mcp_enabled || !local_clients.is_empty()
|
||||
}
|
||||
|
||||
/// The decision itself. `account_id` is the signed-in cloud user, and
|
||||
/// `entitled` is the server's answer for that account.
|
||||
fn offer_for(
|
||||
settings: &AppSettings,
|
||||
account_id: Option<&str>,
|
||||
local_clients: Vec<String>,
|
||||
entitled: bool,
|
||||
) -> McpMigrationOffer {
|
||||
let eligible = account_id.is_some() && entitled && local_mcp_in_use(settings, &local_clients);
|
||||
let offered = account_id.is_some_and(|id| {
|
||||
settings
|
||||
.mcp_migration_offered_for
|
||||
.iter()
|
||||
.any(|seen| seen == id)
|
||||
});
|
||||
McpMigrationOffer {
|
||||
eligible,
|
||||
due: eligible && !offered,
|
||||
local_server_enabled: settings.mcp_enabled,
|
||||
local_clients,
|
||||
}
|
||||
}
|
||||
|
||||
/// Remembers the offer as made to `account_id`. Answers whether the settings
|
||||
/// changed, so a repeated call writes nothing.
|
||||
fn record_offered(settings: &mut AppSettings, account_id: &str) -> bool {
|
||||
if settings
|
||||
.mcp_migration_offered_for
|
||||
.iter()
|
||||
.any(|id| id == account_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
settings
|
||||
.mcp_migration_offered_for
|
||||
.push(account_id.to_string());
|
||||
true
|
||||
}
|
||||
|
||||
/// The server's answer, or no. The move is never offered on a guess: the
|
||||
/// cached entitlement is wrong for a team member, see
|
||||
/// `fetch_remote_control_entitlement`.
|
||||
async fn remote_mcp_entitled() -> bool {
|
||||
match CLOUD_AUTH.fetch_remote_control_entitlement().await {
|
||||
Ok(entitled) => entitled,
|
||||
Err(e) => {
|
||||
log::warn!("[mcp-migration] Could not ask whether remote MCP is available: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_settings() -> Result<AppSettings, String> {
|
||||
SettingsManager::instance()
|
||||
.load_settings()
|
||||
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_mcp_migration_offer() -> Result<McpMigrationOffer, String> {
|
||||
let settings = load_settings()?;
|
||||
let local_clients = crate::mcp_clients_on(McpEndpoint::Local);
|
||||
let account_id = CLOUD_AUTH.get_user().await.map(|state| state.user.id);
|
||||
// A network call, so it is made only when there is something to move and
|
||||
// an account to move it for.
|
||||
let entitled = account_id.is_some()
|
||||
&& local_mcp_in_use(&settings, &local_clients)
|
||||
&& remote_mcp_entitled().await;
|
||||
Ok(offer_for(
|
||||
&settings,
|
||||
account_id.as_deref(),
|
||||
local_clients,
|
||||
entitled,
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mark_mcp_migration_offered() -> Result<(), String> {
|
||||
let Some(state) = CLOUD_AUTH.get_user().await else {
|
||||
return Err(crate::backend_error("MCP_REMOTE_REQUIRES_SIGN_IN"));
|
||||
};
|
||||
let mut settings = load_settings()?;
|
||||
if record_offered(&mut settings, &state.user.id) {
|
||||
SettingsManager::instance()
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unbind the local tombstone and clear the flag that binds it at launch.
|
||||
///
|
||||
/// Safe to call when nothing is bound: the tombstone may have failed to bind,
|
||||
/// and the flag is what the next launch reads.
|
||||
#[tauri::command]
|
||||
pub async fn turn_off_local_mcp_server(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
let server = crate::mcp_server::McpServer::instance();
|
||||
if server.is_running() {
|
||||
if let Err(e) = server.stop().await {
|
||||
if !e.contains("MCP_SERVER_NOT_RUNNING") {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
let manager = SettingsManager::instance();
|
||||
let mut settings = load_settings()?;
|
||||
if settings.mcp_enabled {
|
||||
settings.mcp_enabled = false;
|
||||
manager
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?;
|
||||
}
|
||||
manager
|
||||
.remove_mcp_token(&app_handle)
|
||||
.await
|
||||
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))
|
||||
}
|
||||
|
||||
/// Tell anyone still on the removed local server that it is gone.
|
||||
///
|
||||
/// An account that can move to remote MCP is not told here: the move dialog
|
||||
/// explains the change and does the move, and a notice under it would say the
|
||||
/// same thing twice.
|
||||
pub async fn announce_local_mcp_removal() {
|
||||
let Ok(settings) = SettingsManager::instance().load_settings() else {
|
||||
return;
|
||||
};
|
||||
if !local_mcp_in_use(&settings, &crate::mcp_clients_on(McpEndpoint::Local)) {
|
||||
return;
|
||||
}
|
||||
if CLOUD_AUTH.is_logged_in().await && remote_mcp_entitled().await {
|
||||
log::info!(
|
||||
"[mcp-migration] Local MCP is still in use; the move to remote MCP is offered in the app"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let _ = crate::events::emit_empty(crate::mcp_server::LOCAL_MCP_DEPRECATED_EVENT);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn local(ids: &[&str]) -> Vec<String> {
|
||||
ids.iter().map(|id| id.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_offer_needs_local_use_a_signed_in_account_and_remote_mcp() {
|
||||
let settings = AppSettings::default();
|
||||
let offer = offer_for(&settings, Some("u1"), local(&["cursor"]), true);
|
||||
assert!(offer.eligible && offer.due);
|
||||
assert_eq!(offer.local_clients, local(&["cursor"]));
|
||||
assert!(!offer.local_server_enabled);
|
||||
|
||||
assert_eq!(
|
||||
offer_for(&settings, Some("u1"), local(&["cursor"]), false),
|
||||
McpMigrationOffer {
|
||||
eligible: false,
|
||||
due: false,
|
||||
local_server_enabled: false,
|
||||
local_clients: local(&["cursor"]),
|
||||
},
|
||||
"an account without remote MCP is never offered the move"
|
||||
);
|
||||
assert!(
|
||||
!offer_for(&settings, None, local(&["cursor"]), true).eligible,
|
||||
"nobody signed in, nobody to move"
|
||||
);
|
||||
assert!(
|
||||
!offer_for(&settings, Some("u1"), Vec::new(), true).eligible,
|
||||
"nothing on the local server, nothing to move"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_local_server_flag_alone_is_local_use() {
|
||||
let settings = AppSettings {
|
||||
mcp_enabled: true,
|
||||
..AppSettings::default()
|
||||
};
|
||||
let offer = offer_for(&settings, Some("u1"), Vec::new(), true);
|
||||
assert!(offer.due);
|
||||
assert!(offer.local_server_enabled);
|
||||
assert!(offer.local_clients.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_offer_is_made_once_per_account() {
|
||||
let mut settings = AppSettings::default();
|
||||
assert!(record_offered(&mut settings, "u1"));
|
||||
assert!(
|
||||
!record_offered(&mut settings, "u1"),
|
||||
"a repeat writes nothing"
|
||||
);
|
||||
assert_eq!(settings.mcp_migration_offered_for, local(&["u1"]));
|
||||
|
||||
let offered = offer_for(&settings, Some("u1"), local(&["cursor"]), true);
|
||||
assert!(
|
||||
offered.eligible && !offered.due,
|
||||
"still eligible, so the Integrations page can offer it by hand"
|
||||
);
|
||||
assert!(
|
||||
offer_for(&settings, Some("u2"), local(&["cursor"]), true).due,
|
||||
"another account on the same desktop has its own offer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_from_before_the_offer_parse_with_nobody_offered() {
|
||||
let settings: AppSettings =
|
||||
serde_json::from_str(r#"{ "mcp_enabled": true }"#).expect("old settings parse");
|
||||
assert!(settings.mcp_migration_offered_for.is_empty());
|
||||
assert!(offer_for(&settings, Some("u1"), Vec::new(), true).due);
|
||||
}
|
||||
}
|
||||
@@ -2291,9 +2291,9 @@ impl McpServer {
|
||||
log::info!("[mcp] Server started on port {}", actual_port);
|
||||
|
||||
// Local MCP is removed: the listener above is a tombstone, so there is
|
||||
// nothing to (re)install into a client here. Migrating clients that still
|
||||
// point at the old local endpoint onto remote MCP is done once at startup
|
||||
// (see `crate::migrate_local_mcp_clients`), not on every bind.
|
||||
// nothing to (re)install into a client here. Clients that still point at
|
||||
// the old local endpoint are moved to remote MCP from the app's move
|
||||
// dialog (see `crate::mcp_migration`), not on any bind.
|
||||
let _ = installer_handle;
|
||||
Ok(actual_port)
|
||||
}
|
||||
|
||||
@@ -96,10 +96,14 @@ pub struct AppSettings {
|
||||
/// flow, so the automatic flow never repeats one.
|
||||
#[serde(default)]
|
||||
pub tips_seen: Vec<String>,
|
||||
/// Unix seconds of the last tip that opened by itself. Paces the automatic
|
||||
/// flow to one tip a day at most.
|
||||
/// Unix seconds of the last tip that opened by itself.
|
||||
#[serde(default)]
|
||||
pub tips_last_auto_shown_at: Option<u64>,
|
||||
/// Unix seconds before which no tip opens by itself. Drawn once, when an
|
||||
/// automatic tip opens, a random 2 to 5 days later, so a restart keeps the
|
||||
/// same wait.
|
||||
#[serde(default)]
|
||||
pub tips_next_auto_show_at: Option<u64>,
|
||||
/// Cloud user ids that have had the paid-plan welcome.
|
||||
#[serde(default)]
|
||||
pub paid_welcome_seen_for: Vec<String>,
|
||||
@@ -107,6 +111,10 @@ pub struct AppSettings {
|
||||
/// A change from free to paid is what earns the paid-plan welcome.
|
||||
#[serde(default)]
|
||||
pub cloud_plan_memory: std::collections::HashMap<String, String>,
|
||||
/// Cloud user ids that have been offered the move from local MCP to remote
|
||||
/// MCP, so the move dialog opens by itself at most once per account.
|
||||
#[serde(default)]
|
||||
pub mcp_migration_offered_for: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
@@ -127,9 +135,11 @@ fn default_tips_auto_show() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// How long the automatic tip flow waits between two tips, so a busy day of
|
||||
/// restarts does not turn into a tip on every launch.
|
||||
pub const TIPS_AUTO_INTERVAL_SECS: u64 = 20 * 60 * 60;
|
||||
/// The automatic tip flow waits a random gap between two tips, drawn from
|
||||
/// this range (both ends included), so tips arrive now and then instead of on
|
||||
/// a fixed day and a busy day of restarts never turns into a tip per launch.
|
||||
pub const TIPS_AUTO_GAP_MIN_SECS: u64 = 2 * 24 * 60 * 60;
|
||||
pub const TIPS_AUTO_GAP_MAX_SECS: u64 = 5 * 24 * 60 * 60;
|
||||
|
||||
/// The plan status remembered per cloud user.
|
||||
const PLAN_STATUS_PAID: &str = "paid";
|
||||
@@ -163,8 +173,10 @@ impl Default for AppSettings {
|
||||
tips_auto_show: true,
|
||||
tips_seen: Vec::new(),
|
||||
tips_last_auto_shown_at: None,
|
||||
tips_next_auto_show_at: None,
|
||||
paid_welcome_seen_for: Vec::new(),
|
||||
cloud_plan_memory: std::collections::HashMap::new(),
|
||||
mcp_migration_offered_for: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -633,6 +645,7 @@ pub async fn save_app_settings(
|
||||
settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed;
|
||||
settings.mcp_remote_enabled = current.mcp_remote_enabled;
|
||||
settings.mcp_remote_key_id = current.mcp_remote_key_id;
|
||||
settings.mcp_migration_offered_for = current.mcp_migration_offered_for;
|
||||
}
|
||||
} else {
|
||||
settings.mcp_remote_enabled = false;
|
||||
@@ -906,25 +919,41 @@ pub struct TipsState {
|
||||
pub auto_show: bool,
|
||||
pub seen: Vec<String>,
|
||||
pub last_auto_shown_at: Option<u64>,
|
||||
/// Unix seconds from which the next automatic tip may open, or `None`
|
||||
/// before the first one.
|
||||
pub next_auto_show_at: Option<u64>,
|
||||
/// Whether the automatic flow may open a tip right now: it is switched on
|
||||
/// and the last automatic tip is old enough.
|
||||
/// and the wait after the last automatic tip is over.
|
||||
pub auto_due: bool,
|
||||
}
|
||||
|
||||
impl TipsState {
|
||||
fn of(settings: &AppSettings, now: u64) -> Self {
|
||||
// Settings written before the random gap existed carry only the time of
|
||||
// the last automatic tip; the shortest gap applies to those.
|
||||
let next_auto_show_at = settings.tips_next_auto_show_at.or_else(|| {
|
||||
settings
|
||||
.tips_last_auto_shown_at
|
||||
.map(|last| last.saturating_add(TIPS_AUTO_GAP_MIN_SECS))
|
||||
});
|
||||
Self {
|
||||
auto_show: settings.tips_auto_show,
|
||||
seen: settings.tips_seen.clone(),
|
||||
last_auto_shown_at: settings.tips_last_auto_shown_at,
|
||||
auto_due: settings.tips_auto_show
|
||||
&& settings
|
||||
.tips_last_auto_shown_at
|
||||
.is_none_or(|last| now.saturating_sub(last) >= TIPS_AUTO_INTERVAL_SECS),
|
||||
next_auto_show_at,
|
||||
auto_due: settings.tips_auto_show && next_auto_show_at.is_none_or(|next| now >= next),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When the next automatic tip may open, after one opened at `now`. `roll` is
|
||||
/// any random number; it selects a gap from `TIPS_AUTO_GAP_MIN_SECS` to
|
||||
/// `TIPS_AUTO_GAP_MAX_SECS`.
|
||||
fn next_auto_tip_at(now: u64, roll: u64) -> u64 {
|
||||
let span = TIPS_AUTO_GAP_MAX_SECS - TIPS_AUTO_GAP_MIN_SECS + 1;
|
||||
now.saturating_add(TIPS_AUTO_GAP_MIN_SECS + roll % span)
|
||||
}
|
||||
|
||||
/// Serialises every read-modify-write of the tips fields. Two tips shown in
|
||||
/// quick succession are two concurrent commands, and without this the second
|
||||
/// load could precede the first save and drop it.
|
||||
@@ -938,13 +967,20 @@ fn unix_now() -> u64 {
|
||||
}
|
||||
|
||||
/// Remembers a tip as shown. `auto` marks it as the tip that opened by
|
||||
/// itself, which restarts the daily pacing.
|
||||
fn record_tip_seen(settings: &mut AppSettings, tip_id: &str, auto: bool, now: u64) {
|
||||
/// itself, which starts a new random wait (see `next_auto_tip_at`) unless
|
||||
/// one is still running, so a repeated report of the same tip draws nothing.
|
||||
fn record_tip_seen(settings: &mut AppSettings, tip_id: &str, auto: bool, now: u64, roll: u64) {
|
||||
if !settings.tips_seen.iter().any(|id| id == tip_id) {
|
||||
settings.tips_seen.push(tip_id.to_string());
|
||||
}
|
||||
if auto {
|
||||
settings.tips_last_auto_shown_at = Some(now);
|
||||
if settings
|
||||
.tips_next_auto_show_at
|
||||
.is_none_or(|next| next <= now)
|
||||
{
|
||||
settings.tips_next_auto_show_at = Some(next_auto_tip_at(now, roll));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1009,7 +1045,7 @@ pub async fn mark_tip_seen(tip_id: String, auto: bool) -> Result<TipsState, Stri
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
let now = unix_now();
|
||||
record_tip_seen(&mut settings, &tip_id, auto, now);
|
||||
record_tip_seen(&mut settings, &tip_id, auto, now, rand::random::<u64>());
|
||||
manager
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| format!("Failed to save settings: {e}"))?;
|
||||
@@ -1131,35 +1167,86 @@ mod tests {
|
||||
assert!(state.auto_show);
|
||||
assert!(state.seen.is_empty());
|
||||
assert_eq!(state.last_auto_shown_at, None);
|
||||
assert_eq!(state.next_auto_show_at, None);
|
||||
assert!(state.auto_due, "a fresh install owes its first tip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gap_between_automatic_tips_is_two_to_five_days() {
|
||||
const DAY: u64 = 24 * 60 * 60;
|
||||
let now = 1_000;
|
||||
assert_eq!(next_auto_tip_at(now, 0), now + 2 * DAY);
|
||||
let span = TIPS_AUTO_GAP_MAX_SECS - TIPS_AUTO_GAP_MIN_SECS + 1;
|
||||
assert_eq!(next_auto_tip_at(now, span - 1), now + 5 * DAY);
|
||||
assert_eq!(next_auto_tip_at(now, span), now + 2 * DAY, "the roll wraps");
|
||||
for roll in [1, 7, DAY, 3 * DAY + 11, u64::MAX / 3, u64::MAX] {
|
||||
let next = next_auto_tip_at(now, roll);
|
||||
assert!(
|
||||
(now + 2 * DAY..=now + 5 * DAY).contains(&next),
|
||||
"roll {roll} gave a gap of {} seconds",
|
||||
next - now
|
||||
);
|
||||
}
|
||||
assert_eq!(next_auto_tip_at(u64::MAX - 1, 5), u64::MAX, "no overflow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tips_seen_dedupes_and_paces_the_automatic_flow() {
|
||||
const DAY: u64 = 24 * 60 * 60;
|
||||
let mut settings = AppSettings::default();
|
||||
record_tip_seen(&mut settings, "dns", false, 100);
|
||||
record_tip_seen(&mut settings, "dns", false, 200);
|
||||
record_tip_seen(&mut settings, "dns", false, 100, 0);
|
||||
record_tip_seen(&mut settings, "dns", false, 200, 0);
|
||||
assert_eq!(settings.tips_seen, vec!["dns".to_string()]);
|
||||
assert_eq!(
|
||||
settings.tips_last_auto_shown_at, None,
|
||||
"a browsed tip must not restart the daily pacing"
|
||||
"a browsed tip must not start a wait"
|
||||
);
|
||||
assert_eq!(settings.tips_next_auto_show_at, None);
|
||||
|
||||
record_tip_seen(&mut settings, "proxy", true, 1_000);
|
||||
// A roll of one day past the minimum: this tip's gap is three days.
|
||||
record_tip_seen(&mut settings, "proxy", true, 1_000, DAY);
|
||||
assert_eq!(settings.tips_last_auto_shown_at, Some(1_000));
|
||||
assert!(
|
||||
!TipsState::of(&settings, 1_000 + TIPS_AUTO_INTERVAL_SECS - 1).auto_due,
|
||||
"the next automatic tip waits a day"
|
||||
let next = 1_000 + 3 * DAY;
|
||||
assert_eq!(settings.tips_next_auto_show_at, Some(next));
|
||||
let state = TipsState::of(&settings, next - 1);
|
||||
assert_eq!(state.next_auto_show_at, Some(next));
|
||||
assert!(!state.auto_due, "the next automatic tip waits out the gap");
|
||||
assert!(TipsState::of(&settings, next).auto_due);
|
||||
|
||||
// The same automatic tip reported again, or a browsed one, while the gap
|
||||
// runs does not draw a new one.
|
||||
record_tip_seen(&mut settings, "proxy", true, 1_500, 0);
|
||||
record_tip_seen(&mut settings, "groups", false, 1_600, 0);
|
||||
assert_eq!(settings.tips_next_auto_show_at, Some(next));
|
||||
|
||||
// Once the gap is over, the next automatic tip draws a fresh one.
|
||||
record_tip_seen(&mut settings, "groups", true, next + 10, 0);
|
||||
assert_eq!(
|
||||
settings.tips_next_auto_show_at,
|
||||
Some(next + 10 + TIPS_AUTO_GAP_MIN_SECS)
|
||||
);
|
||||
assert!(TipsState::of(&settings, 1_000 + TIPS_AUTO_INTERVAL_SECS).auto_due);
|
||||
|
||||
settings.tips_auto_show = false;
|
||||
assert!(
|
||||
!TipsState::of(&settings, 1_000 + TIPS_AUTO_INTERVAL_SECS * 3).auto_due,
|
||||
!TipsState::of(&settings, next + 10 * DAY).auto_due,
|
||||
"switched off means never due"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_from_before_the_random_gap_wait_the_shortest_gap() {
|
||||
let settings: AppSettings =
|
||||
serde_json::from_str(r#"{ "tips_last_auto_shown_at": 1000 }"#).expect("old settings parse");
|
||||
assert_eq!(settings.tips_next_auto_show_at, None);
|
||||
let state = TipsState::of(&settings, 1_000 + TIPS_AUTO_GAP_MIN_SECS - 1);
|
||||
assert_eq!(
|
||||
state.next_auto_show_at,
|
||||
Some(1_000 + TIPS_AUTO_GAP_MIN_SECS)
|
||||
);
|
||||
assert!(!state.auto_due);
|
||||
assert!(TipsState::of(&settings, 1_000 + TIPS_AUTO_GAP_MIN_SECS).auto_due);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paid_welcome_is_due_once_when_an_account_turns_paid() {
|
||||
let mut settings = AppSettings::default();
|
||||
@@ -1277,8 +1364,10 @@ mod tests {
|
||||
tips_auto_show: true,
|
||||
tips_seen: Vec::new(),
|
||||
tips_last_auto_shown_at: None,
|
||||
tips_next_auto_show_at: None,
|
||||
paid_welcome_seen_for: Vec::new(),
|
||||
cloud_plan_memory: std::collections::HashMap::new(),
|
||||
mcp_migration_offered_for: Vec::new(),
|
||||
};
|
||||
|
||||
let save_result = manager.save_settings(&test_settings);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.31.0",
|
||||
"version": "0.31.1",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
+70
-40
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user