diff --git a/AGENTS.md b/AGENTS.md index 5b55229f..4c408b85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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..{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 diff --git a/e2e/app/Cargo.lock b/e2e/app/Cargo.lock index cbf8bd18..86a267eb 100644 --- a/e2e/app/Cargo.lock +++ b/e2e/app/Cargo.lock @@ -1720,7 +1720,7 @@ dependencies = [ [[package]] name = "donutbrowser" -version = "0.30.0" +version = "0.31.1" dependencies = [ "aes", "aes-gcm", diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index 444da5e4..d4d02743 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -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", diff --git a/e2e/tests/smoke.test.mjs b/e2e/tests/smoke.test.mjs index 26b65da7..fc0aebb2 100644 --- a/e2e/tests/smoke.test.mjs +++ b/e2e/tests/smoke.test.mjs @@ -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", diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index 83a399a4..38541c76 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -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;`); + } + }); +}); diff --git a/package.json b/package.json index f03d17a7..23baeb46 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ada2fafe..2065abbc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1738,7 +1738,7 @@ dependencies = [ [[package]] name = "donutbrowser" -version = "0.31.0" +version = "0.31.1" dependencies = [ "aes", "aes-gcm", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bfafcff1..b3f36676 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index 151ed3dd..de5edc11 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -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 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bd0ac0ae..5139ce10 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 = 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 { +/// 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 { 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 { + 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, diff --git a/src-tauri/src/mcp_migration.rs b/src-tauri/src/mcp_migration.rs new file mode 100644 index 00000000..6b7e7b24 --- /dev/null +++ b/src-tauri/src/mcp_migration.rs @@ -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, +} + +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, + 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 { + 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 { + 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 { + 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); + } +} diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 0a708e6b..aca52b0f 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -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) } diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index fff0e063..616e65b5 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -96,10 +96,14 @@ pub struct AppSettings { /// flow, so the automatic flow never repeats one. #[serde(default)] pub tips_seen: Vec, - /// 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, + /// 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, /// Cloud user ids that have had the paid-plan welcome. #[serde(default)] pub paid_welcome_seen_for: Vec, @@ -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, + /// 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, } #[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, pub last_auto_shown_at: Option, + /// Unix seconds from which the next automatic tip may open, or `None` + /// before the first one. + pub next_auto_show_at: Option, /// 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()); 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); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3d6c70ee..4227fc87 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/app/page.tsx b/src/app/page.tsx index efde20cd..f3cd0f58 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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("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); }} /> + { + if (!open) mcpMigration.close(); + }} + /> { - failed_clients?: string[]; -} - -function credentialPrefixOf( - credential: Pick | 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 = { - "claude-desktop": SiClaude, - "claude-code": SiClaude, - cursor: SiCursor, - vscode: VscVscode, - windsurf: SiWindsurf, - zed: SiZedindustries, - }; - const brand = marks[id]; - if (brand) { - const Mark = brand; - return